Files
EVCS/tools/historise-evcs.mjs
Tallagrand Thierry 931e07eca9 Le service d historisation consigne lui-meme les evenements qu il constate
Une courbe sans ses evenements ne s interprete pas. Le service voit passer les
etats toutes les minutes : autant qu il en tire les conclusions plutot que d
attendre que quelqu un y pense.

Douze etats DISCRETS suivis — contacteur EDF, session reseau de nuit, verrou
hysteresis, vehicule branche, strategie, vehicule selectionne, etat BatteryLife,
mode de l Auto Charge Manager, reseau necessaire, vigilance orage, production
bridee. Les grandeurs continues restent dans mesure et les decisions dans
decision : un evenement qui double une autre table est du bruit. Une lecture
unavailable n est pas une transition mais une absence de lecture, elle n est pas
consignee.

Le redemarrage de Home Assistant est detecte sans capteur d uptime, qui n existe
pas ici : au redemarrage HA restaure ses helpers et leur pose un last_changed
neuf. Le 04/09 a 07:28:49, 352 entites partageaient la seconde du redemarrage
dont 183 input_number. Le service prend donc le mode des last_changed des
input_number — aucune entite codee en dur, et l estimation se corrige d
elle-meme puisque entre deux redemarrages seuls les helpers reellement ecrits s
ecartent du mode. Sous dix helpers d accord, la signature est jugee trop faible
et rien n est affirme.

Un episode d indisponibilite de Home Assistant est journalise AU RETOUR avec sa
duree : au moment de la panne, la base n est peut-etre pas joignable non plus.

Au premier cycle l etat est enregistre SANS rien journaliser, sinon chaque
redemarrage du service produirait douze fausses transitions ; seul le demarrage
du service est consigne.

Verifie de bout en bout en conditions reelles et non seulement a blanc : essai a
blanc avec cycle court, bascule reelle du selecteur de vehicule puis
restauration. Amorcage sans fausse transition, cycle sans changement a zero
evenement, les deux bascules relevees, etat restaure.

Documente aussi le GRANT INSERT sur la seule table evenement pour le role d
analyse — insert seulement, ni update ni delete : on ajoute une annotation,
jamais on ne reecrit ni n efface les precedentes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KKzkkVTG6KXnGXFFC7X9XJ
2026-09-04 07:43:49 +02:00

408 lines
17 KiB
JavaScript

