Memoire de mesures : PostgreSQL et InfluxDB sur le second Synology
Le proprietaire a tranche : on met les deux en service et on decidera a l usage s il faut garder les deux. Elles ne repondent pas a la meme question. PostgreSQL garde ce que n8n CALCULE — sorties du bilan, decisions du controleur, prevision telle qu elle a ete publiee — parce que ce ne sont pas des capteurs, donc qu aucune statistique Home Assistant ne les retient, et parce que ce sont des vecteurs, du texte et des jointures. Cinq tables, une vue de confort qui repond a la question restee sans reponse le 03/09 : ce que le bilan exigeait contre le SOC reellement atteint, heure par heure. InfluxDB garde ce que Home Assistant OBSERVE, a la resolution native et sur tout le perimetre energie, y compris les entites auxquelles personne n a encore pense. C est exactement ce qui a manque le 03/09, quand les modes des chargeurs MPPT se sont reveles etre le bon signal alors que personne ne les enregistrait. Les deux sur 10.10.10.22 : cette machine ne porte qu un service, quand 10.10.10.17 en porte quatre. Le volume n est pas l argument, les deux Synology ont des teraoctets libres. L alimentation de PostgreSQL ne passe volontairement PAS par n8n. Tout ce que n8n calcule, il le publie deja dans des helpers Home Assistant : les lire donne la meme information sans toucher aux trois workflows de production stabilises hier soir ni creer d identifiant dans n8n. L horodatage retenu est celui de Home Assistant et non celui du sondage, si bien qu une valeur inchangee porte la meme clef primaire et n est pas reecrite : on enregistre quand la grandeur bouge, pas quand on regarde. Essai a blanc verifie sur l installation reelle : 15 mesures, 1 ligne de bilan, 1 decision, 2 previsions, avec les bonnes dates. Le mode blanc comptait d abord les lignes ecrites, donc zero par construction — corrige pour compter ce qu il ecrirait, sans quoi il ne verifiait rien. Le branchement de Home Assistant sur InfluxDB demande un redemarrage et n est donc PAS applique : le fichier de configuration est pret, a poser en milieu de matinee, jamais entre 01:24 et 07:24. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KKzkkVTG6KXnGXFFC7X9XJ
This commit is contained in:
273
tools/historise-evcs.mjs
Normal file
273
tools/historise-evcs.mjs
Normal file
@@ -0,0 +1,273 @@
|
||||
#!/usr/bin/env node
|
||||
// =====================================================================
|
||||
// Historisation EVCS — alimente PostgreSQL depuis Home Assistant.
|
||||
// =====================================================================
|
||||
//
|
||||
// POURQUOI PAS DEPUIS n8n. C'était l'idée de départ (« n8n calcule déjà ce qui
|
||||
// compte, l'écrire est une ligne de plus »). Mais tout ce que n8n calcule, il
|
||||
// le PUBLIE déjà dans des helpers Home Assistant : SOC requis, heure de
|
||||
// croisement, décisions, prévisions. Les lire ici donne la même information
|
||||
// sans toucher aux trois workflows de production qu'on vient de stabiliser, et
|
||||
// sans créer d'identifiant de connexion dans n8n.
|
||||
//
|
||||
// Ce service tourne sur 10.10.10.22, à côté de la base : si 10.10.10.17 tombe,
|
||||
// il n'y a de toute façon plus rien à enregistrer, mais la mémoire déjà
|
||||
// constituée, elle, survit.
|
||||
//
|
||||
// L'HORODATAGE EST CELUI DE HOME ASSISTANT, pas celui du sondage. Une valeur
|
||||
// inchangée depuis le cycle précédent porte donc la même clé primaire et n'est
|
||||
// pas réécrite (ON CONFLICT DO NOTHING) : on enregistre quand la grandeur a
|
||||
// bougé, pas quand on a regardé.
|
||||
//
|
||||
// Usage :
|
||||
// node tools/historise-evcs.mjs # boucle
|
||||
// node tools/historise-evcs.mjs --once # un cycle puis sortie
|
||||
// node tools/historise-evcs.mjs --once --dry-run # sans écrire
|
||||
// =====================================================================
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import pg from 'pg';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const ONCE = process.argv.includes('--once');
|
||||
const DRY = process.argv.includes('--dry-run');
|
||||
|
||||
const HA_URL = process.env.HA_URL || 'http://10.10.10.17:8123';
|
||||
const INTERVAL_S = Number(process.env.HISTORISE_INTERVAL_S || 60);
|
||||
|
||||
function fromEnvFile(cle) {
|
||||
const p = path.join(ROOT, '.env');
|
||||
if (!fs.existsSync(p)) return null;
|
||||
for (const l of fs.readFileSync(p, 'utf8').split('\n')) {
|
||||
const m = l.match(new RegExp(`^\\s*${cle}\\s*=\\s*(.+?)\\s*$`));
|
||||
if (m) return m[1].replace(/^["']|["']$/g, '');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const HA_TOKEN = process.env.HA_TOKEN || fromEnvFile('HA_TOKEN');
|
||||
if (!HA_TOKEN) {
|
||||
console.error('HA_TOKEN absent (ni variable d\'environnement, ni .env).');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Grandeurs suivies. Élargir ici est sans risque : une clé nouvelle
|
||||
// commence simplement à s'enregistrer. Retirer une clé n'efface rien.
|
||||
// ---------------------------------------------------------------------
|
||||
const MESURES = [
|
||||
'sensor.victron_battery_soc',
|
||||
'sensor.victron_battery_current',
|
||||
'sensor.victron_battery_power',
|
||||
'sensor.victron_battery_voltage',
|
||||
'sensor.victron_pv_power_system',
|
||||
'sensor.victron_consumption_power_l1',
|
||||
'sensor.victron_grid_power_l1',
|
||||
'sensor.victron_evcs_ac_power',
|
||||
'sensor.victron_evcs_current',
|
||||
'sensor.victron_vebus_dc_power_system',
|
||||
'sensor.sun_solar_elevation',
|
||||
'sensor.victron_max_charge_current_setting',
|
||||
'number.gx_device_ac_power_setpoint',
|
||||
'input_number.evcs_balance_required_soc',
|
||||
'input_number.evcs_balance_energy_to_cover_kwh',
|
||||
'input_number.victron_evcs_night_target_soc',
|
||||
];
|
||||
|
||||
const num = (v) => {
|
||||
const n = Number.parseFloat(v);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
};
|
||||
const txt = (s) => (s ?? '').toString().trim();
|
||||
|
||||
async function etats() {
|
||||
const r = await fetch(`${HA_URL}/api/states`, {
|
||||
headers: { Authorization: `Bearer ${HA_TOKEN}` },
|
||||
});
|
||||
if (!r.ok) throw new Error(`HA ${r.status}`);
|
||||
const tout = await r.json();
|
||||
const m = new Map();
|
||||
for (const s of tout) m.set(s.entity_id, s);
|
||||
return m;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Écritures. Chacune est idempotente : rejouer un cycle n'invente rien.
|
||||
// ---------------------------------------------------------------------
|
||||
async function ecrireMesures(sql, st) {
|
||||
let n = 0;
|
||||
for (const id of MESURES) {
|
||||
const e = st.get(id);
|
||||
if (!e) continue;
|
||||
const v = num(e.state);
|
||||
if (v === null) continue; // 'unknown' / 'unavailable' : on ne devine pas
|
||||
const { rowCount } = await sql(
|
||||
`INSERT INTO mesure (ts, cle, valeur) VALUES ($1, $2, $3)
|
||||
ON CONFLICT (cle, ts) DO NOTHING`,
|
||||
[e.last_updated, id, v]
|
||||
);
|
||||
n += rowCount;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
async function ecrireBilan(sql, st) {
|
||||
const marqueur = st.get('input_text.evcs_balance_last_run');
|
||||
if (!marqueur || !txt(marqueur.state)) return 0;
|
||||
const g = (id) => st.get(id);
|
||||
const { rowCount } = await sql(
|
||||
`INSERT INTO bilan (ts, statut, soc_pct, energie_a_couvrir_kwh, soc_requis_pct,
|
||||
soc_requis_prudent_pct, heure_croisement, reseau_necessaire,
|
||||
reseau_energie_kwh, manque_stockage_kwh, pv_censure,
|
||||
motif_censure, orage_niveau, orage_reserve_kwh, brut)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15)
|
||||
ON CONFLICT (ts) DO NOTHING`,
|
||||
[
|
||||
marqueur.last_updated,
|
||||
txt(g('input_text.evcs_balance_status')?.state),
|
||||
num(g('sensor.victron_battery_soc')?.state),
|
||||
num(g('input_number.evcs_balance_energy_to_cover_kwh')?.state),
|
||||
num(g('input_number.evcs_balance_required_soc')?.state),
|
||||
num(g('input_number.evcs_balance_required_soc_prudent')?.state),
|
||||
txt(g('input_text.evcs_balance_crossing_hour')?.state),
|
||||
txt(g('input_boolean.evcs_balance_grid_needed')?.state) === 'on',
|
||||
num(g('input_number.evcs_balance_grid_energy_needed_kwh')?.state),
|
||||
num(g('input_number.evcs_balance_storage_shortfall_kwh')?.state),
|
||||
txt(g('input_boolean.evcs_balance_pv_censored')?.state) === 'on',
|
||||
txt(g('input_text.evcs_balance_censor_reason')?.state),
|
||||
txt(g('input_text.evcs_balance_storm_level')?.state),
|
||||
num(g('input_number.evcs_balance_storm_reserve_kwh')?.state),
|
||||
JSON.stringify({
|
||||
raison: txt(g('input_text.evcs_balance_reason')?.state),
|
||||
fenetre_prochain_deficit: txt(g('input_text.evcs_balance_next_deficit_window')?.state),
|
||||
soc_requis_prochain_deficit: num(g('input_number.evcs_balance_next_deficit_required_soc')?.state),
|
||||
}),
|
||||
]
|
||||
);
|
||||
return rowCount;
|
||||
}
|
||||
|
||||
async function ecrireDecision(sql, st) {
|
||||
const etat = st.get('input_text.victron_evcs_surplus_state');
|
||||
if (!etat) return 0;
|
||||
const g = (id) => st.get(id);
|
||||
const { rowCount } = await sql(
|
||||
`INSERT INTO decision (ts, profil, source_mode, etat, decision, charge_desiree,
|
||||
courant_a, soc_pct, cible_matin_pct, takeover_reseau,
|
||||
session_engagee, raison, brut)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)
|
||||
ON CONFLICT (ts) DO NOTHING`,
|
||||
[
|
||||
etat.last_updated,
|
||||
txt(g('input_text.victron_evcs_operating_mode')?.state),
|
||||
txt(g('input_text.victron_evcs_source_mode')?.state),
|
||||
txt(etat.state),
|
||||
txt(g('input_text.victron_evcs_surplus_current_decision')?.state),
|
||||
num(g('sensor.victron_evcs_ac_power')?.state) > 0,
|
||||
num(g('number.victron_evcs_set_current_command')?.state),
|
||||
num(g('sensor.victron_battery_soc')?.state),
|
||||
num(g('input_number.victron_evcs_night_target_soc')?.state),
|
||||
txt(g('input_boolean.victron_evcs_night_grid_takeover_needed')?.state) === 'on',
|
||||
txt(g('input_boolean.victron_evcs_night_grid_session_committed')?.state) === 'on',
|
||||
txt(g('input_text.victron_evcs_surplus_reason')?.state),
|
||||
JSON.stringify({
|
||||
session_nuit: txt(g('input_boolean.victron_evcs_night_session_active')?.state),
|
||||
edf: txt(g('switch.edf_general_ok_switch')?.state),
|
||||
strategie: txt(g('input_select.victron_evcs_strategy')?.state),
|
||||
vehicule: txt(g('input_select.victron_evcs_vehicle')?.state),
|
||||
}),
|
||||
]
|
||||
);
|
||||
return rowCount;
|
||||
}
|
||||
|
||||
// La prévision n'a de valeur que datée de sa PUBLICATION : sans cela on ne peut
|
||||
// pas mesurer son biais, qui est précisément la question ouverte (§42).
|
||||
async function ecrirePrevision(sql, st) {
|
||||
const marqueur = st.get('input_text.evcs_balance_forecast_updated');
|
||||
if (!marqueur || !txt(marqueur.state)) return 0;
|
||||
const publieA = marqueur.last_updated;
|
||||
const auj = new Date(publieA);
|
||||
let n = 0;
|
||||
for (const [id, decalageJours] of [
|
||||
['input_text.evcs_balance_forecast_today', 0],
|
||||
['input_text.evcs_balance_forecast_tomorrow', 1],
|
||||
]) {
|
||||
const e = st.get(id);
|
||||
if (!e) continue;
|
||||
const v = txt(e.state).split(',').map(Number);
|
||||
if (v.length !== 24 || v.some((x) => !Number.isFinite(x))) continue;
|
||||
const jour = new Date(auj.getTime() + decalageJours * 86400000)
|
||||
.toLocaleDateString('sv-SE', { timeZone: 'Europe/Paris' }); // AAAA-MM-JJ
|
||||
const { rowCount } = await sql(
|
||||
`INSERT INTO prevision (publie_a, jour, total_kwh, horaire)
|
||||
VALUES ($1,$2,$3,$4) ON CONFLICT (publie_a, jour) DO NOTHING`,
|
||||
[publieA, jour, v.reduce((a, b) => a + b, 0), v]
|
||||
);
|
||||
n += rowCount;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
async function cycle(client) {
|
||||
// En blanc, on compte ce qu'on ECRIRAIT. Un essai a blanc qui renvoie
|
||||
// toujours zero ne verifie rien — c'est le piege classique du test qui ne
|
||||
// regarde que le chemin heureux.
|
||||
const sql = DRY
|
||||
? async (q, p) => {
|
||||
if (process.env.HISTORISE_VERBOSE) {
|
||||
console.log(' ' + q.split('\n')[0].trim() + ' <- ' + JSON.stringify(p).slice(0, 120));
|
||||
}
|
||||
return { rowCount: 1 };
|
||||
}
|
||||
: (q, p) => client.query(q, p);
|
||||
const st = await etats();
|
||||
const r = {
|
||||
mesures: await ecrireMesures(sql, st),
|
||||
bilan: await ecrireBilan(sql, st),
|
||||
decision: await ecrireDecision(sql, st),
|
||||
prevision: await ecrirePrevision(sql, st),
|
||||
};
|
||||
const total = r.mesures + r.bilan + r.decision + r.prevision;
|
||||
if (total > 0 || ONCE) {
|
||||
console.log(
|
||||
`${new Date().toLocaleString('fr-FR', { timeZone: 'Europe/Paris' })} ` +
|
||||
`${DRY ? '[BLANC] ' : ''}mesures=${r.mesures} bilan=${r.bilan} ` +
|
||||
`decision=${r.decision} prevision=${r.prevision}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const client = new pg.Client({
|
||||
host: process.env.PGHOST || '10.10.10.22',
|
||||
port: Number(process.env.PGPORT || 5432),
|
||||
user: process.env.PGUSER || 'evcs',
|
||||
database: process.env.PGDATABASE || 'evcs',
|
||||
password: process.env.PGPASSWORD,
|
||||
});
|
||||
|
||||
if (!DRY) await client.connect();
|
||||
console.log(
|
||||
`Historisation EVCS — HA=${HA_URL} base=${DRY ? '(blanc)' : client.host + ':' + client.port} ` +
|
||||
`intervalle=${INTERVAL_S}s`
|
||||
);
|
||||
|
||||
if (ONCE) {
|
||||
await cycle(client);
|
||||
if (!DRY) await client.end();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Une panne d'un cycle ne doit pas tuer la boucle : Home Assistant redémarre,
|
||||
// le réseau hoquette, la base se recharge. On journalise et on retente.
|
||||
for (;;) {
|
||||
try {
|
||||
await cycle(client);
|
||||
} catch (e) {
|
||||
console.error(`${new Date().toISOString()} cycle en echec : ${e.message}`);
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, INTERVAL_S * 1000));
|
||||
}
|
||||
Reference in New Issue
Block a user