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:
Tallagrand Thierry
2026-09-04 01:11:09 +02:00
parent b27c91a3f6
commit 230039d2a2
9 changed files with 817 additions and 2 deletions

273
tools/historise-evcs.mjs Normal file
View 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));
}