#!/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',
];
// ---------------------------------------------------------------------
// EVENEMENTS DETECTES AUTOMATIQUEMENT
// ---------------------------------------------------------------------
// « Une courbe sans ses evenements ne s'interprete pas » : six mois plus tard,
// une discontinuite ne veut rien dire sans la ligne qui dit ce qui s'est passe
// ce jour-la. Le service voit passer les etats toutes les minutes — autant
// qu'il consigne lui-meme ce qu'il peut constater, plutot que d'attendre que
// quelqu'un y pense.
//
// On suit des etats DISCRETS dont chaque changement merite une ligne. Les
// grandeurs continues n'ont rien a faire ici (elles sont dans `mesure`), et les
// decisions du controleur non plus (elles sont dans `decision`) : un evenement
// qui double une autre table est du bruit.
const TRANSITIONS_SUIVIES = [
['switch.edf_general_ok_switch', 'reseau', 'Contacteur EDF'],
['input_boolean.victron_evcs_night_grid_session_committed','reseau', 'Session reseau de nuit'],
['input_boolean.victron_evcs_night_grid_takeover_needed', 'reseau', 'Reprise reseau nuit'],
['input_boolean.victron_grid_hysteresis_latch', 'reseau', 'Verrou hysteresis'],
['binary_sensor.victron_evcs_vehicle_connected', 'vehicule', 'Vehicule branche'],
['input_select.victron_evcs_vehicle', 'intervention','Vehicule selectionne'],
['input_select.victron_evcs_strategy', 'intervention','Strategie'],
['sensor.gx_device_ess_batterylife_state', 'ess', 'Etat BatteryLife de l ESS'],
['input_text.victron_charge_mode', 'ess', 'Mode Auto Charge Manager'],
['input_boolean.evcs_balance_grid_needed', 'bilan', 'Reseau necessaire'],
['input_boolean.evcs_balance_storm_active', 'bilan', 'Vigilance orage'],
['input_boolean.evcs_balance_pv_censored', 'bilan', 'Production bridee'],
];
// Heure de demarrage de Home Assistant, sans capteur d'uptime — il n'y en a pas
// sur cette installation. Au redemarrage, HA restaure ses helpers et leur pose
// un `last_changed` neuf : le 04/09, 352 entites partageaient la seconde du
// redemarrage, dont 183 input_number. Le MODE des last_changed des
// input_number est donc l'heure de demarrage. Aucune entite codee en dur, et
// l'estimation se corrige d'elle-meme : entre deux redemarrages, seuls les
// quelques helpers reellement ecrits s'ecartent du mode.
function demarrageHaMs(st) {
const paquets = new Map();
for (const [id, e] of st) {
if (!id.startsWith('input_number.')) continue;
const t = Date.parse(e.last_changed);
if (!Number.isFinite(t)) continue;
const cle = Math.floor(t / 1000);
paquets.set(cle, (paquets.get(cle) || 0) + 1);
}
let meilleur = null, n = 0;
for (const [cle, c] of paquets) if (c > n) { n = c; meilleur = cle; }
// Moins de 10 helpers d'accord : signature trop faible pour affirmer quoi que
// ce soit. On ne devine pas.
return n >= 10 ? meilleur * 1000 : null;
}
async function journaliser(sql, genre, resume, detail) {
await sql(
`INSERT INTO evenement (genre, source, resume, detail) VALUES ($1, $2, $3, $4)`,
[genre, 'historisation', resume, JSON.stringify(detail || {})]
);
console.log(` evenement [${genre}] ${resume}`);
}
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;
}
// Memoire entre cycles. Au PREMIER passage on enregistre l'etat sans rien
// journaliser : sinon chaque redemarrage du service produirait douze fausses
// transitions. Seul le demarrage du service lui-meme est consigne.
const memoire = { amorce: false, demarrageHa: null, valeurs: new Map() };
async function detecterEvenements(sql, st) {
if (!memoire.amorce) {
for (const [id] of TRANSITIONS_SUIVIES.map((t) => [t[0]])) {
const e = st.get(id);
if (e) memoire.valeurs.set(id, txt(e.state));
}
memoire.demarrageHa = demarrageHaMs(st);
memoire.amorce = true;
await journaliser(sql, 'systeme', 'Service d historisation demarre', {
suivis: TRANSITIONS_SUIVIES.length,
});
return 1;
}
let n = 0;
// Redemarrage de Home Assistant. Tolerance de 5 s : l'estimation par mode
// peut bouger d'une seconde sans qu'il se soit rien passe.
const demarrage = demarrageHaMs(st);
if (
demarrage !== null &&
memoire.demarrageHa !== null &&
demarrage > memoire.demarrageHa + 5000
) {
await journaliser(sql, 'systeme', 'Home Assistant a redemarre', {
demarre_a: new Date(demarrage).toISOString(),
precedent: new Date(memoire.demarrageHa).toISOString(),
});
n++;
}
if (demarrage !== null) memoire.demarrageHa = demarrage;
// Transitions suivies.
for (const [id, genre, libelle] of TRANSITIONS_SUIVIES) {
const e = st.get(id);
if (!e) continue;
const cur = txt(e.state);
// Une lecture indisponible n'est pas une transition : c'est une absence de
// lecture. La consigner ferait deux lignes de bruit a chaque hoquet MQTT.
if (cur === '' || cur === 'unavailable' || cur === 'unknown') continue;
const prec = memoire.valeurs.get(id);
if (prec === undefined) { memoire.valeurs.set(id, cur); continue; }
if (prec === cur) continue;
await journaliser(sql, genre, `${libelle} : ${prec} -> ${cur}`, {
entite: id, avant: prec, apres: cur,
horodatage_ha: e.last_changed,
});
memoire.valeurs.set(id, cur);
n++;
}
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),
evenement: await detecterEvenements(sql, st),
};
const total = r.mesures + r.bilan + r.decision + r.prevision + r.evenement;
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} evenement=${r.evenement}`
);
}
}
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.
// Un episode d'indisponibilite est un evenement a part entiere : c'est
// exactement ce qu'on voudra retrouver devant un trou dans les courbes. Il est
// journalise au RETOUR, avec sa duree — au moment de la panne, la base n'est
// peut-etre pas joignable non plus.
let echecDepuis = null;
for (;;) {
try {
await cycle(client);
if (echecDepuis !== null) {
const duree = Math.round((Date.now() - echecDepuis) / 1000);
try {
await journaliser(client.query.bind(client), 'systeme',
`Home Assistant injoignable pendant ${duree} s`,
{ depuis: new Date(echecDepuis).toISOString(), secondes: duree });
} catch { /* la base non plus n'etait peut-etre pas la : on n'insiste pas */ }
echecDepuis = null;
}
} catch (e) {
if (echecDepuis === null) echecDepuis = Date.now();
console.error(`${new Date().toISOString()} cycle en echec : ${e.message}`);
}
await new Promise((r) => setTimeout(r, INTERVAL_S * 1000));
}