#!/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', // Le plafond impose par le BMS lui-meme, a distinguer de la limite DVCC // ci-dessus que l'Auto Charge Manager ecrit. C'est LUI qui s'effondre quand // une cellule atteint 3550 mV, et il commande tout le parc. 'sensor.airtria_8_us2000c_maximum_allowed_charge_current', 'sensor.airtria_8_us2000c_maximum_allowed_charging_voltage', 'sensor.victron_max_charge_voltage_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 = [ ['sensor.airtria_8_us2000c_tension_de_cellule_elevee', 'batterie', 'Alarme tension de cellule'], ['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; } // --------------------------------------------------------------------- // Le service fait evoluer son propre schema. `schema.sql` n'est applique qu'a // la CREATION de la base : une table ajoutee plus tard n'y arriverait jamais. // `IF NOT EXISTS` rend l'operation rejouable a chaque demarrage. // --------------------------------------------------------------------- async function migrer(client) { await client.query(` CREATE TABLE IF NOT EXISTS session_charge ( debut timestamptz PRIMARY KEY, fin timestamptz NOT NULL, terminee boolean NOT NULL DEFAULT false, vehicule text, vehicule_sur boolean NOT NULL DEFAULT true, energie_kwh double precision NOT NULL DEFAULT 0, p_max_w double precision, courant_max_a double precision, consigne_max_a double precision, source_mode text, debranche_a timestamptz, brut jsonb )`); // Part venue du reseau. On se fie au `source_mode` du controleur — son // INTENTION, sans ambiguite — et non a la puissance reseau instantanee, qui // peut alimenter la maison pendant que le soleil alimente la voiture. await client.query( 'ALTER TABLE session_charge ADD COLUMN IF NOT EXISTS energie_reseau_kwh ' + 'double precision NOT NULL DEFAULT 0'); // Le compteur de la BORNE, qui fait foi. Mon integration de la puissance // est robuste mais ne couvre que ce que le service a vu : le 05/09, la // session avait commence a 01:25 et le detecteur n'a compte qu'a partir de // 03:16 — 0,75 kWh au lieu de 8. La borne, elle, compte depuis le debut. await client.query( 'ALTER TABLE session_charge ADD COLUMN IF NOT EXISTS energie_station_kwh double precision'); await client.query( 'CREATE INDEX IF NOT EXISTS session_charge_debut_idx ON session_charge (debut DESC)'); // Tensions cellule par cellule des modules Pylontech. La source est la // console serie du BMS, pas Home Assistant : aucune de ces valeurs n'est // publiee cote Victron, qui n'expose que l'agregat du parc. // // L'identite d'un module est son CODE-BARRES, jamais son adresse : chaque // chaine renumerote ses modules a partir de 1, donc il existe deux // « module 1 » dans ce parc. await client.query(` CREATE TABLE IF NOT EXISTS cellule ( ts timestamptz NOT NULL, module text NOT NULL, cellule smallint NOT NULL, mv integer NOT NULL, soc smallint, courant_ma integer, etat text, source text NOT NULL, PRIMARY KEY (ts, module, cellule) )`); await client.query( 'CREATE INDEX IF NOT EXISTS cellule_module_ts_idx ON cellule (module, ts DESC)'); // Le role de lecture doit voir la nouvelle table. S'il n'existe pas encore, // on ne fait pas echouer le demarrage pour autant. await client.query(`DO $$ BEGIN GRANT SELECT ON session_charge TO evcs_lecture; EXCEPTION WHEN undefined_object THEN NULL; END $$`); } // --------------------------------------------------------------------- // Sessions de charge. // // Pourquoi une table plutot qu'un calcul a la demande : l'etiquette du // vehicule n'existe QUE pendant la session. `input_select.victron_evcs_vehicle` // est un reglage manuel, il change, et rien ne conserve sa valeur au moment ou // la charge a eu lieu. Sans cet enregistrement, une session d'il y a trois // semaines est anonyme pour toujours. // // ⚠️ On n'essaie PAS de deviner le vehicule. Mesure du 05/09 : a consigne // identique de 20 A, la VW accepte 3 680 W et la JEEP 3 655 W — 0,1 A d'ecart. // Ils sont electriquement indiscernables. Et l'heure ne dit rien non plus : en // semaine la JEEP charge le midi et la VW la nuit, mais le week-end c'est // l'inverse. On enregistre ce que le selecteur disait, et on note s'il a change // en cours de route. // --------------------------------------------------------------------- const SEUIL_CHARGE_W = 300; // au-dela, la voiture prend vraiment du courant const FIN_APRES_MIN = 10; // sous le seuil pendant ce temps : session close async function ecrireSession(sql, client, st) { // En blanc la base n'est pas connectee : on lit du vide plutot que d'echouer. // La logique se deroule alors comme si aucune session n'etait ouverte — ce // qui suffit a verifier le chemin d'ouverture, pas celui de cloture. const lire = DRY ? async () => ({ rows: [], rowCount: 0 }) : (q, v) => client.query(q, v); const g = (id) => st.get(id); const p = num(g('sensor.victron_evcs_ac_power')?.state) ?? 0; const connecte = txt(g('binary_sensor.victron_evcs_vehicle_connected')?.state) === 'on'; const vehicule = txt(g('input_select.victron_evcs_vehicle')?.state) || null; const maintenant = new Date(); let n = 0; const { rows } = await lire( 'SELECT * FROM session_charge WHERE NOT terminee ORDER BY debut DESC LIMIT 1'); const ouverte = rows[0] ?? null; if (p > SEUIL_CHARGE_W) { if (!ouverte) { await sql( `INSERT INTO session_charge (debut, fin, vehicule, energie_kwh, p_max_w, courant_max_a, consigne_max_a, source_mode, brut, energie_station_kwh) VALUES ($1,$1,$2,0,$3,$4,$5,$6,$7,$8) ON CONFLICT (debut) DO NOTHING`, [maintenant, vehicule, p, num(g('sensor.victron_evcs_current')?.state), num(g('sensor.victron_evcs_set_current')?.state), txt(g('input_text.victron_evcs_source_mode')?.state) || null, JSON.stringify({ profil: txt(g('input_text.victron_evcs_operating_mode')?.state) }), num(g('sensor.evcs_hq2239dnt7p_last_session_energy')?.state)]); n++; } else { // Le pas de temps est plafonne : un arret du service ne doit pas // extrapoler la derniere puissance connue sur plusieurs heures. const dt = Math.min((maintenant - new Date(ouverte.fin)) / 1000, 300); await sql( `UPDATE session_charge SET fin = $2, -- Transtypages EXPLICITES, sur TOUS les parametres. -- node-postgres envoie ses parametres sans type ; multiplier deux -- d'entre eux fait echouer PostgreSQL sur « operator is not unique: -- unknown * unknown ». Les DEUX lignes en souffraient — la premiere -- version de ce code n'a jamais reussi un seul UPDATE, ce qui ne -- s'est vu qu'au journal du conteneur : l'essai a blanc court-circuite -- l'envoi a la base et ne peut donc rien valider de ce genre. -- REGLE : tout parametre entrant dans un calcul porte son ::float8. energie_kwh = energie_kwh + $3::float8 * $4::float8 / 3600000.0, energie_reseau_kwh = energie_reseau_kwh + $8::float8 * $3::float8 * $4::float8 / 3600000.0, p_max_w = GREATEST(coalesce(p_max_w, 0), $3), courant_max_a = GREATEST(coalesce(courant_max_a, 0), coalesce($5, 0)), consigne_max_a = GREATEST(coalesce(consigne_max_a, 0), coalesce($6, 0)), energie_station_kwh = GREATEST(coalesce(energie_station_kwh, 0), coalesce($9::float8, 0)), vehicule = coalesce($7, vehicule), vehicule_sur = vehicule_sur AND ($7 IS NULL OR vehicule IS NULL OR vehicule = $7) WHERE debut = $1`, [ouverte.debut, maintenant, p, dt, num(g('sensor.victron_evcs_current')?.state), num(g('sensor.victron_evcs_set_current')?.state), vehicule, txt(g('input_text.victron_evcs_source_mode')?.state).startsWith('Réseau') ? 1 : 0, num(g('sensor.evcs_hq2239dnt7p_last_session_energy')?.state)]); n++; } } else if (ouverte) { const silence = (maintenant - new Date(ouverte.fin)) / 60000; if (silence >= FIN_APRES_MIN || !connecte) { await sql('UPDATE session_charge SET terminee = true WHERE debut = $1', [ouverte.debut]); await journaliser(sql, 'vehicule', `Session de charge terminee : ${ouverte.vehicule ?? 'vehicule inconnu'}, ` + `${Number(ouverte.energie_station_kwh ?? ouverte.energie_kwh).toFixed(2)} kWh`, { debut: ouverte.debut, vehicule: ouverte.vehicule, kwh: ouverte.energie_kwh }); n += 2; } } // L'heure de debranchement, notee sur la derniere session. Elle confirme le // vehicule apres coup : les deux partent a des heures differentes. if (!connecte) { const { rowCount } = await lire( `SELECT 1 FROM session_charge WHERE debranche_a IS NULL AND fin > now() - interval '18 hours' LIMIT 1`); if (rowCount) { await sql( `UPDATE session_charge SET debranche_a = $1 WHERE debranche_a IS NULL AND fin > now() - interval '18 hours'`, [maintenant]); 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), session: await ecrireSession(sql, client, st), evenement: await detecterEvenements(sql, st), }; const total = r.mesures + r.bilan + r.decision + r.prevision + r.evenement + r.session; 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} session=${r.session} 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(); await migrer(client); // schema a jour avant le premier cycle } 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)); }