#!/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'; import { texteRapport, envoyer } from './rapport-nuit.mjs'; 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.airtria_8_us2000c_maximum_allowed_discharge_current', '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'], // Avec quatre modules le plafond de decharge tombe a 100 A, soit ~5,1 kW, // quand la maison a deja pointe a 8 kW en cuisinant. Cette alarme dira si // les pointes du soir touchent la limite. ['sensor.airtria_8_us2000c_high_discharge_current', 'batterie', 'Alarme courant de decharge'], ['sensor.airtria_8_us2000c_low_voltage', 'batterie', 'Alarme tension basse'], ['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(); // --------------------------------------------------------------------- // QUI A PROVOQUE CETTE TRANSITION (12/09/2026) // --------------------------------------------------------------------- // Le 12/09, le contacteur reseau a bascule six fois en dix minutes. Trouver le // coupable a demande sept requetes enchainees — executions n8n des deux // workflows, liste des ecrivains, puis automations.yaml — parce que la table // des evenements disait QUE ca avait bascule, jamais PAR QUI. // // Home Assistant le sait pourtant : son logbook resout le contexte d un // changement d etat et nomme l automatisation, le script ou l utilisateur qui // l a declenche. Pour la coupure de 06:29:44 il rend : // context_entity_id : automation.victron_grid_off_si_soc_35 // context_message : triggered by numeric state of sensor.victron_battery_soc // // Un appel par transition reelle — quelques-uns par jour, pas par cycle. En cas // d echec on n invente rien : pas d auteur, et la ligne est ecrite quand meme. // --------------------------------------------------------------------- // QUI A PROVOQUE CETTE TRANSITION — reecrit le 14/09/2026 // --------------------------------------------------------------------- // ⚠️ La version precedente filtrait sur `x.context_name`. CE CHAMP N EXISTE // PAS dans le journal de bord de Home Assistant : la fonction rendait `null` a // tous les coups et les 416 transitions journalisees portaient un auteur vide. // Personne ne s en etait apercu parce qu un auteur absent ressemble a un auteur // inconnaissable. Or le contexte EST la, sous d autres noms. // // Trois signatures, relevees sur des transitions dont je connaissais l auteur : // // automatisation / script ligne voisine dont l entity_id est `script.*` ou // `automation.*` — elle se nomme elle-meme. // service exterieur (n8n) `context_user_id` sur la ligne cible, et la rafale // se CLOT par une ligne de NARRATION (un `message` // sans `state`) qui porte le nom du workflow et sa // raison : « Victron Auto Charge », « EVCS Bilan // energetique (ombre) ». // derivation ni l un ni l autre, mais `context_entity_id` : la // valeur DECOULE d un autre capteur. Ce n est PAS un // auteur et il ne faut pas l annoncer comme tel. // // ⚠️ ON BALAIE VERS L AVANT pour trouver la narration, jamais vers l arriere : // un workflow ecrit ses helpers PUIS se journalise. Vers l arriere on // ramasserait la narration du workflow PRECEDENT. Verifie sur le cas ou l Auto // Charge Manager et le Water Heater Advisor ecrivent dans la MEME seconde : // chaque helper revient bien a son propre workflow, 3 sur 3. const NARRATION = (x) => x && x.message !== undefined && x.state === undefined; const DELAI_NARRATION_MS = 1500; async function auteurDeLaTransition(entite, horodatageHa) { try { const t = Date.parse(horodatageHa); if (!Number.isFinite(t)) return null; const debut = new Date(t - 5000).toISOString(); const fin = new Date(t + 5000).toISOString(); const r = await fetch( `${HA_URL}/api/logbook/${debut}?end_time=${encodeURIComponent(fin)}&entity_id=${encodeURIComponent(entite)}`, { headers: { Authorization: `Bearer ${HA_TOKEN}` }, signal: AbortSignal.timeout(8000) }, ); if (!r.ok) return null; const lignes = await r.json(); if (!Array.isArray(lignes)) return null; const i = lignes.findIndex((x) => x.entity_id === entite && x.state !== undefined && Math.abs(Date.parse(x.when) - t) < 2000); if (i < 0) return null; const cible = lignes[i]; const acteur = lignes.find((x) => /^(automation|script)\./.test(String(x.entity_id || '')) && Math.abs(Date.parse(x.when) - t) < 2000); if (acteur) { return { auteur: acteur.entity_id, nom: acteur.name || acteur.entity_id, type: acteur.entity_id.split('.')[0], declencheur: cible.context_service || null }; } if (cible.context_user_id) { const n = lignes.slice(i + 1).find(NARRATION); if (n && Date.parse(n.when) - t < DELAI_NARRATION_MS) { // `declencheur` porte la RAISON que le workflow a lui-meme publiee. // C est ce que « expliquer les decisions » reclamait, et c etait deja la. return { auteur: n.entity_id || n.name, nom: n.name, type: 'service', declencheur: (n.message || '').slice(0, 200) }; } return { auteur: cible.context_user_id, nom: 'service externe (non nomme)', type: 'service', declencheur: cible.context_domain && cible.context_service ? `${cible.context_domain}.${cible.context_service}` : null }; } if (cible.context_entity_id) { return { auteur: cible.context_entity_id, nom: cible.context_entity_id, type: 'derivation', declencheur: null }; } return null; } catch { return null; } } 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; } // Une observation par run du conseiller. La cle est l'horodatage de SON // marqueur : rejouer un cycle n'invente rien, et un conseiller a l'arret // n'ecrit pas la meme observation toutes les minutes. async function ecrireObservation(sql, st) { const marqueur = st.get('input_text.evcs_advisor_last_run'); if (!marqueur || !txt(marqueur.state)) return 0; const g = (id) => txt(st.get(id)?.state); // `_reason` porte « [NN%] la piste ». On separe pour que la confiance // reste interrogeable sans avoir a decouper une chaine en SQL. const reason = g('input_text.evcs_advisor_reason') || ''; const m = reason.match(/^\[(\d+)%\]\s*(.*)$/s); const { rowCount } = await sql( `INSERT INTO observation (ts, statut, gravite, constat, ce_qui_cloche, piste, modele, soc_pct, brut) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) ON CONFLICT (ts) DO NOTHING`, [ marqueur.last_updated, g('input_text.evcs_advisor_status'), g('input_text.evcs_advisor_gravite'), g('input_text.evcs_advisor_constat'), g('input_text.evcs_advisor_ce_qui_cloche'), m ? m[2] : reason, g('input_text.evcs_advisor_model'), num(st.get('sensor.victron_battery_soc')?.state), JSON.stringify({ confiance_pct: m ? Number(m[1]) : null, reason_brut: reason }), ] ); 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; const qui = await auteurDeLaTransition(id, e.last_changed); // L auteur est colle au RESUME, pas seulement au detail : c est le resume // que Grafana affiche en annotation sur tous les tableaux et dans les // panneaux « journal des evenements », et que le rapport de nuit reprend. // Le mettre la le rend visible partout sans toucher a un seul tableau. // Une derivation n'est pas un ecrivain : « par sensor.X » serait un // contresens pour une valeur qui DECOULE d'un autre capteur. const resume = `${libelle} : ${prec} -> ${cur}` + (qui ? (qui.type === 'derivation' ? ` — derive de ${qui.nom}` : ` — par ${qui.nom}`) : ''); await journaliser(sql, genre, resume, { entite: id, avant: prec, apres: cur, horodatage_ha: e.last_changed, ...(qui ? { auteur: qui.auteur, auteur_nom: qui.nom, auteur_type: qui.type, declencheur: qui.declencheur } : {}), }); 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 false, 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'); // Le COMPTEUR CUMULATIF de la borne (`..._total_energy`, ~3 780 kWh au // 06/09). Sa difference entre l'ouverture et la cloture donne l'energie de // la session sans aucune ambiguite de semantique et sans erreur // d'integration — contrairement a `..._last_session_energy`, dont on ne // savait meme pas s'il decrivait la session en cours ou la precedente. // Il ne couvre que ce que le service a vu ; `duree_borne_min` // (`..._last_session_time`) dit si la borne, elle, en a vu davantage. for (const col of ['compteur_debut_kwh', 'energie_compteur_kwh', 'duree_borne_min']) { await client.query( `ALTER TABLE session_charge ADD COLUMN IF NOT EXISTS ${col} double precision`); } // --------------------------------------------------------------------- // `vehicule_sur` CHANGE DE SENS (14/09/2026) // --------------------------------------------------------------------- // Avant : « le selecteur n'a pas change PENDANT la session ». Une propriete // qui ne prouvait rien — le selecteur est reste sur VW du 07/09 au 13/09 et // onze sessions fausses portaient toutes `true`. Mesure faite depuis : sur // douze branchements, le selecteur n'a ete regle au bon moment QU'UNE FOIS. // Desormais : « un humain a confirme », et seul le concierge le pose. await client.query( 'ALTER TABLE session_charge ALTER COLUMN vehicule_sur SET DEFAULT false'); // Rattrapage des lignes anterieures, une seule fois. Rien n'ayant ete // confirme par le canal tracable, tout retombe a faux — SAUF les six lignes // que le proprietaire a lui-meme arbitrees le 13/09 (cf. la reparation // ci-dessus) : c'est une vraie confirmation humaine, seulement donnee de // vive voix. La perdre serait jeter l'information qu'il a fournie. // ⚠️ REJEU. La premiere version de ce bloc n'etait pas garde : a chaque // redemarrage elle remettait les lignes anterieures a faux puis les six // arbitrees a vrai. Le resultat restait JUSTE — l'operation est idempotente // en sortie — mais elle ecrivait une ligne de journal trompeuse a chaque // fois, quatre en une journee, qui serait apparue en annotation sur tous les // tableaux. On garde donc sur la trace elle-meme : si elle existe, c'est // fait. const { rows: dejaFait } = await client.query( `SELECT 1 FROM evenement WHERE resume LIKE 'Semantique de vehicule_sur%' LIMIT 1`); const { rowCount: bascules } = dejaFait.length ? { rowCount: 0 } : await client.query( `UPDATE session_charge SET vehicule_sur = false WHERE vehicule_sur AND debut < '2026-09-14'::date`); if (bascules) { await client.query( `UPDATE session_charge SET vehicule_sur = true WHERE debut = ANY($1::timestamptz[])`, [['2026-09-09 15:55:15.423+02', '2026-09-09 19:35:51.789+02', '2026-09-10 01:25:24.359+02', '2026-09-11 16:54:58.101+02', '2026-09-12 11:42:56.901+02', '2026-09-12 11:55:54.493+02']]); await journaliser(client.query.bind(client), 'vehicule', `Semantique de vehicule_sur inversee : ${bascules} lignes repassees a « non confirme »`, { origine: 'proprietaire', motif: 'le drapeau dit desormais « un humain a confirme »', confirmees_de_vive_voix: 6, date_arbitrage: '2026-09-13' }); } // ------------------------------------------------------------------- // ETIQUETTE DE BRANCHEMENT (14/09/2026) // ------------------------------------------------------------------- // Le concierge demandait « quel vehicule ? » a l ouverture d une SESSION de // charge. Trop tard : le 14/09 a 19:55 le vehicule etait branche, le // selecteur perime depuis six jours, et aucune question ne pouvait partir // parce qu aucune session n existait — la borne ne tirait rien, il faisait // nuit. La premiere session de la nuit serait donc nee mal etiquetee. // // On etiquette desormais le BRANCHEMENT, pas les sessions. C est d ailleurs // l invariant du projet : un cable, un vehicule. L etiquette se pose des le // branchement et s applique aux sessions AU FUR ET A MESURE qu elles // s ouvrent — cette table est la memoire qui le permet. await client.query(` CREATE TABLE IF NOT EXISTS etiquette_branchement ( branchement timestamptz PRIMARY KEY, label text NOT NULL, source_airtria text NOT NULL, confirmed_at text, pose_a timestamptz NOT NULL DEFAULT now(), sessions_vues integer NOT NULL DEFAULT 0 )`); await client.query( 'CREATE INDEX IF NOT EXISTS session_charge_debut_idx ON session_charge (debut DESC)'); // ------------------------------------------------------------------- // REPARATION UNIQUE (06/09/2026) — sessions faussees par le GREATEST // ------------------------------------------------------------------- // Chaque ligne est confrontee a l'integration de `victron_evcs_ac_power` // relue dans InfluxDB sur la periode de charge REELLE (detection des // periodes > 300 W separees par plus de dix minutes) : // // periode reelle energie reelle valeur en base ecart // 05 01:26-03:55 8,27 kWh 9,400 14 % -> gardee // 05 10:00-10:54 2,32 kWh 2,200 5 % -> gardee // 05 11:11-12:38 3,37 kWh 5,400 60 % -> effacee // 06 01:25-02:44 4,24 kWh 4,200 1 % -> gardee // 06 13:32-13:42 0,34 kWh 4,200 1135 % -> corrigee // // La derniere est celle qui a produit le double comptage dans le journal : // le total de la nuit, capture au premier echantillon et verrouille par le // GREATEST. Le capteur de la borne lisait 0,3 kWh au moment de la reparation, // et `last_session_time` 11 min — la duree exacte de cette session. // On ne corrige QUE ce qu'on a verifie ; les trois lignes coherentes ne sont // pas touchees, et celle a 60 % d'ecart est effacee plutot que devinee. // // ⚠️ `debut` porte les millisecondes : on cible une FENETRE D'UNE SECONDE, // pas une egalite. Deux sessions ne peuvent pas s'ouvrir dans la meme // seconde, et la condition sur la valeur attendue rend l'operation // idempotente — rejouee, elle ne trouve plus rien. const reparations = [ ['2026-09-06 13:31:18+02', 0.3, 4.2], ['2026-09-05 11:11:22+02', null, 5.4], ]; for (const [debut, valeur, attendu] of reparations) { await client.query( `UPDATE session_charge SET energie_station_kwh = $2 WHERE debut >= $1::timestamptz AND debut < $1::timestamptz + interval '1 second' AND energie_station_kwh = $3::float8`, [debut, valeur, attendu]); } // Meme jour, meme fonction : l'heure de debranchement etait posee en bloc sur // toutes les sessions non marquees des dix-huit dernieres heures. Le 05/09 a // 15:02:16, la session de nuit de la JEEP a donc herite du debranchement de // la VW. On ne sait pas quand la JEEP a ete debranchee : on efface plutot que // d'inventer. // Les deux sessions VW de la journee (10:00 et 11:11) appartiennent bien au // meme branchement que ce debranchement de 15:02 : elles sont conservees. await client.query( `UPDATE session_charge SET debranche_a = NULL WHERE debut >= '2026-09-05 02:56:13+02'::timestamptz AND debut < '2026-09-05 02:56:14+02'::timestamptz AND debranche_a >= '2026-09-05 15:02:16+02'::timestamptz AND debranche_a < '2026-09-05 15:02:17+02'::timestamptz`); // 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)'); // Rang du module dans SA chaine AU MOMENT du releve. Enregistre par ligne et // non dans une table de correspondance : chaque chaine renumerote a partir // de 1, et un recablage du LVHUB peut tout renumeroter — la valeur figee // mentirait sur l'historique. Absent des anciens releves => NULL. await client.query('ALTER TABLE cellule ADD COLUMN IF NOT EXISTS rang smallint'); // Ce que le CONSEILLER a observe. Ses helpers sont ecrases toutes les // quinze minutes : sans cette table, un signalement grave emis a 3 h du // matin a disparu au reveil. C'est precisement la trace que le proprietaire // demandait — voir un comportement idiot ne sert a rien si personne ne peut // le relire le lendemain. await client.query(` CREATE TABLE IF NOT EXISTS observation ( ts timestamptz PRIMARY KEY, statut text, gravite text, constat text, ce_qui_cloche text, piste text, modele text, soc_pct double precision, brut jsonb )`); // On interroge cette table par gravite bien plus souvent que par date : // « montre-moi tous les signalements graves de la semaine ». await client.query( 'CREATE INDEX IF NOT EXISTS observation_gravite_idx ON observation (gravite, ts DESC)'); // ------------------------------------------------------------------- // REPARATION UNIQUE (13/09/2026) — onze sessions etiquetees « VW » a tort // ------------------------------------------------------------------- // `input_select.victron_evcs_vehicle` est reste sur VW du 07/09 20:49 au // 13/09 : onze sessions consecutives ont herite de cette etiquette, toutes // avec `vehicule_sur = true` — ce drapeau ne passe a faux que si le // selecteur bouge PENDANT une session, donc un selecteur perime reste // « sur ». C'est le defaut qui a motive le concierge vehicule. // // Le proprietaire a tranche, et son arbitrage repose sur un contexte // qu'aucun capteur ne produit : sa femme (JEEP) etait en conges cette // semaine, son fils (VW) en stage sans retour le midi. // // mer 09/09 15:55 6,61 kWh -> JEEP | un SEUL branchement, du mercredi // mer 09/09 19:35 0,77 kWh -> JEEP | 15:55 au jeudi 09:05 : sa femme // jeu 10/09 01:25 4,88 kWh -> JEEP | a branche la JEEP, et le soir son // | fils n'a PAS debranche pour mettre // | la VW a la place — il a roule a // | l'essence le jeudi. Trois sessions, // | un seul cable, un seul vehicule. // ven 11/09 16:54 7,45 kWh -> MEGANE (voiture de service, voir ci-dessous) // sam 12/09 11:42 0,09 kWh -> JEEP (samedi midi, routine du week-end) // sam 12/09 11:55 5,94 kWh -> JEEP // // ⚠️ Le triplet du mercredi a d'abord ete corrige a MOITIE — 15:55 en JEEP, // les deux autres laissees en VW — ce qui etait physiquement impossible : il // n'y a qu'un point de charge. C'est le journal `evenement` qui l'a montre, // en enregistrant les transitions de `binary_sensor.victron_evcs_vehicle_connected` : // aucun debranchement entre 15:55 et le lendemain 09:05. La frontiere d'un // branchement est un FAIT observe, jamais un ecart de temps — les creux // soir/heures creuses font 5 h 20 et ne se distinguent pas d'une nuit entiere. // // La session du 11/09 n'est NI la VW NI la JEEP : 6 360 W a 28 A, quand les // deux vehicules du foyer plafonnent vers 3 680 W. C'est une Megane // electrique de service. Un vehicule invite se reconnait donc a sa // puissance, contrairement a la VW et a la JEEP entre elles. // // Les sept autres lignes etaient justes — par chance, la VW etant le // vehicule qui charge le plus souvent. Elles ne sont pas touchees. // // ⚠️ `debut` porte les millisecondes : fenetre d'une seconde, jamais une // egalite. La condition sur la valeur attendue rend l'operation idempotente. for (const [debut, etait, devient] of [ ['2026-09-09 15:55:00+02', 'VW', 'JEEP'], ['2026-09-09 19:35:00+02', 'VW', 'JEEP'], ['2026-09-10 01:25:00+02', 'VW', 'JEEP'], ['2026-09-11 16:54:00+02', 'VW', 'MEGANE'], ['2026-09-12 11:42:00+02', 'VW', 'JEEP'], ['2026-09-12 11:55:00+02', 'VW', 'JEEP'], ]) { await client.query( `UPDATE session_charge SET vehicule = $3 WHERE debut >= $1::timestamptz AND debut < $1::timestamptz + interval '1 minute' AND vehicule = $2`, [debut, etait, devient]); } // 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, cellule, observation 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 // "1h 49m 53s" / "8m 27s" / "0s" -> secondes. Meme lecture que INPUT_PARSE. // `sensor.victron_evcs_charging_time` repart de zero a CHAQUE nouvelle // connexion physique (c'est le signal du reset P11) et se fige a la valeur du // debranchement : il donne donc la duree exacte du branchement qui s'acheve. function dureeBranchementSec(s) { const t = (s ?? '').toString().toLowerCase(); if (!t) return null; let sec = 0, vu = false; const h = t.match(/(\d+)\s*h/); if (h) { sec += Number(h[1]) * 3600; vu = true; } const m = t.match(/(\d+)\s*m/); if (m) { sec += Number(m[1]) * 60; vu = true; } const q = t.match(/(\d+)\s*s/); if (q) { sec += Number(q[1]); vu = true; } return vu ? sec : null; } 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, compteur_debut_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_total_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)), -- vehicule_sur n'est PLUS touche ici, et la virgule de la ligne -- precedente a disparu avec lui. Le drapeau ne dit plus « le -- selecteur n'a pas bouge » — une propriete qui ne prouvait rien, -- un selecteur perime depuis six jours restant « sur » — mais -- « UN HUMAIN A CONFIRME ». Seule une reponse du proprietaire, -- via le concierge, peut le mettre a vrai. -- ⚠️ ET IL NE DOIT PAS ECRASER UNE CONFIRMATION HUMAINE. -- Defaut mesure le 16/09 : cette ligne reecrivait le vehicule depuis -- le selecteur A CHAQUE CYCLE d'une session ouverte, pendant que -- appliquerEtiquettesEnAttente remettait l'etiquette du concierge -- juste apres. Deux ecrivains sur la meme colonne, toutes les 60 s, -- et une ligne de journal a chaque passe : une soixantaine le 15/09. -- Le drapeau vehicule_sur dit « un humain a confirme » : quand il -- est vrai, le selecteur n'a plus voix au chapitre. vehicule = CASE WHEN vehicule_sur THEN vehicule ELSE coalesce($7, vehicule) END 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]); n++; } } else if (ouverte) { const silence = (maintenant - new Date(ouverte.fin)) / 60000; if (silence >= FIN_APRES_MIN || !connecte) { // Le compteur de la borne se lit UNE FOIS, A LA CLOTURE. // // Il etait auparavant echantillonne a chaque cycle et conserve par un // GREATEST : un operateur monotone, qui ne peut donc que SUREVALUER, et // qui verrouillait la premiere valeur vue. Le 06/09 la session de midi // (0,34 kWh reels, 0,324 integres) s'est refermee sur 4,20 kWh — le // total de la nuit precedente, capture au premier echantillon et jamais // relache. D'ou le meme chiffre annonce deux fois dans le journal. // // ⚠️ La semantique exacte de ce capteur n'est PAS etablie : il n'etait // pas historise, donc on n'a aucune serie pour trancher entre « energie // de la derniere session terminee » et « compteur de la session en cours // qui se reinitialise avec du retard ». Confrontation aux integrations // reelles des cinq sessions connues : ecarts de 1, 5, 14, 60 et 1135 %. // Il est donc enregistre comme une OBSERVATION, jamais substitue en // silence a notre integration. Le capteur vient d'etre ajoute a // l'historisation InfluxDB pour pouvoir enfin l'etablir. const station = num(g('sensor.evcs_hq2239dnt7p_last_session_energy')?.state); const dureeBorne = num(g('sensor.evcs_hq2239dnt7p_last_session_time')?.state); const compteur = num(g('sensor.evcs_hq2239dnt7p_total_energy')?.state); // Le compteur de la borne a une resolution de 0,1 kWh. La soustraction // de deux nombres proches de 3 800 en double precision rend // « 0.8000000000001819 » : treize decimales de fausse precision sur une // mesure qui n'en a qu'une. On arrondit a l'ECRITURE plutot que de // laisser chaque lecteur le faire — signale par la session OpenClaw le // 13/09 en lisant la table. const parCompteur = compteur !== null && ouverte.compteur_debut_kwh !== null && compteur >= ouverte.compteur_debut_kwh ? Math.round((compteur - Number(ouverte.compteur_debut_kwh)) * 1000) / 1000 : null; await sql( `UPDATE session_charge SET terminee = true, energie_station_kwh = $2, energie_compteur_kwh = $3, duree_borne_min = $4 WHERE debut = $1`, [ouverte.debut, station, parCompteur, dureeBorne]); // Le chiffre annonce est le NOTRE, celui qu'on sait defendre. Celui de la // borne n'apparait que s'il s'en ecarte notablement — soit qu'elle ait vu // un debut que le service a manque (le cas qui a motive cette colonne : // 8 kWh reels pour 0,75 integres le 05/09), soit qu'elle raconte autre // chose, et dans les deux cas ca se voit. // Le chiffre annonce est, par ordre de preference : la difference du // compteur cumulatif de la borne (exacte, sans integration), sinon notre // integration de la puissance. Celui de `last_session_energy` n'apparait // que s'il s'en ecarte notablement — soit que la borne ait vu un debut // que le service a manque (le cas qui a motive cette colonne : 8 kWh // reels pour 0,75 integres le 05/09), soit qu'elle raconte autre chose, // et dans les deux cas ca se voit au lieu de se substituer en silence. const retenu = parCompteur !== null ? parCompteur : Number(ouverte.energie_kwh); const ecart = station !== null && retenu > 0 ? Math.abs(station - retenu) / retenu : 0; await journaliser(sql, 'vehicule', `Session de charge terminee : ${ouverte.vehicule ?? 'vehicule inconnu'}, ` + `${retenu.toFixed(2)} kWh` + (ecart > 0.10 ? ` (borne : ${station.toFixed(2)} kWh sur ${dureeBorne ?? '?'} min)` : ''), { debut: ouverte.debut, vehicule: ouverte.vehicule, kwh: retenu, kwh_integre: Number(ouverte.energie_kwh), kwh_compteur: parCompteur, kwh_borne: station, duree_borne_min: dureeBorne }); n += 2; } } // L'heure de debranchement. Elle confirme le vehicule apres coup : les deux // partent a des heures differentes. // // ⚠️ Ne marquer QUE les sessions du branchement qui vient de s'achever. La // version d'origine marquait en bloc toutes les sessions non marquees des // dix-huit dernieres heures : le 05/09 a 15:02, la session de nuit de la // JEEP a herite de l'heure de debranchement de la VW — soit exactement // l'inverse de ce que cette colonne est censee etablir. La duree du // branchement borne la fenetre (5 min de marge pour le pas de mesure) ; // duree illisible ou nulle => on ne marque que la session la plus recente, // jamais celles d'avant. if (!connecte) { const dureeSec = dureeBranchementSec(txt(g('sensor.victron_evcs_charging_time')?.state)); const depuis = dureeSec !== null && dureeSec > 0 ? new Date(maintenant.getTime() - dureeSec * 1000 - 5 * 60000) : null; const { rows: aMarquer } = await lire( depuis ? `SELECT debut FROM session_charge WHERE debranche_a IS NULL AND fin > now() - interval '18 hours' AND debut >= $1` : `SELECT debut FROM session_charge WHERE debranche_a IS NULL AND fin > now() - interval '18 hours' ORDER BY debut DESC LIMIT 1`, depuis ? [depuis] : []); for (const r of aMarquer) { await sql('UPDATE session_charge SET debranche_a = $2 WHERE debut = $1', [r.debut, maintenant]); n++; } } return n; } // --------------------------------------------------------------------- // Le rapport de nuit, envoye par courriel une fois par jour. // // Pourquoi ici plutot que dans un cron ou dans une session interactive : c'est // ce service qui est allume en permanence. Une session Claude s'arrete des que // l'ecran se ferme, et le planificateur qu'elle porte s'arrete avec elle ; un // cron DSM tournerait sur un hote qui n'a meme pas node. Le service, lui, a // deja la base, le depot et une boucle a la minute. // // L'idempotence ne s'appuie sur aucun fichier d'etat : elle se lit dans la // table `evenement`, qui est de toute facon la trace que l'on veut garder. // Redemarrer le conteneur ne renvoie donc pas le rapport. // --------------------------------------------------------------------- const RAPPORT_HEURE = 6; const RAPPORT_MINUTE = 47; async function envoyerRapportDuMatin(sql, client) { if (!client) return 0; const parts = Object.fromEntries( new Intl.DateTimeFormat('fr-FR', { timeZone: 'Europe/Paris', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false, }).formatToParts(new Date()).filter((x) => x.type !== 'literal') .map((x) => [x.type, Number(x.value)])); const minutesDuJour = parts.hour * 60 + parts.minute; if (minutesDuJour < RAPPORT_HEURE * 60 + RAPPORT_MINUTE) return 0; // Fenetre de rattrapage : si le service etait arrete a 6 h 47, il envoie au // premier cycle suivant, mais pas au milieu de l'apres-midi. if (minutesDuJour > 10 * 60) return 0; const jour = `${parts.year}-${String(parts.month).padStart(2, '0')}-${String(parts.day).padStart(2, '0')}`; const { rowCount } = await client.query( `SELECT 1 FROM evenement WHERE genre = 'rapport' AND ts >= $1::date AND ts < $1::date + interval '1 day' LIMIT 1`, [jour]); if (rowCount) return 0; const texte = await texteRapport(client, new Date()); await envoyer(texte, 'EVCS - rapport de la nuit'); await journaliser(sql, 'rapport', 'Rapport de nuit envoye par courriel', { jour, caracteres: texte.length }); console.log(` rapport de nuit envoye (${texte.length} caracteres)`); return 1; } // --------------------------------------------------------------------- // Verse dans la base les relevés de cellule deposes par le poste qui porte le // cable serie. Celui-ci n'ecrit qu'un CSV dans le depot partage : il n'a donc // aucun mot de passe de base a connaitre, et le service d'ici, qui l'a deja, // fait l'ingestion. `ON CONFLICT DO NOTHING` rend l'operation idempotente, // donc relire le meme fichier a chaque cycle ne coute qu'une requete. const DOSSIER_CELLULES = 'docs/mesures/batteries'; async function ingererCellules(client) { let fichiers; try { fichiers = fs.readdirSync(DOSSIER_CELLULES); } catch { return 0; } let lignes = 0; for (const n of fichiers) { if (!n.endsWith('-suivi.csv')) continue; const f = path.join(DOSSIER_CELLULES, n); // Seuls les fichiers vivants : un releve arrete depuis plus d'une heure a // deja ete verse, le relire serait du bruit. if (Date.now() - fs.statSync(f).mtimeMs > 3600000) continue; const l = fs.readFileSync(f, 'utf8').trim().split('\n'); const cols = l[0].split(','); const iC = cols.indexOf('c1_mV'); if (iC < 0) continue; // Le courant du module, ajoute au releve le 17/09/2026. Repere PAR SON NOM : // les US5000 et les US2000C n'emettent pas les memes colonnes, et l'ancrer // sur une position ferait lire la mauvaise grandeur des qu'on deplace le // cable d'un pack a l'autre. Absent des anciens fichiers => null, donc les // lignes deja versees gardent leur courant inconnu. const iI = cols.indexOf('courant_mA'); // Le rang dans la chaine, ajoute au releve le 18/09/2026 — par son NOM, // comme le courant : la position en fin de ligne n'est pas un contrat. const iA = cols.indexOf('adresse'); const val = []; for (const ligne of l.slice(1)) { const c = ligne.split(','); if (c.length < iC + 15) continue; if (!/^[A-Z]\d{6}[A-Z]\d+$/.test(c[1])) continue; // sans code-barres, on ne devine pas // Signe : negatif en decharge, positif en charge — c'est ce qui permet de // filtrer « pendant la charge » sans deviner. const ma = iI >= 0 ? Number(c[iI]) : NaN; const courant = Number.isFinite(ma) ? Math.round(ma) : null; const ra = iA >= 0 ? parseInt(c[iA], 10) : NaN; const rang = Number.isFinite(ra) && ra >= 1 && ra <= 16 ? ra : null; for (let k = 0; k < 15; k++) { const mv = Number(c[iC + k]); if (!(mv > 1000)) continue; val.push([c[0], c[1], k + 1, mv, null, courant, null, 'suivi', rang]); } } for (let i = 0; i < val.length; i += 500) { const lot = val.slice(i, i + 500); const ph = lot.map((_, k) => `($${k * 9 + 1},$${k * 9 + 2},$${k * 9 + 3},$${k * 9 + 4},$${k * 9 + 5},$${k * 9 + 6},$${k * 9 + 7},$${k * 9 + 8},$${k * 9 + 9})`).join(','); const r = await client.query( `INSERT INTO cellule (ts, module, cellule, mv, soc, courant_ma, etat, source, rang) VALUES ${ph} ON CONFLICT (ts, module, cellule) DO NOTHING`, lot.flat()); lignes += r.rowCount; } } return lignes; } // --------------------------------------------------------------------- // ETIQUETTE DE VEHICULE POSEE PAR LE CONCIERGE (13/09/2026) // --------------------------------------------------------------------- // Le selecteur `input_select.victron_evcs_vehicle` est un reglage manuel, donc // oubliable : du 07/09 au 13/09 il est reste sur VW et onze sessions en ont // herite, dont six etaient fausses. L'agent conversationnel d'Airtria demande // par WhatsApp quel vehicule est branche et depose la reponse dans un helper ; // ce lecteur l'applique. Contrat complet : HANDOFF §66. // // Airtria n'ecrit JAMAIS dans cette base — seulement le helper. La base reste // la propriete de ce service. // // {"sessions":["2026-09-12 11:55:54.493+02", …], <- verbatim, ms comprises // "label":"VW|JEEP|MEGANE|INVITE|ZERO", // "confirmed_at":"2026-09-13T18:04:11+02:00", // "source":"whatsapp"|"regle-puissance"} // // ⚠️ LA PROVENANCE DECIDE DE LA CERTITUDE. `whatsapp` est une confirmation // humaine et vaut `vehicule_sur = true` ; `regle-puissance` est une DEDUCTION // (un invite reconnu a sa puissance) et reste a false. Marquer « sur » une // deduction reintroduirait exactement le defaut que ce chantier corrige. // // ⚠️ LA REGLE DE PUISSANCE EST A SENS UNIQUE : au-dessus de ~4 200 W avec une // consigne >= 20 A elle conclut « ni VW ni JEEP », jamais l'inverse — les deux // vehicules du foyer plafonnent a 3 680 et 3 655 W, vingt-cinq watts d'ecart. // Un label household venu de ce chemin est donc un bug par construction : on // REFUSE et on signale, on n'applique pas. const HOUSEHOLD = new Set(['VW', 'JEEP']); // Les canaux par lesquels un HUMAIN peut confirmer. Enumeres explicitement : // un canal inconnu ne doit pas valoir confirmation par defaut. `regle-puissance` // n'y est pas et n'y sera jamais — c'est une deduction, pas une parole. const CANAUX_HUMAINS = new Set(['whatsapp', 'proprietaire']); const LABELS = new Set(['VW', 'JEEP', 'MEGANE', 'INVITE', 'ZERO']); let dernierBlancVu = null; // anti-repetition du journal en essai a blanc // --------------------------------------------------------------------- // LA REPONSE DU PROPRIETAIRE PILOTE LE SELECTEUR // --------------------------------------------------------------------- // Demande du proprietaire, 16/09/2026 : « si je dis JEEP il va rester sur VW, // donc la solution c'est de le piloter quand je reponds ». // // Il a raison, et ce n'est pas cosmetique. La base n'est pas le seul lecteur du // vehicule : le CONTROLEUR n8n lit input_select.victron_evcs_vehicle pour en // deduire l'heure de depart (VW 08:50, JEEP 06:30), donc minutesUntilDeparture, // donc batteryRecoveryUrgent — qui coupe P12 et ferme EDF trente minutes avant // le depart suppose. Un selecteur perime ne fausse pas une etiquette, il change // une decision d'energie. Mesure la nuit du 16/09 : selecteur reste sur JEEP // alors que la VW etait branchee et confirmee par WhatsApp => EDF se serait // ferme a 06:00 pour rien. // // ⚠️ UNE SEULE ECRITURE PAR REPONSE, jamais un reassert cyclique. La fonction // appelante est idempotente par confirmed_at : elle ne repasse pas ici tant que // le proprietaire n'a pas repondu autre chose. C'est ce qui laisse le dernier // mot a l'humain, quel que soit le canal — s'il change le selecteur a la main // apres avoir repondu, on ne le contredit pas. // // ⚠️ Et seulement pour les labels qui SONT des options du selecteur. MEGANE, // INVITE ou ZERO n'y figurent pas : ecrire une option inexistante echouerait, et // surtout la notion d'heure de depart n'a aucun sens pour un vehicule de // passage. Dans ce cas on ne touche a rien et le controleur garde son reglage. async function piloterSelecteurVehicule(sql, st, label, source) { const actuel = txt(st.get('input_select.victron_evcs_vehicle')?.state); const options = st.get('input_select.victron_evcs_vehicle')?.attributes?.options || []; if (!options.includes(label)) return 0; if (actuel === label) return 0; // ⚠️ L'essai a blanc ne doit RIEN ecrire, et cette fonction est la premiere du // service a toucher un actionneur du controleur. Sans cette garde, un // --dry-run aurait pilote le selecteur pour de vrai : trouve en relisant la // sortie du premier essai, ou seule l'egalite fortuite actuel === label a // evite l'ecriture. if (DRY) { console.log(` [blanc] piloterait le selecteur ${actuel || 'inconnu'} -> ${label}`); return 0; } try { const r = await fetch(`${HA_URL}/api/services/input_select/select_option`, { method: 'POST', headers: { Authorization: `Bearer ${HA_TOKEN}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ entity_id: 'input_select.victron_evcs_vehicle', option: label }), signal: AbortSignal.timeout(8000), }); if (!r.ok) throw new Error(`HTTP ${r.status}`); } catch (e) { await journaliser(sql, 'vehicule', `Selecteur vehicule NON pilote (${actuel || 'inconnu'} -> ${label}) : ${e.message}`, { origine: 'airtria', source_airtria: source, label, avant: actuel, echec: e.message }); return 1; } await journaliser(sql, 'vehicule', `Selecteur vehicule pilote par le concierge : ${actuel || 'inconnu'} -> ${label}`, { origine: 'airtria', source_airtria: source, label, avant: actuel, entite: 'input_select.victron_evcs_vehicle', motif: 'la reponse du proprietaire fait foi, et le controleur lit le selecteur' }); return 1; } async function appliquerEtiquetteConcierge(sql, client, st) { const brut = txt(st.get('input_text.airtria_vehicle_label')?.state); if (!brut || brut === 'unknown' || brut === 'unavailable') return 0; let ordre; try { ordre = JSON.parse(brut); } catch { return journaliser(sql, 'vehicule', 'Etiquette concierge illisible (JSON invalide)', { origine: 'airtria', brut: brut.slice(0, 200) }).then(() => 1); } const label = txt(ordre.label).toUpperCase(); const source = txt(ordre.source); const confirmedAt = txt(ordre.confirmed_at); const demandees = Array.isArray(ordre.sessions) ? ordre.sessions.filter(Boolean) : []; // Nouveau contrat : Airtria peut designer le BRANCHEMENT plutot que des // sessions, ce qui lui permet de poser sa question des le branchement, avant // qu une seule session existe. L ancien contrat reste accepte. const brancheDemande = txt(ordre.branchement) || null; if (!label || (!demandees.length && !brancheDemande)) return 0; // Le kill-switch gate l'ECRITURE, pas la lecture : tant qu'il est a OFF on // deroule tout le raisonnement et on journalise ce qu'on AURAIT ecrit. C'est // ce qui permet d'eprouver la jointure sur de vraies clefs, pendant l'essai // grandeur nature d'Airtria, avant que quoi que ce soit ne compte. const appliquer = txt(st.get('input_boolean.airtria_concierge_enabled')?.state) === 'on'; const lire = DRY ? async () => ({ rows: [] }) : (q, v) => client.query(q, v); const refuser = async (motif, detail) => { if (!appliquer && dernierBlancVu === confirmedAt + motif) return 0; dernierBlancVu = confirmedAt + motif; await journaliser(sql, 'vehicule', `${appliquer ? 'Etiquette concierge REFUSEE' : '[blanc] refus'} : ${motif}`, { origine: 'airtria', source_airtria: source, label, confirmed_at: confirmedAt, refus: motif, ...detail }); return 1; }; if (!LABELS.has(label)) return refuser(`label inconnu « ${label} »`, {}); if (source === 'regle-puissance' && HOUSEHOLD.has(label)) { return refuser( `${label} ne peut pas venir de la regle de puissance, qui ne sait dire que « ni VW ni JEEP »`, { sessions: demandees }); } // Deja pose : on ne rejoue pas. La clef est l'horodatage de confirmation, // qu'Airtria ne reutilise jamais d'une reponse a l'autre. if (confirmedAt) { const { rows } = await lire( `SELECT 1 FROM evenement WHERE genre = 'vehicule' AND detail->>'origine' = 'airtria' AND detail->>'confirmed_at' = $1 LIMIT 1`, [confirmedAt]); if (rows.length) return 0; } // UN CABLE, UN VEHICULE. La frontiere d'un branchement est un FAIT observe — // les transitions de `binary_sensor.victron_evcs_vehicle_connected` — et // JAMAIS un ecart de temps : le creux entre la session du soir et celle des // heures creuses fait plus de cinq heures et ne se distingue pas d'une nuit // entiere. C'est ce journal qui a montre, le 13/09, qu'une correction du // mercredi etait physiquement impossible (trois sessions, un seul cable). // ⚠️ Les 2 secondes de tolerance sur la borne basse ne sont pas decoratives. // Une session s'OUVRE avant que la transition de branchement soit // journalisee, les deux etant ecrites dans le meme cycle et `ecrireSession` // passant avant `detecterEvenements` : le 09/09, session a 15:55:15.423 et // evenement « on » a 15:55:15.485, soit 62 ms plus tard. Sans cette marge, // la session qui OUVRE un branchement est exclue de son propre groupe — ce // que le premier essai a blanc a effectivement montre (2 sessions sur 3). // ⚠️ NORMALISER AVANT DE DEDUPLIQUER. Airtria envoie sa clef telle que // PostgreSQL l'affiche — « 2026-09-12 11:55:54.493+02 » — et la base me la // rend en ISO — « 2026-09-12T09:55:54.493Z ». Meme instant, caracteres // differents : un Set les compte DEUX FOIS. L'UPDATE resistait (les deux se // transtypent en timestamptz) mais le journal annoncait « 2 sessions, dont 1 // ajoutee par le branchement » pour UNE seule session — et surtout le // tableau `sessions` du detail portait le doublon, alors que c'est lui que // le controle de contradiction interroge sur les etiquettes suivantes. // Une clef illisible est gardee telle quelle : mieux vaut une clef brute // qu'une clef inventee. const enIso = (x) => { const d = new Date(x); return Number.isNaN(+d) ? String(x) : d.toISOString(); }; const cibles = new Set(demandees.map(enIso)); let frontiereConnue = true; // Une clef de branchement se resout comme une clef de session : on remonte au // dernier « on » qui la precede. Elle peut donc ne designer AUCUNE session — // c est precisement le cas qu on veut couvrir. for (const d of (brancheDemande ? [...demandees, brancheDemande] : demandees)) { const { rows } = await lire( `WITH bas AS ( SELECT max(ts) AS t FROM evenement WHERE genre = 'vehicule' AND detail->>'entite' = 'binary_sensor.victron_evcs_vehicle_connected' AND detail->>'apres' = 'on' AND ts <= $1::timestamptz + interval '1 second'), haut AS ( SELECT min(ts) AS t FROM evenement, bas WHERE genre = 'vehicule' AND detail->>'entite' = 'binary_sensor.victron_evcs_vehicle_connected' AND detail->>'apres' = 'off' AND bas.t IS NOT NULL AND ts > bas.t) SELECT s.debut, s.vehicule FROM session_charge s, bas, haut WHERE bas.t IS NOT NULL AND s.debut >= bas.t - interval '2 seconds' AND (haut.t IS NULL OR s.debut < haut.t) ORDER BY s.debut`, [d]); // Aucune borne : le journal ne remonte pas assez loin. On n'invente pas // une frontiere qu'on n'a pas observee — on s'en tient a la liste recue. // Aucune session dans ce branchement n est NORMAL quand l etiquette est // posee au branchement : la voiture est branchee et n a pas encore charge. // Ce n est une frontiere inconnue que si la clef venait d une session. if (!rows.length) { if (d !== brancheDemande) frontiereConnue = false; continue; } for (const r of rows) cibles.add(enIso(r.debut)); } // Deux etiquettes DIFFERENTES sur le meme cable : soit une frontiere fausse, // soit une ligne fausse. Dans les deux cas on ne tranche pas tout seul. const { rows: deja } = await lire( `SELECT DISTINCT e.detail->>'label' AS label FROM evenement e WHERE e.genre = 'vehicule' AND e.detail->>'origine' = 'airtria' AND e.detail ? 'sessions' AND EXISTS (SELECT 1 FROM jsonb_array_elements_text(e.detail->'sessions') x WHERE x.value = ANY($1::text[]))`, [[...cibles]]); const contradiction = deja.map((r) => r.label).filter((l) => l && l !== label); if (contradiction.length) { return refuser( `le branchement porte deja l etiquette ${contradiction.join('/')}, incompatible avec ${label}`, { sessions: [...cibles], deja: contradiction }); } const sur = CANAUX_HUMAINS.has(source); const detail = { origine: 'airtria', source_airtria: source, label, confirmed_at: confirmedAt, sessions: [...cibles], vehicule_sur: sur, recues: demandees.length, elargies: cibles.size - demandees.length, ...(frontiereConnue ? {} : { frontiere_inconnue: true }), }; if (!appliquer) { // Essai a blanc : on montre ce qu'on aurait fait, une seule fois par ordre. if (dernierBlancVu === confirmedAt) return 0; dernierBlancVu = confirmedAt; console.log(` [concierge, kill-switch OFF] appliquerait ${label} ` + `(${source}, sur=${sur}) a ${cibles.size} session(s)` + (detail.elargies ? `, dont ${detail.elargies} ajoutee(s) par le branchement` : '') + (frontiereConnue ? '' : ' — frontiere de branchement INCONNUE')); return 0; } // On MEMORISE l etiquette au niveau du branchement. C est ce qui permet de // repondre avant qu une session existe : les sessions qui s ouvriront ensuite // la recevront au cycle suivant, sans nouvelle question. const { rows: bornes } = await lire( `SELECT max(ts) AS t FROM evenement WHERE genre = 'vehicule' AND detail->>'entite' = 'binary_sensor.victron_evcs_vehicle_connected' AND detail->>'apres' = 'on' AND ts <= $1::timestamptz + interval '1 second'`, [brancheDemande || demandees[0]]); const branchement = bornes[0]?.t || null; if (branchement) { await sql( `INSERT INTO etiquette_branchement (branchement, label, source_airtria, confirmed_at) VALUES ($1,$2,$3,$4) ON CONFLICT (branchement) DO UPDATE SET label = EXCLUDED.label, source_airtria = EXCLUDED.source_airtria, confirmed_at = EXCLUDED.confirmed_at, pose_a = now()`, [branchement, label, source, confirmedAt || null]); } // Le controleur lit le selecteur : la reponse doit y arriver, pas seulement // dans la base. Une seule fois, ici, parce que cette fonction ne repasse que // sur une nouvelle reponse. const sel = appliquer ? await piloterSelecteurVehicule(sql, st, label, source) : 0; const { rowCount } = cibles.size ? await sql( `UPDATE session_charge SET vehicule = $2, vehicule_sur = $3 WHERE debut = ANY($1::timestamptz[]) AND (vehicule IS DISTINCT FROM $2 OR vehicule_sur IS DISTINCT FROM $3)`, [[...cibles], label, sur]) : { rowCount: 0 }; await journaliser(sql, 'vehicule', cibles.size ? `Etiquette ${label} appliquee par le concierge (${cibles.size} session${cibles.size > 1 ? 's' : ''}, ${source})` : `Etiquette ${label} retenue pour le branchement en cours (aucune session encore, ${source})`, { ...detail, branchement, selecteur_pilote: !!sel }); return (rowCount || 0) + sel + 1; } // --------------------------------------------------------------------- // APPLIQUER LES ETIQUETTES MEMORISEES AUX SESSIONS QUI S OUVRENT // --------------------------------------------------------------------- // Second temps du concierge : l etiquette est posee AU BRANCHEMENT, les // sessions arrivent apres. Cette passe les rattrape a chaque cycle, sans // nouvelle question et sans intervention. // // ⚠️ Elle n applique qu aux sessions du branchement etiquete, bornees par le // debranchement suivant — l invariant « un cable, un vehicule ». Les 2 secondes // de tolerance sur la borne basse sont celles du 09/09 : une session s ouvre // jusqu a 62 ms AVANT que sa transition de branchement soit journalisee. async function appliquerEtiquettesEnAttente(sql, client) { if (DRY) return 0; const { rows } = await client.query( `SELECT branchement, label, source_airtria FROM etiquette_branchement WHERE branchement > now() - interval '48 hours' ORDER BY branchement`); let n = 0; for (const e of rows) { const sur = CANAUX_HUMAINS.has(e.source_airtria); const { rowCount } = await sql( `UPDATE session_charge s SET vehicule = $2, vehicule_sur = $3 WHERE s.debut >= $1::timestamptz - interval '2 seconds' AND s.debut < coalesce(( SELECT min(ts) FROM evenement WHERE genre = 'vehicule' AND detail->>'entite' = 'binary_sensor.victron_evcs_vehicle_connected' AND detail->>'apres' = 'off' AND ts > $1::timestamptz), 'infinity'::timestamptz) AND (s.vehicule IS DISTINCT FROM $2 OR s.vehicule_sur IS DISTINCT FROM $3)`, [e.branchement, e.label, sur]); if (rowCount) { await journaliser(sql, 'vehicule', `Etiquette ${e.label} posee sur ${rowCount} session(s) ouverte(s) depuis le branchement`, { origine: 'airtria', source_airtria: e.source_airtria, label: e.label, branchement: e.branchement, applique_a: rowCount, differe: true }); await client.query( 'UPDATE etiquette_branchement SET sessions_vues = sessions_vues + $2 WHERE branchement = $1', [e.branchement, rowCount]); n += rowCount; } } return n; } // --------------------------------------------------------------------- // PREVISION CONTRE REEL — la serie que Home Assistant ne peut pas produire // --------------------------------------------------------------------- // Les courbes de prevision vivent dans des `input_text` ECRASES a chaque // publication : Home Assistant n'en garde aucun historique exploitable. La base, // elle, a 13 jours de previsions horaires (table `prevision`) ET la production // mesuree. C'est donc ici qu'on peut comparer. // // Le service pousse la serie dans un capteur HA cree par l'API, dont les // ATTRIBUTS portent les tableaux — un `input_text` plafonne a 255 caracteres ne // tiendrait pas 168 points. La carte ApexCharts les lit directement. // // ⚠️ C'est la PREMIERE ecriture de ce service vers Home Assistant : il a // toujours ete lecteur. Le perimetre est volontairement minuscule — UN capteur, // dans son propre espace de noms, aucun actionneur, et un capteur cree par API // disparait au redemarrage de HA puis se recree au cycle suivant. // // ⚠️ ET LA REGLE DE LECTURE EST DANS LA DONNEE : une heure BRIDEE n'est pas // comparable. Production sous la prevision peut vouloir dire mauvaise prevision // OU batterie pleine, et seules les heures non bridees permettent de juger. // D'ou la troisieme serie, sans laquelle la carte ferait accuser la meteo a tort. // // ⚠️ DEUX COURBES DE PREVISION, ET ELLES NE DISENT PAS LA MEME CHOSE. Pour un // jour ECOULE, la seule reference honnete est ce qui avait ete annonce LE MATIN // MEME : une prevision revisee a 18 h connait deja la journee, la comparer au // reel ne mesure plus rien. Mais cette courbe-la est de l'HISTOIRE, et elle ne // dit rien de ce qui vient. La prevision au sens propre — aujourd'hui jusqu'a // ce soir, demain en entier — est la DERNIERE publiee, et c'est la serie // `attendue`. Ne pas les fondre en une seule : elles n'ont ni la meme // provenance ni le meme usage. async function publierPrevisionContreReel(client) { if (DRY) return 0; const { rows } = await client.query(` -- Ce qui etait ANNONCE LE MATIN pour un jour deja ecoule. C'est la seule -- reference qui mesure la qualite d'une prevision : celle qui n'a pas ete -- revisee depuis. Mais c'est de l'HISTOIRE, pas une prevision. with matin as ( select distinct on (jour) jour, horaire from prevision where jour between current_date - 6 and current_date and (publie_a at time zone 'Europe/Paris')::time < time '10:00' order by jour, publie_a desc), -- Ce qui est annonce MAINTENANT pour les heures a venir : la prevision au -- sens propre, aujourd'hui jusqu'a ce soir et demain en entier. courante as ( select distinct on (jour) jour, horaire from prevision where jour >= current_date order by jour, publie_a desc), maintenant as (select date_trunc('hour', now() at time zone 'Europe/Paris') h), passee as (select m.jour + (u.i - 1) * interval '1 hour' as heure, u.v kwh from matin m, unnest(m.horaire) with ordinality as u(v, i)), futur as (select c.jour + (u.i - 1) * interval '1 hour' as heure, u.v kwh from courante c, unnest(c.horaire) with ordinality as u(v, i)), mn as (select generate_series((current_date - 6)::timestamptz, date_trunc('minute', now()), interval '1 minute') t), v as (select date_trunc('minute', ts) t, (array_agg(valeur order by ts desc))[1] v from mesure where cle='sensor.victron_pv_power_system' and ts >= current_date - 6 group by 1), j as (select mn.t, v.v, count(v.v) over (order by mn.t) g from mn left join v on v.t=mn.t), r as (select t, first_value(v) over (partition by g order by t) w from j), reelle as (select date_trunc('hour', t at time zone 'Europe/Paris') heure, sum(w)/60/1000 kwh from r where w is not null group by 1), censure as (select date_trunc('hour', ts at time zone 'Europe/Paris') heure, avg(case when pv_censure then 1.0 else 0.0 end) taux from bilan where ts >= current_date - 6 group by 1) -- Les series passees s'arretent a l'heure en cours, les futures y -- commencent : les deux courbes se TOUCHENT sur cette heure-la au lieu de -- laisser un trou. Au-dela, "reel" et "bridage" valent NULL et non zero — -- un zero dessinerait une chute de production qui n'a pas eu lieu. select extract(epoch from h.heure at time zone 'Europe/Paris')*1000 as ms, case when h.heure <= n.h then round(coalesce(p.kwh,0)::numeric,2)::float8 end as predite, case when h.heure >= n.h then round(f.kwh::numeric,2)::float8 end as attendue, case when h.heure <= n.h then round(coalesce(e.kwh,0)::numeric,2)::float8 end as reelle, case when h.heure <= n.h then round(coalesce(c.taux,0)::numeric*100)::float8 end as bridage, (h.heure < n.h) as clos, (h.heure::date = (now() at time zone 'Europe/Paris')::date) as aujourdhui from (select heure from passee union select heure from futur union select heure from reelle) h cross join maintenant n left join passee p on p.heure=h.heure left join futur f on f.heure=h.heure left join reelle e on e.heure=h.heure left join censure c on c.heure=h.heure where h.heure >= current_date - 6 order by h.heure`); // Ecart moyen sur les seules heures COMPARABLES : non bridees, et ou quelque // chose etait attendu ou produit. C'est le seul chiffre qui juge la prevision. // // ⚠️ `clos` EXCLUT L'HEURE EN COURS. Elle n'est mesuree que pour les minutes // deja ecoulees : a 14h05 son "reel" vaut cinq minutes de production contre // une prevision d'heure pleine, soit un ecart de -92 % qui n'existe pas. const comparables = rows.filter((x) => x.clos && x.bridage < 20 && (x.predite > 0.2 || x.reelle > 0.2)); const sp = comparables.reduce((a, x) => a + x.predite, 0); const sr = comparables.reduce((a, x) => a + x.reelle, 0); const ecart = sp > 0 ? Math.round((sr / sp - 1) * 100) : null; // Le partage aujourd'hui / demain se fait EN SQL, dans le fuseau deja utilise // par tout le reste de la requete. Le refaire en JavaScript reviendrait a // parier sur le fuseau du conteneur — qui est en UTC pour `date`, et ne doit // donc pas servir d'arbitre. const aVenir = rows.filter((x) => x.attendue !== null && x.attendue !== undefined); const somme = (f) => Math.round(aVenir.filter(f).reduce((a, x) => a + x.attendue, 0) * 10) / 10; const corps = { state: ecart === null ? 'unknown' : String(ecart), attributes: { friendly_name: 'Prévision solaire — écart sur les heures comparables', unit_of_measurement: '%', icon: 'mdi:chart-timeline-variant', heures_comparables: comparables.length, heures_total: rows.filter((x) => x.clos).length, reste_aujourdhui_kwh: somme((x) => x.aujourdhui), annonce_demain_kwh: somme((x) => !x.aujourdhui), predite: rows.map((x) => [Number(x.ms), x.predite]), attendue: rows.map((x) => [Number(x.ms), x.attendue]), reelle: rows.map((x) => [Number(x.ms), x.reelle]), bridage: rows.map((x) => [Number(x.ms), x.bridage]), }, }; const r = await fetch(`${HA_URL}/api/states/sensor.evcs_prevision_contre_reel`, { method: 'POST', headers: { Authorization: `Bearer ${HA_TOKEN}`, 'Content-Type': 'application/json' }, body: JSON.stringify(corps), signal: AbortSignal.timeout(10000), }); if (!r.ok) throw new Error(`HTTP ${r.status}`); return rows.length; } // --------------------------------------------------------------------- // BATTEMENT DE COEUR — la seule entite dont l immobilite soit interpretable // --------------------------------------------------------------------- // Defaut que ceci corrige, et il etait beant : un cycle en echec n ecrivait que // sur console.error, c est-a-dire dans le journal du conteneur que personne ne // lit. Et l incident n etait journalise qu au RETOUR a la normale, dans // PostgreSQL — donc precisement la ou l on ne peut pas ecrire quand c est // PostgreSQL qui est tombe. Base injoignable : chaque cycle echouait en silence // indefiniment, et le rapport de 06:47 n arrivait jamais sans un mot. // // ⚠️ POURQUOI UN CAPTEUR DEDIE ET PAS LA FRAICHEUR D UNE ENTITE METIER. // Mesure du 16/09 : Home Assistant ne materialise PAS une ecriture identique, et // `last_reported` — le champ introduit en 2024 pour marquer chaque remontee // d etat — est rigoureusement EGAL a `last_changed`. Aucun champ de l API ne // distingue donc un ecrivain mort d un ecrivain qui republie la meme valeur. // Sur une installation saine, `vehicle_connected` etait inchange depuis 752 min // (voiture branchee la veille), `source_mode` depuis 139 min (voiture pleine) : // trois faux positifs sur cinq entites si l on lit la fraicheur comme une panne. // La vivacite se lit donc ICI et nulle part ailleurs, parce que l etat de ce // capteur est un HORODATAGE — il change forcement a chaque ecriture. // // ⚠️ ET L APPEL SE FAIT HORS DU try/catch DU CYCLE. C est tout le correctif : // si la base tombe, le battement part quand meme, en portant `degrade` et la // cause. Un battement qui ne survit pas a la panne qu il doit signaler ne sert // a rien. // // Forme figee avec la session OpenClaw le 16/09, pour que les deux battements // du systeme soient identiques (le sien : sensor.concierge_heartbeat). async function publierBattement({ statut, baseJoignable, cyclesEnEchec, dernierEchec }) { const corps = { state: new Date().toISOString(), attributes: { friendly_name: 'Historisation EVCS — battement de coeur', icon: statut === 'ok' ? 'mdi:heart-pulse' : 'mdi:heart-broken', statut, base_joignable: baseJoignable, cycles_en_echec: cyclesEnEchec, dernier_echec: dernierEchec || '', }, }; const r = await fetch(`${HA_URL}/api/states/sensor.evcs_historisation_heartbeat`, { method: 'POST', headers: { Authorization: `Bearer ${HA_TOKEN}`, 'Content-Type': 'application/json' }, body: JSON.stringify(corps), signal: AbortSignal.timeout(8000), }); if (!r.ok) throw new Error(`HTTP ${r.status}`); } // --------------------------------------------------------------------- // GUICHET D ORDRES — Airtria demande, ce service decide et applique // --------------------------------------------------------------------- // Valide separement par le proprietaire le 16/09. C est la premiere fois qu une // reponse WhatsApp touche au PILOTAGE et non a une etiquette consultative : P12, // P9, la strategie, le plafond de courant. // // ⚠️ LA LISTE BLANCHE EST ICI ET NULLE PART AILLEURS. Airtria ne doit jamais la // detenir — moindre privilege : il demande, il ne decide pas de ce qui est // demandable. Tout ordre hors de cette table est refuse et journalise, y compris // si Airtria se trompe ou si quelqu un ecrit le helper a la main. // // ⚠️ CE QUI N Y FIGURE PAS, ET N Y FIGURERA PAS : le contacteur // switch.edf_general_ok_switch, les limites DVCC de l Auto Charge Manager, la // consigne de puissance reseau, et les planchers SOC de securite. Ce sont // exactement ceux qui peuvent vider la batterie sous son plancher, la gaver sur // le reseau, ou evincer le solaire une journee entiere. // // Les options d un input_select sont lues EN DIRECT sur l entite, jamais codees // en dur : le jour ou ZERO sera ajoute au selecteur de vehicule (moto remise en // service fin septembre), ce code le consommera sans modification. const ORDRES = { p12: { entite: 'input_boolean.victron_evcs_night_battery_charge', genre: 'bool' }, p9: { entite: 'input_boolean.victron_evcs_allow_grid_departure_urgent', genre: 'bool' }, hc_nuit: { entite: 'input_boolean.victron_evcs_allow_grid_hc_night', genre: 'bool' }, strategie: { entite: 'input_select.victron_evcs_strategy', genre: 'option' }, vehicule: { entite: 'input_select.victron_evcs_vehicle', genre: 'option' }, plafond_edf: { entite: 'input_number.evcs_plafond_courant_edf_on', genre: 'nombre', min: 6, max: 28 }, }; async function appliquerOrdreAirtria(sql, client, st) { const brut = txt(st.get('input_text.airtria_ordre')?.state); if (!brut || brut === 'unknown' || brut === 'unavailable') return 0; let o; try { o = JSON.parse(brut); } catch { return journaliser(sql, 'intervention', 'Ordre Airtria illisible (JSON invalide)', { origine: 'airtria', brut: brut.slice(0, 200) }).then(() => 1); } const nom = txt(o.ordre).toLowerCase(); const source = txt(o.source); const confirmedAt = txt(o.confirmed_at); const valeur = txt(o.valeur); if (!nom || !valeur) return 0; const appliquer = txt(st.get('input_boolean.airtria_ordres_enabled')?.state) === 'on'; const lire = DRY ? async () => ({ rows: [] }) : (q, v) => client.query(q, v); const refuser = async (motif) => { if (!appliquer && dernierBlancVu === confirmedAt + motif) return 0; dernierBlancVu = confirmedAt + motif; await journaliser(sql, 'intervention', `Ordre Airtria REFUSE (${nom}) : ${motif}`, { origine: 'airtria', source_airtria: source, ordre: nom, valeur, confirmed_at: confirmedAt, refus: motif }); return 1; }; const spec = ORDRES[nom]; if (!spec) return refuser(`ordre inconnu, hors liste blanche`); // Un ordre de PILOTAGE ne peut venir que d un humain. Une regle qui deduit // « il faudrait activer P12 » n a rien a faire ici : c est une decision, pas // une observation. if (!CANAUX_HUMAINS.has(source)) { return refuser(`source « ${source || 'absente'} » : un ordre de pilotage exige un canal humain`); } // Deja applique : on ne rejoue pas. Meme clef que le concierge. if (confirmedAt) { const { rows } = await lire( `SELECT 1 FROM evenement WHERE genre = 'intervention' AND detail->>'origine' = 'airtria' AND detail->>'confirmed_at' = $1 LIMIT 1`, [confirmedAt]); if (rows.length) return 0; } const cible = st.get(spec.entite); if (!cible) return refuser(`entite cible ${spec.entite} absente de Home Assistant`); const avant = txt(cible.state); let service, data; if (spec.genre === 'bool') { const v = valeur.toLowerCase(); if (v !== 'on' && v !== 'off') return refuser(`valeur « ${valeur} » : attendu on ou off`); service = v === 'on' ? 'input_boolean/turn_on' : 'input_boolean/turn_off'; data = { entity_id: spec.entite }; if (avant === v) return 0; // deja dans l etat demande } else if (spec.genre === 'option') { const options = cible.attributes?.options || []; if (!options.includes(valeur)) { return refuser(`valeur « ${valeur} » absente des options (${options.join(', ') || 'aucune'})`); } service = 'input_select/select_option'; data = { entity_id: spec.entite, option: valeur }; if (avant === valeur) return 0; } else { const v = Number(valeur); if (!Number.isFinite(v)) return refuser(`valeur « ${valeur} » non numerique`); if (v < spec.min || v > spec.max) { return refuser(`valeur ${v} hors des bornes [${spec.min}, ${spec.max}]`); } service = 'input_number/set_value'; data = { entity_id: spec.entite, value: v }; if (Number(avant) === v) return 0; } if (!appliquer) { if (dernierBlancVu === confirmedAt) return 0; dernierBlancVu = confirmedAt; console.log(` [ordres, kill-switch OFF] appliquerait ${nom}=${valeur} ` + `sur ${spec.entite} (actuellement ${avant})`); return 0; } try { const r = await fetch(`${HA_URL}/api/services/${service}`, { method: 'POST', headers: { Authorization: `Bearer ${HA_TOKEN}`, 'Content-Type': 'application/json' }, body: JSON.stringify(data), signal: AbortSignal.timeout(8000), }); if (!r.ok) throw new Error(`HTTP ${r.status}`); } catch (e) { return journaliser(sql, 'intervention', `Ordre Airtria NON APPLIQUE (${nom}=${valeur}) : ${e.message}`, { origine: 'airtria', source_airtria: source, ordre: nom, valeur, confirmed_at: confirmedAt, entite: spec.entite, echec: e.message }).then(() => 1); } await journaliser(sql, 'intervention', `Ordre Airtria applique : ${nom} = ${valeur} (etait ${avant})`, { origine: 'airtria', source_airtria: source, ordre: nom, valeur, confirmed_at: confirmedAt, entite: spec.entite, avant }); return 1; } 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), observation: await ecrireObservation(sql, st), prevision: await ecrirePrevision(sql, st), session: await ecrireSession(sql, client, st), evenement: await detecterEvenements(sql, st), etiquette: await appliquerEtiquetteConcierge(sql, client, st).catch((e) => { console.error(`etiquette concierge en echec : ${e.message}`); return 0; }) + await appliquerEtiquettesEnAttente(sql, client).catch((e) => { console.error(`etiquettes differees en echec : ${e.message}`); return 0; }), // Guichet d'ordres : Airtria demande, la liste blanche d'ici decide. ordre: await appliquerOrdreAirtria(sql, client, st).catch((e) => { console.error(`ordre airtria en echec : ${e.message}`); return 0; }), // Ingestion des relevés de cellule deposes par le poste au cable serie. // Hors essai a blanc : l'insertion est massive et idempotente, la simuler // n'apprendrait rien. // Serie horaire : inutile de la republier toutes les minutes. prevreel: (DRY || new Date().getMinutes() % 10 !== 3) ? 0 : await publierPrevisionContreReel(client).catch((e) => { console.error(`prevision contre reel : ${e.message}`); return 0; }), rapport: DRY ? 0 : await envoyerRapportDuMatin(sql, client).catch((e) => { console.error('rapport de nuit :', e.message); return 0; }), cellules: DRY ? 0 : await ingererCellules(client).catch((e) => { console.error(`ingestion cellules en echec : ${e.message}`); return 0; }), }; const total = r.mesures + r.bilan + r.decision + r.prevision + r.evenement + r.session + r.cellules + r.observation + r.etiquette + (r.rapport || 0); 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} ` + `observation=${r.observation} etiquette=${r.etiquette} ordre=${r.ordre} cellules=${r.cellules}` ); } } // --------------------------------------------------------------------- // CONNEXION A LA BASE — resiliente, et ce n'est pas du luxe // --------------------------------------------------------------------- // ⚠️ DEFAUT MESURE LE 16/09, en eprouvant le battement de coeur : quand la // connexion PostgreSQL tombe EN COURS DE ROUTE, le client `pg` emet un // evenement 'error' sur le socket. Un 'error' sans ecouteur est une exception // NON CAPTUREE — elle ne passe pas par le try/catch du cycle, qui n'attend // qu'une promesse, et elle TUE LE PROCESSUS. Le service mourait donc // exactement dans la panne que le battement existe pour signaler. // // Deux consequences, toutes deux traitees ici : // 1. un ecouteur 'error' obligatoire, qui marque la connexion morte au lieu // de faire tomber le processus ; // 2. une reconnexion, car un client `pg` n'est PAS reutilisable apres erreur : // sans cela le service resterait vivant mais definitivement muet, et il // faudrait un redemarrage a la main pour qu'il reparte quand la base // revient. // // Et la connexion initiale ne doit plus etre fatale : base absente au demarrage, // le service tourne quand meme et publie `degrade`. Un service qui meurt ne // peut rien signaler. const CONFIG_PG = { 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, }; let client = null; let clientMort = true; let schemaFait = false; async function assurerConnexion() { if (client && !clientMort) return; if (client) { try { await client.end(); } catch { /* deja morte */ } client = null; } const c = new pg.Client(CONFIG_PG); c.on('error', (e) => { clientMort = true; console.error(`${new Date().toISOString()} connexion base perdue : ${e.message}`); }); await c.connect(); if (!schemaFait) { await migrer(c); schemaFait = true; } client = c; clientMort = false; } if (!DRY) { // Un echec ici n'est plus fatal : la boucle reessaiera, et le battement // dira `degrade` en attendant. try { await assurerConnexion(); } catch (e) { console.error(`${new Date().toISOString()} base injoignable au demarrage : ${e.message}`); } } console.log( `Historisation EVCS — HA=${HA_URL} base=${DRY ? '(blanc)' : CONFIG_PG.host + ':' + CONFIG_PG.port} ` + `intervalle=${INTERVAL_S}s` ); if (ONCE) { // ⚠️ La boucle appelle assurerConnexion() a chaque tour ; ce chemin-la ne le // faisait pas, et une base absente au demarrage lui donnait client = null — // donc un TypeError au lieu d'un message clair. Trouve en eprouvant le // guichet d'ordres le 16/09, sur un simple mauvais numero de port. if (!DRY) await assurerConnexion(); 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; let cyclesEnEchec = 0; let dernierEchec = ''; for (;;) { try { if (!DRY) await assurerConnexion(); await cycle(client); if (echecDepuis !== null) { const duree = Math.round((Date.now() - echecDepuis) / 1000); try { // Le message disait « Home Assistant injoignable » quelle que soit la // cause. Depuis que la base peut tomber sans tuer le service, c'est // souvent faux : on nomme ce qui a reellement echoue. await journaliser(client.query.bind(client), 'systeme', `Cycles en echec pendant ${duree} s : ${dernierEchec || 'cause inconnue'}`, { depuis: new Date(echecDepuis).toISOString(), secondes: duree, cycles: cyclesEnEchec, cause: dernierEchec }); } catch { /* la base non plus n'etait peut-etre pas la : on n'insiste pas */ } echecDepuis = null; } cyclesEnEchec = 0; dernierEchec = ''; } catch (e) { if (echecDepuis === null) echecDepuis = Date.now(); cyclesEnEchec += 1; dernierEchec = String(e.message).slice(0, 200); console.error(`${new Date().toISOString()} cycle en echec : ${e.message}`); } // HORS du try/catch ci-dessus, et volontairement : c'est ce qui fait que la // panne la plus silencieuse — base morte, cycle avorte a chaque passe — // devient visible. Son propre echec ne doit pas tuer la boucle non plus. if (!DRY) { try { await publierBattement({ statut: cyclesEnEchec === 0 ? 'ok' : 'degrade', baseJoignable: cyclesEnEchec === 0, cyclesEnEchec, dernierEchec, }); } catch (e) { // Home Assistant injoignable : il ne reste que le journal du conteneur. // C'est la seule panne que ce mecanisme ne peut pas signaler lui-meme — // elle est couverte dans l'autre sens par le chien de garde sur .22. console.error(`${new Date().toISOString()} battement non publie : ${e.message}`); } } await new Promise((r) => setTimeout(r, INTERVAL_S * 1000)); }