#!/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)); }