Files
EVCS/tools/historise-evcs.mjs

2520 lines
132 KiB
JavaScript

#!/usr/bin/env node
// =====================================================================
// Historisation EVCS — alimente PostgreSQL depuis Home Assistant.
// =====================================================================
//
// POURQUOI PAS DEPUIS n8n. C'était l'idée de départ (« n8n calcule déjà ce qui
// compte, l'écrire est une ligne de plus »). Mais tout ce que n8n calcule, il
// le PUBLIE déjà dans des helpers Home Assistant : SOC requis, heure de
// croisement, décisions, prévisions. Les lire ici donne la même information
// sans toucher aux trois workflows de production qu'on vient de stabiliser, et
// sans créer d'identifiant de connexion dans n8n.
//
// Ce service tourne sur 10.10.10.22, à côté de la base : si 10.10.10.17 tombe,
// il n'y a de toute façon plus rien à enregistrer, mais la mémoire déjà
// constituée, elle, survit.
//
// L'HORODATAGE EST CELUI DE HOME ASSISTANT, pas celui du sondage. Une valeur
// inchangée depuis le cycle précédent porte donc la même clé primaire et n'est
// pas réécrite (ON CONFLICT DO NOTHING) : on enregistre quand la grandeur a
// bougé, pas quand on a regardé.
//
// Usage :
// node tools/historise-evcs.mjs # boucle
// node tools/historise-evcs.mjs --once # un cycle puis sortie
// node tools/historise-evcs.mjs --once --dry-run # sans écrire
// =====================================================================
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import pg from 'pg';
import { creerSchemaKm, ingererReleves, ingererNiveauSaisi, ingererJeep } from './kilometrage.mjs';
import { texteRapport, envoyer, donneesNuit, texteDepuis } from './rapport-nuit.mjs';
import { diagnosticPv, etatCellules, productionJour, chargeSolaire, bilanNuit, etatReseau, importJour, minuitLocal, COMPTEURS_IMPORT, COMPTEUR_EDF, consoJour, COMPTEURS_CONSO, projectionSoir, previsionNuit, vrmVersCourbes, nuitMaisonPrevue } from './diagnostics-agent.mjs';
import { transitionsDansSerie } from './allers-retours.mjs';
import { energieSur24h, energieDepuis, COMPTEUR_CHAUFFE_EAU } from './chauffe-eau.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',
// Compteurs d'energie cumules de l'import (26/09) : HANDOFF §76 notait leur
// absence, qui empechait tout calcul d'import par delta de compteur.
'sensor.airtria_mpii_v1_energy_from_ac_in_1_to_ac_out',
'sensor.airtria_mpii_v1_energy_from_ac_in_1_to_inverter',
'sensor.edf_general_ok_energie_totale',
// Budget voiture de la nuit (30/09) : le budget publie par le bilan et le
// compteur de la borne, pour tracer « donne depuis 01:24 » contre le budget.
'input_number.evcs_balance_night_vehicle_budget_kwh',
'sensor.evcs_hq2239dnt7p_total_energy',
// Jeep lue par Uconnect (04/10/2026)
'sensor.jeep_renegade_phev_odometer',
'sensor.jeep_renegade_phev_hvbattery_charge',
'sensor.jeep_renegade_phev_extrapolated_battery',
// Chauffe-eau (07/10/2026) : compteur cumule, puissance observee par le
// workflow ECS (morte jusqu'au 07/10, cf. HANDOFF), energie sur 24 h.
'sensor.tt_chauffe_eau_pleumeur_energie_totale',
'input_number.victron_water_heater_observed_power_w',
'sensor.evcs_chauffe_eau_24h_kwh',
];
// ---------------------------------------------------------------------
// 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'],
['input_select.victron_evcs_night_charge_mode', 'intervention','Charge de la voiture la nuit'],
['switch.tt_chauffe_eau_pleumeur_switch', 'chauffe_eau', 'Chauffe-eau sous tension'],
['input_boolean.evcs_balance_ecs_nuit', 'chauffe_eau', 'Chauffe-eau en HC de nuit (jour faible)'],
['input_text.evcs_balance_ecs_nuit_jour', 'chauffe_eau', 'Decision du jour pour le chauffe-eau'],
['input_text.victron_water_heater_observed_regime', 'chauffe_eau', 'Regime du chauffe-eau'],
['input_text.victron_evcs_night_budget', 'vehicule', 'Budget voiture de la nuit fige (kWh|index borne)'],
['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, ts = null) {
// `ts` : horodatage HA d'une transition retrouvee apres coup (aller-retour
// bref, cf. detecterEvenements) ; sinon l'heure d'ecriture (defaut de la table).
await sql(
`INSERT INTO evenement (ts, genre, source, resume, detail)
VALUES (COALESCE($5::timestamptz, now()), $1, $2, $3, $4)`,
[genre, 'historisation', resume, JSON.stringify(detail || {}), ts]
);
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 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(), changes: new Map() };
// ALLERS-RETOURS BREFS (06/10/2026). Le service lit l'etat une fois par cycle
// (~75 s) : un debranchement-rebranchement de 20 s (echange VW -> JEEP a
// 19:52:36 / 19:52:56 le 06/10) tombe entre deux lectures, l'etat est le meme
// avant et apres, et AUCUN branchement n'etait vu — ni par l'etiquetage, ni par
// le concierge qui lit cette table. Le `last_changed` de HA, lui, avance : sur
// ces entites, s'il a bouge alors que l'etat est identique, on relit
// l'historique HA de l'intervalle et on journalise chaque transition a SON
// horodatage. C'est la signature d'un echange de vehicule.
const ALLERS_RETOURS_SUIVIS = new Set([
'binary_sensor.victron_evcs_vehicle_connected',
'switch.edf_general_ok_switch',
]);
async function transitionsCachees(id, depart, depuisIso, jusquaIso) {
const r = await fetch(
`${HA_URL}/api/history/period/${encodeURIComponent(depuisIso)}?end_time=` +
`${encodeURIComponent(new Date(Date.parse(jusquaIso) + 1000).toISOString())}` +
`&filter_entity_id=${id}&minimal_response&no_attributes`,
{ headers: { Authorization: `Bearer ${HA_TOKEN}` }, signal: AbortSignal.timeout(15000) },
);
if (!r.ok) throw new Error(`historique HA ${r.status}`);
const series = await r.json();
return transitionsDansSerie(series[0], depart, depuisIso, jusquaIso);
}
// RELECTURE UCONNECT AU BRANCHEMENT (07/10/2026, accord du proprietaire) :
// l'integration ne relit le cloud Stellantis que toutes les 15 min ; au
// branchement de la borne on demande `uconnect.update`, une RELECTURE du cloud
// (ce que la voiture a deja publie : fin de trajet, contact). AUCUNE commande
// vers la voiture : deep_refresh / refresh_location la reveillent et restent
// interdits, comme charge_now, lights, doors. Une fois par branchement.
let derniereRelectureUconnect = 0;
async function relireUconnect() {
if (DRY || Date.now() - derniereRelectureUconnect < 5 * 60000) return;
derniereRelectureUconnect = Date.now();
try {
const r = await fetch(`${HA_URL}/api/services/uconnect/update`, {
method: 'POST', headers: { Authorization: `Bearer ${HA_TOKEN}`, 'Content-Type': 'application/json' },
body: '{}', signal: AbortSignal.timeout(20000),
});
console.log(` relecture Uconnect au branchement : HTTP ${r.status}`);
} catch (e) { console.log(` relecture Uconnect au branchement : ${e.message}`); }
}
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.changes.set(id, e.last_changed); }
}
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);
const changePrec = memoire.changes.get(id);
memoire.changes.set(id, e.last_changed);
if (prec === undefined) { memoire.valeurs.set(id, cur); continue; }
if (prec === cur) {
if (!ALLERS_RETOURS_SUIVIS.has(id) || !changePrec
|| !(Date.parse(e.last_changed) > Date.parse(changePrec))) continue;
let cachees = [];
try {
cachees = await transitionsCachees(id, prec, changePrec, e.last_changed);
} catch (err) {
console.log(` aller-retour ${id} : historique illisible (${err.message})`);
}
for (const tr of cachees) {
const quiC = await auteurDeLaTransition(id, tr.a);
const resumeC = `${libelle} : ${tr.avant} -> ${tr.apres} (aller-retour bref, retrouve dans l historique HA)`
+ (quiC ? (quiC.type === 'derivation' ? ` — derive de ${quiC.nom}` : ` — par ${quiC.nom}`) : '');
await journaliser(sql, genre, resumeC, {
entite: id, avant: tr.avant, apres: tr.apres, horodatage_ha: tr.a, aller_retour: true,
...(quiC ? { auteur: quiC.auteur, auteur_nom: quiC.nom, auteur_type: quiC.type, declencheur: quiC.declencheur } : {}),
}, tr.a);
n++;
}
if (id === 'binary_sensor.victron_evcs_vehicle_connected' && cachees.some((x) => x.apres === 'on')) await relireUconnect();
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++;
if (id === 'binary_sensor.victron_evcs_vehicle_connected' && cur === 'on') await relireUconnect();
}
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
)`);
// Correction humaine (05/10) : l'etiquette ne vaut que pour les sessions non
// terminees a cet instant (NULL = tout le branchement).
await client.query('ALTER TABLE etiquette_branchement ADD COLUMN IF NOT EXISTS applique_depuis timestamptz');
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');
// 28/09 : deux sources de prevision enregistrees cote a cote (VRM et
// Forecast.Solar) pour les comparer a armes egales ; la cle doit donc porter
// la source, sinon deux publications de la meme seconde s'ecraseraient.
await client.query(`DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_index i JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey)
WHERE i.indrelid = 'prevision'::regclass AND i.indisprimary AND a.attname = 'source') THEN
ALTER TABLE prevision DROP CONSTRAINT IF EXISTS prevision_pkey;
ALTER TABLE prevision ADD PRIMARY KEY (publie_a, jour, source);
END IF; END $$`);
// Le PACK d'origine (26/09/2026), lu dans le nom du fichier (-pack1-, -pack2-).
// Depuis que deux lecteurs relevent deux chaines, le rang seul est ambigu :
// chaque chaine numerote ses modules a partir de 1. Repere affiche =
// pack x 10 + rang (11 = pack 1 module 1, 21 = pack 2 module 1), choix du
// proprietaire. NULL pour les releves anterieurs dont le pack n'est pas sur.
await client.query('ALTER TABLE cellule ADD COLUMN IF NOT EXISTS pack 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)');
// Kilometrage et capacite des vehicules (04/10/2026) : tables releve_km et
// niveau_saisi, vues km_intervalle et capacite_estimee (tools/kilometrage.mjs).
await creerSchemaKm(client);
// -------------------------------------------------------------------
// 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.
// ---------------------------------------------------------------------
// 07:30, apres la fermeture de la fenetre HC (07:24) : l'import de la nuit est
// alors termine, donc le total du rapport est COMPLET. A 06:47 il manquait
// l'import 06:47->07:24 (proposition du proprietaire, 24/09). Reserve connue :
// si la cible du matin est trop basse, l'hysteresis peut refermer EDF apres
// 07:24 (cas plein tarif §39) et ce rapport le raterait — l'exception, pas la
// regle. La fenetre de calcul est de toute facon plafonnee a now() (voir
// rapport-nuit.mjs), donc avancer/reculer cette heure reste sur.
const RAPPORT_HEURE = 7;
const RAPPORT_MINUTE = 30;
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 pk = n.match(/-pack(\d)-suivi\.csv$/);
const pack = pk ? parseInt(pk[1], 10) : null;
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, pack]);
}
}
for (let i = 0; i < val.length; i += 500) {
const lot = val.slice(i, i + 500);
const ph = lot.map((_, k) => `(${Array.from({ length: 10 }, (_, j) => `$${k * 10 + j + 1}`).join(',')})`).join(',');
const r = await client.query(
`INSERT INTO cellule (ts, module, cellule, mv, soc, courant_ma, etat, source, rang, pack)
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']);
// Sources qui CONFIRMENT un vehicule : l'humain, ou la voiture elle-meme
// (04/10/2026 : la Jeep se declare branchee via Uconnect).
const CANAUX_SURS = new Set([...CANAUX_HUMAINS, 'uconnect']);
const LABELS = new Set(['VW', 'JEEP', 'MEGANE', 'INVITE', 'ZERO']);
let dernierBlancVu = null; // anti-repetition du journal en essai a blanc
// Detection d'anomalie "branchee mais ne charge pas" (19/09/2026). Etat
// d'episode EN PROCESSUS : instant ou la condition a commence a tenir, null
// sinon. Sur un redemarrage il repart de null — une anomalie reelle et
// persistante se re-arme en ANOMALIE_CHARGE_MIN. On garde aussi l'horodatage
// du dernier episode SIGNALE pour ne journaliser qu'une fois par episode.
let anomalieChargeDepuis = null;
let anomalieChargeSignalee = null;
// ---------------------------------------------------------------------
// 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 ${source === 'uconnect' ? 'la Jeep (Uconnect)' : '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);
// CORRECTION HUMAINE (05/10/2026, decision du proprietaire) : sa DERNIERE
// reponse fait foi. Cas reel du 04/10 : JEEP notee a 16:59, puis « Non
// maintenant c'est la zero » a 17:16 — permutation rapide que la borne n'a
// pas vue. L'ancien refus laissait JEEP en base pendant que le concierge
// repondait « Noté : ZERO ». Desormais : les sessions TERMINEES avant la
// correction gardent leur etiquette (c'etait le bon vehicule a ce moment-la),
// la session en cours et les suivantes prennent la nouvelle. Une
// contradiction NON humaine (regle de puissance) reste refusee.
let correctionDepuis = null;
if (contradiction.length) {
if (!CANAUX_HUMAINS.has(source)) {
return refuser(
`le branchement porte deja l etiquette ${contradiction.join('/')}, incompatible avec ${label}`,
{ sessions: [...cibles], deja: contradiction });
}
correctionDepuis = Number.isFinite(Date.parse(confirmedAt)) ? new Date(confirmedAt) : new Date();
const { rows: encore } = await lire(
`SELECT debut FROM session_charge WHERE debut = ANY($1::timestamptz[])
AND (fin IS NULL OR fin > $2 OR NOT terminee)`, [[...cibles], correctionDepuis]);
const gardees = cibles.size - encore.length;
cibles.clear();
for (const r of encore) cibles.add(enIso(r.debut));
await journaliser(sql, 'vehicule',
`Etiquette corrigee par le proprietaire : ${contradiction.join('/')} -> ${label} a partir de `
+ `${correctionDepuis.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit', timeZone: 'Europe/Paris' })}`
+ (gardees ? ` (${gardees} session${gardees > 1 ? 's' : ''} terminee${gardees > 1 ? 's' : ''} avant : inchangee${gardees > 1 ? 's' : ''})` : ''),
{ origine: 'correction', source_airtria: source, label, avant: contradiction,
confirmed_at: confirmedAt, depuis: correctionDepuis.toISOString(), gardees });
}
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, applique_depuis)
VALUES ($1,$2,$3,$4,$5)
ON CONFLICT (branchement) DO UPDATE
SET label = EXCLUDED.label, source_airtria = EXCLUDED.source_airtria,
confirmed_at = EXCLUDED.confirmed_at, pose_a = now(),
applique_depuis = EXCLUDED.applique_depuis`,
[branchement, label, source, confirmedAt || null, correctionDepuis]);
}
// 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.
// LA JEEP SE DECLARE ELLE-MEME (04/10/2026, accord du proprietaire). Appelee par
// ingererJeep a la premiere mise a jour Uconnect qui suit un branchement de la
// borne, Jeep « branchee ». Trois gestes, une fois par branchement :
// 1. etiquette JEEP du branchement (source uconnect, confirmee) — SANS ecraser
// une etiquette deja donnee par le proprietaire (ON CONFLICT DO NOTHING) ;
// 2. selecteur sur JEEP (heure de depart, regles du controleur) ;
// 3. niveau « saisi » du vehicule = niveau lu, sauf saisie humaine depuis le
// branchement - 1 h (l'automatisation HA recale la reference du compteur).
// Le concierge ne doit alors plus poser sa question : il lit etiquette_branchement.
async function etiqueterJeepUconnect(sql, client, st, branche, niveau, motif = 'branchee') {
if (DRY) return 0;
const { rowCount } = await client.query(
`INSERT INTO etiquette_branchement (branchement, label, source_airtria, confirmed_at)
VALUES ($1, 'JEEP', 'uconnect', $2) ON CONFLICT (branchement) DO NOTHING`,
[branche, new Date().toISOString()]);
if (!rowCount) return 0; // deja etiquete (le proprietaire a repondu) : on ne touche a rien
await journaliser(sql, 'vehicule', `Branchement identifie : JEEP (${motif === 'arrivee' ? 'fin de trajet juste avant le branchement' : 'la voiture se declare branchee'}, Uconnect), niveau ${niveau} %`,
{ origine: 'uconnect', label: 'JEEP', branchement: branche.toISOString(), niveau, preuve: motif });
let n = 1 + await piloterSelecteurVehicule(sql, st, 'JEEP', 'uconnect');
const saisieA = Number(st.get('input_datetime.victron_evcs_vehicle_level_at')?.attributes?.timestamp) * 1000;
if (!(saisieA >= branche.getTime() - 3600000)) {
try {
const r = await fetch(`${HA_URL}/api/services/input_number/set_value`, {
method: 'POST',
headers: { Authorization: `Bearer ${HA_TOKEN}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ entity_id: 'input_number.victron_evcs_vehicle_level_pct', value: niveau }),
signal: AbortSignal.timeout(8000),
});
if (!r.ok) throw new Error(`HTTP ${r.status}`);
await journaliser(sql, 'vehicule', `Niveau du vehicule branche pose a ${niveau} % (lu par la Jeep)`,
{ origine: 'uconnect', niveau, entite: 'input_number.victron_evcs_vehicle_level_pct' });
n++;
} catch (e) {
await journaliser(sql, 'vehicule', `Niveau JEEP NON pose (${e.message})`, { origine: 'uconnect', niveau });
}
}
return n;
}
async function appliquerEtiquettesEnAttente(sql, client) {
if (DRY) return 0;
const { rows } = await client.query(
`SELECT branchement, label, source_airtria, applique_depuis FROM etiquette_branchement
WHERE branchement > now() - interval '48 hours' ORDER BY branchement`);
let n = 0;
for (const e of rows) {
const sur = CANAUX_SURS.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 ($4::timestamptz IS NULL OR s.fin IS NULL OR s.fin > $4::timestamptz OR NOT s.terminee)
AND (s.vehicule IS DISTINCT FROM $2 OR s.vehicule_sur IS DISTINCT FROM $3)`,
[e.branchement, e.label, sur, e.applique_depuis]);
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.
// Source de prevision active (selecteur HA, 28/09) : l'ecart prevision/reel et
// les courbes publiees portent sur ELLE, jamais sur un melange des deux.
let sourcePrevisionActive = 'forecast.solar';
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 source = $1
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 and source = $1
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`, [sourcePrevisionActive]);
// 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',
source_prevision: sourcePrevisionActive,
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;
}
// ---------------------------------------------------------------------
// ANOMALIE "branchee mais ne charge pas" (19/09/2026)
// ---------------------------------------------------------------------
// Grief du proprietaire : la JEEP branchee, du soleil dispo, et rien qui
// charge (borne figee apres un swap trop rapide). Je DETECTE, la session
// OpenClaw (SPARK) ENVOIE l'alerte WhatsApp en lisant le helper.
//
// Critere, et pourquoi il ne se declenche PAS sur une voiture vraiment pleine :
// sans injection reseau, une batterie pleine fait BRIDER le MPPT (censure) donc
// PV ~ conso donc excedent ~ 0 -> pas d'alerte. Un excedent POSITIF qui PERSISTE
// avec voiture branchee et borne a ~0 W signifie qu'il manque un preneur a de
// l'energie gratuite : ca, ca vaut un signalement.
// - vehicule branche
// - borne ac_power < SEUIL_CHARGE_W (ne charge pas)
// - etat controleur bloquant (source_mode "Bloque" OU surplus_state d'attente)
// - excedent solaire (pv - conso) > ANOMALIE_CHARGE_SURPLUS_W
// - tenu >= ANOMALIE_CHARGE_MIN
// ⚠️ SEUILS A CALIBRER sur l'historique avant d'etre figes (lecon des
// oscillateurs) — valeurs de depart conservatrices. Un seul signalement par
// episode. Le helper reste INERTE tant que SPARK n'a pas cable l'envoi.
const ANOMALIE_CHARGE_MIN = 10; // minutes
const ANOMALIE_CHARGE_SURPLUS_W = 500; // excedent solaire minimal
const ETATS_BLOQUANTS = new Set([
'day_vehicle_full_idle', 'idle_wait_conditions', 'idle_wait_source',
'stop_source_lost', 'night_session_done_idle', 'night_recovery_done_idle',
]);
async function detecterAnomalieCharge(sql, st) {
const g = (id) => st.get(id);
const connecte = txt(g('binary_sensor.victron_evcs_vehicle_connected')?.state) === 'on';
const acPower = num(g('sensor.victron_evcs_ac_power')?.state) ?? 0;
const pv = num(g('sensor.victron_pv_power_system')?.state) ?? 0;
const conso = num(g('sensor.victron_consumption_power_l1')?.state) ?? 0;
const excedent = pv - conso; // sans injection, ~ puissance batterie au signe pres
const sourceMode = txt(g('input_text.victron_evcs_source_mode')?.state) || '';
const surplusState = (txt(g('input_text.victron_evcs_surplus_state')?.state) || '').toLowerCase();
const bloquant = sourceMode.startsWith('Bloqu') || ETATS_BLOQUANTS.has(surplusState);
const condition = connecte && acPower < SEUIL_CHARGE_W && bloquant
&& excedent > ANOMALIE_CHARGE_SURPLUS_W;
const now = Date.now();
if (!condition) {
// fin d'episode : on efface le helper si un episode etait actif
if (anomalieChargeDepuis !== null) {
anomalieChargeDepuis = null;
await ecrireHelperTexte('input_text.evcs_alerte_charge', '');
}
return 0;
}
if (anomalieChargeDepuis === null) anomalieChargeDepuis = now;
const tenuMin = (now - anomalieChargeDepuis) / 60000;
if (tenuMin < ANOMALIE_CHARGE_MIN) return 0;
// Episode confirme. Un seul signalement par episode (clef = son debut).
if (anomalieChargeSignalee === anomalieChargeDepuis) return 0;
anomalieChargeSignalee = anomalieChargeDepuis;
const since = new Date(anomalieChargeDepuis).toISOString();
const message = `Vehicule branche depuis ${Math.round(tenuMin)} min, `
+ `${Math.round(excedent)} W de soleil dispo, mais la borne ne charge pas `
+ `(${sourceMode || surplusState}). Verifier la borne / rebrancher.`;
await ecrireHelperTexte('input_text.evcs_alerte_charge',
JSON.stringify({ active: true, since, excedent_w: Math.round(excedent),
source_mode: sourceMode, surplus_state: surplusState, message }));
await journaliser(sql, 'anomalie',
`Branchee mais ne charge pas : ${Math.round(excedent)} W de soleil perdu depuis ${Math.round(tenuMin)} min`,
{ since, excedent_w: Math.round(excedent), ac_power_w: acPower,
source_mode: sourceMode, surplus_state: surplusState });
return 1;
}
// Petit ecrivain de helper texte, reutilise par la detection d'anomalie.
async function ecrireHelperTexte(entite, valeur) {
if (DRY) return;
try {
await fetch(`${HA_URL}/api/services/input_text/set_value`, {
method: 'POST',
headers: { Authorization: `Bearer ${HA_TOKEN}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ entity_id: entite, value: valeur }),
signal: AbortSignal.timeout(8000),
});
} catch { /* le helper reste a sa valeur, on reessaiera au prochain cycle */ }
}
async function ecrireHelperNombre(entite, valeur) {
if (DRY || valeur === null || valeur === undefined || !Number.isFinite(valeur)) return;
try {
await fetch(`${HA_URL}/api/services/input_number/set_value`, {
method: 'POST',
headers: { Authorization: `Bearer ${HA_TOKEN}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ entity_id: entite, value: valeur }),
signal: AbortSignal.timeout(8000),
});
} catch { /* on reessaiera au prochain cycle */ }
}
// Publie le detail cellule (relevé serie, table `cellule`) dans des helpers HA,
// pour que l'agent vocal OpenClaw le lise sans taper PostgreSQL. Les trames BMS
// cellule de HA sont mortes depuis le 15/09 (§69) : ce relevé serie est la seule
// source vivante. On expose l'ECART max du pack, le module extreme, et l'AGE du
// dernier relevé — c'est l'age qui permet a l'agent d'appliquer la meme
// discipline que pour le gel GX (frais -> il le donne, vieux -> il dit muet).
// Publie un capteur calcule (etat + attributs) via l'API d'etats de HA. Il
// disparait a un redemarrage de HA jusqu'au cycle suivant (<= 1 min) : l'agent
// lit alors une entite absente, donc « muet », jamais une valeur fausse.
async function publierCapteur(entite, etat, attributs) {
if (DRY) return;
const r = await fetch(`${HA_URL}/api/states/${entite}`, {
method: 'POST',
headers: { Authorization: `Bearer ${HA_TOKEN}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ state: String(etat), attributes: attributs }),
signal: AbortSignal.timeout(8000),
});
if (!r.ok) throw new Error(`${entite} : HTTP ${r.status}`);
}
// Verdict PV pour l'agent vocal (G5 du banc) : bridage normal / substitution
// EDF / ressource limitee, calcule par tools/diagnostics-agent.mjs et teste.
// Hors base : ne depend que des etats HA, donc survit a une panne PostgreSQL.
async function publierDiagnosticPv(st) {
const d = diagnosticPv(st);
await publierCapteur('sensor.evcs_pv_diagnostic', d.etat, {
friendly_name: 'EVCS — diagnostic production PV', icon: 'mdi:solar-power-variant', ...d,
calcule_a: new Date().toISOString(),
});
// Production du jour (G1/G9) et relais de la decision de charge (G6/G7) :
// memes proprietes, ne dependent que de HA.
const prod = productionJour(st);
await publierCapteur('sensor.evcs_production_jour_kwh', prod.etat, {
friendly_name: 'EVCS — production PV reelle du jour', icon: 'mdi:solar-power',
unit_of_measurement: typeof prod.etat === 'number' ? 'kWh' : undefined, ...prod.attributs,
});
const ch = chargeSolaire(st);
await publierCapteur('sensor.evcs_charge_solaire', ch.etat, {
friendly_name: 'EVCS — charge voiture (decision du controleur)', icon: 'mdi:ev-station', ...ch.attributs,
});
// Import EDF depuis minuit (« Depuis reseau » de VRM), sur les compteurs cumules.
await publierImportJour(st).catch((e) => {
console.error(`import du jour : ${e.message}`); return 0;
});
// Projection du SOC au coucher du soleil : « indetermine » tant que la
// prevision solaire n'est pas corrigee (decision du proprietaire, 27/09).
const pj = projectionSoir(st);
await publierCapteur('sensor.evcs_batterie_projection_soir', pj.etat, {
friendly_name: 'EVCS — batterie attendue au coucher du soleil', icon: 'mdi:battery-clock',
...pj.attributs, calcule_a: new Date().toISOString(),
});
// Prevision de la nuit (28/09) : uniquement le bilan + etats lus.
const pnuit = previsionNuit(st);
await publierCapteur('sensor.evcs_prevision_nuit', pnuit.etat, {
friendly_name: 'EVCS — prevision de la nuit (EDF a prendre)', icon: 'mdi:weather-night-partly-cloudy',
unit_of_measurement: typeof pnuit.etat === 'number' ? 'kWh' : undefined, ...pnuit.attributs,
calcule_a: new Date().toISOString(),
});
// Raccordement reseau : contacteur ouvert = import nul, pas une panne (26/09).
const rr = etatReseau(st);
await publierCapteur('sensor.evcs_reseau', rr.etat, {
friendly_name: 'EVCS — raccordement reseau EDF', icon: 'mdi:transmission-tower', ...rr.attributs,
});
return 1;
}
// Valeur des compteurs d'energie a minuit (heure de Paris), lue UNE fois par jour
// dans l'historique de HA : la derniere valeur connue avant minuit. L'historique
// renvoie en tete l'etat en vigueur au debut de la fenetre, donc un compteur
// immobile (contacteur ouvert toute la soiree) a bien une valeur.
const IDS_IMPORT = [...COMPTEURS_IMPORT, COMPTEUR_EDF];
// La consommation du jour (26/09) lit ses compteurs de sortie AC au meme minuit.
const IDS_CONSO = COMPTEURS_CONSO.map(([, id]) => id);
const IDS_MINUIT = [...new Set([...IDS_IMPORT, ...IDS_CONSO])];
let minuitCache = { jour: null, valeurs: null };
async function valeursAMinuit() {
const minuit = minuitLocal(new Date());
const jour = minuit.toISOString();
if (minuitCache.jour === jour) return minuitCache.valeurs;
const debut = new Date(minuit.getTime() - 6 * 3600e3).toISOString();
const r = await fetch(
`${HA_URL}/api/history/period/${debut}?end_time=${encodeURIComponent(jour)}` +
`&filter_entity_id=${IDS_MINUIT.join(',')}&minimal_response&no_attributes`,
{ headers: { Authorization: `Bearer ${HA_TOKEN}` }, signal: AbortSignal.timeout(15000) },
);
if (!r.ok) throw new Error(`historique HA ${r.status}`);
const series = await r.json();
const valeurs = {};
for (const serie of series) {
const id = serie[0]?.entity_id;
const nums = serie.map((e) => Number(e.state)).filter(Number.isFinite);
if (id) valeurs[id] = nums.length ? nums[nums.length - 1] : null;
}
// On ne met en cache qu'une lecture complete : sinon on reessaie au cycle suivant.
if (IDS_MINUIT.every((id) => Number.isFinite(valeurs[id]))) minuitCache = { jour, valeurs };
return valeurs;
}
// Chauffe-eau sur 24 h (07/10/2026) : securite eau chaude de la regle « jour
// faible » du workflow ECS. Illisible -> « indisponible » : le workflow ne
// restreint alors rien (jamais un ballon froid par panne de capteur).
async function publierChauffeEau24h() {
const fin = new Date(), debut = new Date(fin.getTime() - 24 * 3600e3);
const r = await fetch(
`${HA_URL}/api/history/period/${debut.toISOString()}?end_time=${encodeURIComponent(fin.toISOString())}` +
`&filter_entity_id=${COMPTEUR_CHAUFFE_EAU}&minimal_response&no_attributes`,
{ headers: { Authorization: `Bearer ${HA_TOKEN}` }, signal: AbortSignal.timeout(15000) },
);
if (!r.ok) throw new Error(`historique HA ${r.status}`);
const serie = (await r.json())[0];
const kwh = energieSur24h(serie);
await publierCapteur('sensor.evcs_chauffe_eau_24h_kwh', kwh === null ? 'indisponible' : kwh, {
friendly_name: 'EVCS — chauffe-eau sur 24 h', unit_of_measurement: 'kWh', icon: 'mdi:water-boiler',
compteur: COMPTEUR_CHAUFFE_EAU, calcule_a: fin.toISOString(),
});
// Depuis la fin de la derniere fenetre HC de nuit (07:24 heure de Paris) :
// securite de la douche du soir (20 h), regle « jour faible ».
const p = new Intl.DateTimeFormat('sv-SE', { timeZone: 'Europe/Paris', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false }).format(fin);
const [jour, hm] = p.split(' ');
const decalage = new Date(fin.toLocaleString('en-US', { timeZone: 'Europe/Paris' })).getTime() - new Date(fin.toLocaleString('en-US', { timeZone: 'UTC' })).getTime();
let borne = Date.parse(`${jour}T07:24:00Z`) - decalage;
if (hm < '07:24') borne -= 24 * 3600e3;
const kwhJour = energieDepuis(serie, borne);
await publierCapteur('sensor.evcs_chauffe_eau_jour_kwh', kwhJour === null ? 'indisponible' : kwhJour, {
friendly_name: 'EVCS — chauffe-eau depuis 07:24', unit_of_measurement: 'kWh', icon: 'mdi:water-boiler',
depuis: new Date(borne).toISOString(), calcule_a: fin.toISOString(),
});
return 2;
}
async function publierImportJour(st) {
const minuit = await valeursAMinuit();
const courant = {};
for (const id of IDS_IMPORT) {
const v = Number(st.get(id)?.state);
courant[id] = Number.isFinite(v) ? v : null;
}
const ij = importJour(courant, minuit);
await publierCapteur('sensor.evcs_import_jour_kwh', ij.etat, {
friendly_name: 'EVCS — energie importee d\'EDF depuis minuit', icon: 'mdi:transmission-tower-import',
unit_of_measurement: typeof ij.etat === 'number' ? 'kWh' : undefined, ...ij.attributs,
calcule_a: new Date().toISOString(),
});
// Consommation du jour (« Consommation Total » de VRM) : sortie AC du MultiPlus.
const cc = {};
for (const id of IDS_CONSO) { const v = Number(st.get(id)?.state); cc[id] = Number.isFinite(v) ? v : null; }
const cj = consoJour(cc, minuit);
await publierCapteur('sensor.evcs_consommation_jour_kwh', cj.etat, {
friendly_name: 'EVCS — consommation du jour (maison et borne)', icon: 'mdi:home-lightning-bolt',
unit_of_measurement: typeof cj.etat === 'number' ? 'kWh' : undefined, ...cj.attributs,
calcule_a: new Date().toISOString(),
});
return 1;
}
// Bilan de la nuit pour l'agent vocal : les donnees du rapport du matin (meme
// code, donc memes chiffres que le courriel). Toutes les 5 min suffit.
// ---------------------------------------------------------------------
// PREVISION VRM (28/09/2026) — decision du proprietaire : le bilan passe sur
// VRM tout de suite, les deux sources sont enregistrees, comparaison dans une
// semaine. Le jeton reste dans le .env du depot (VRM_TOKEN), jamais dans HA :
// il agit avec les droits du compte VRM. Lecture seule.
// ---------------------------------------------------------------------
const VRM_SITE = process.env.VRM_SITE_ID || fromEnvFile('VRM_SITE_ID') || '298009';
let vrmPublie = false;
async function publierPrevisionVrm(sql) {
const token = process.env.VRM_TOKEN || fromEnvFile('VRM_TOKEN');
if (DRY || !token) return 0;
const maintenant = new Date();
const minuit = minuitLocal(maintenant);
const debut = Math.floor(minuit.getTime() / 1000);
const fin = debut + 2 * 86400 + 3600;
const r = await fetch(`https://vrmapi.victronenergy.com/v2/installations/${VRM_SITE}/stats` +
`?type=forecast&interval=hours&start=${debut}&end=${fin}`,
{ headers: { 'X-Authorization': `Token ${token}` }, signal: AbortSignal.timeout(20000) });
if (!r.ok) throw new Error(`VRM HTTP ${r.status}`);
const j = await r.json();
const c = vrmVersCourbes(j?.records?.solar_yield_forecast, maintenant);
if (!c.ok) throw new Error('VRM : pas de courbe pour aujourd hui');
await ecrireHelperTexte('input_text.evcs_balance_vrm_day0', c.day0);
await ecrireHelperTexte('input_text.evcs_balance_vrm_today', c.today.csv);
if (c.tomorrow) await ecrireHelperTexte('input_text.evcs_balance_vrm_tomorrow', c.tomorrow.csv);
await ecrireHelperTexte('input_text.evcs_balance_vrm_updated', c.updated);
let n = 0;
for (const [jour, courbe] of [[c.day0, c.today], [minuitLocal(new Date(maintenant.getTime() + 86400000)).toLocaleDateString('sv-SE', { timeZone: 'Europe/Paris' }), c.tomorrow]]) {
if (!courbe) continue;
const { rowCount } = await sql(
`INSERT INTO prevision (publie_a, jour, source, total_kwh, horaire)
VALUES ($1,$2,'vrm',$3,$4) ON CONFLICT DO NOTHING`,
[maintenant.toISOString(), jour, courbe.totalKwh, courbe.horaire]);
n += rowCount;
}
return n;
}
// La nuit de la maison prevue par la soiree (28/09/2026). Les nuits closes
// viennent de la MEME requete que le tableau Grafana « La nuit »
// (tools/nuits-maison.sql) : un seul calcul, deux lecteurs. Le bilan lit le
// FACTEUR (nuit prevue / nuit moyenne) pour recaler son profil de 21:30 a 09:00.
const NUITS_SQL = fs.readFileSync(path.join(path.dirname(fileURLToPath(import.meta.url)), 'nuits-maison.sql'), 'utf8');
const SOIREE_SQL = `
WITH pts AS (
SELECT cle, ts, valeur,
least(coalesce(lead(ts) OVER (PARTITION BY cle ORDER BY ts), now()), ts + interval '1 hour') AS fin
FROM mesure WHERE cle IN ('sensor.victron_consumption_power_l1', 'sensor.victron_evcs_ac_power')
AND ts > to_timestamp($1 / 1000.0) - interval '2 hours' AND ts < to_timestamp($2 / 1000.0)
)
SELECT sum(valeur * extract(epoch FROM least(fin, to_timestamp($2 / 1000.0)) - greatest(ts, to_timestamp($1 / 1000.0)))
* CASE WHEN cle = 'sensor.victron_evcs_ac_power' THEN -1 ELSE 1 END) / 3.6e6 AS kwh,
count(*) FILTER (WHERE cle = 'sensor.victron_consumption_power_l1') AS points
FROM pts WHERE fin > to_timestamp($1 / 1000.0)`;
let nuitMaisonPubliee = false;
async function publierNuitMaison(client) {
if (DRY || !client) return 0;
const maintenant = new Date();
const cadre = nuitMaisonPrevue([], NaN, maintenant);
// Les nuits closes sont lues A CHAQUE fois, meme le jour : elles alimentent
// aussi le nuage du tableau HA (attribut `nuage`, [soiree, nuit, voiture 0/1]).
const nuits = (await client.query(NUITS_SQL +
'\nSELECT soiree_kwh AS soiree, maison_nuit_kwh AS nuit, voiture_kwh AS voiture FROM nuits ORDER BY j')).rows;
let soiree = NaN;
if (cadre.etat !== 'hors_fenetre') {
const debut = cadre.attributs.debut_ms;
const soir = (await client.query(SOIREE_SQL, [debut - 3.5 * 3600000, debut])).rows[0];
// Une soiree sans mesure (service arrete) ne vaut pas zero kWh.
soiree = soir && Number(soir.points) > 0 ? Number(soir.kwh) : NaN;
}
const res = nuitMaisonPrevue(nuits, soiree, maintenant);
const nuage = nuits.map((n) => [Number(n.soiree), Number(n.nuit), Number(n.voiture) >= 0.5 ? 1 : 0]);
await publierCapteur('sensor.evcs_nuit_maison_prevue_kwh', res.etat, {
friendly_name: 'EVCS — nuit de la maison prevue par la soiree', icon: 'mdi:chart-scatter-plot',
unit_of_measurement: typeof res.etat === 'number' ? 'kWh' : undefined, ...res.attributs,
nuage, calcule_a: maintenant.toISOString(),
});
return 1;
}
let bilanNuitPublie = false; // publie des le premier cycle, puis toutes les 5 min
let chauffeEauPublie = false; // chauffe-eau sur 24 h (07/10) : premier cycle puis toutes les 5 min
async function publierBilanNuit(client) {
if (DRY || !client) return 0;
const d = await donneesNuit(client);
const bn = bilanNuit(d, texteDepuis(d));
await publierCapteur('sensor.evcs_bilan_nuit', bn.etat, {
friendly_name: 'EVCS — bilan de la derniere nuit', icon: 'mdi:weather-night', ...bn.attributs,
calcule_a: new Date().toISOString(),
});
return 1;
}
async function publierCellulesHelpers(client) {
if (DRY) return 0;
// Base injoignable : on publie « muet » plutot que de laisser au capteur son
// dernier etat « frais », qui vieillirait sans que rien ne le dise.
const muet = (cause) => publierCapteur('sensor.evcs_cellules', 'muet', {
friendly_name: 'EVCS cellules — ecart max du pack', icon: 'mdi:battery-heart-variant',
statut: 'muet', explication: `Detail cellule indisponible (${cause}).`,
});
if (!client) { await muet('base injoignable'); return 0; }
const { rows: [r] } = await client.query(
`WITH mx AS (SELECT max(ts) AS t FROM cellule),
-- 15 min et non 5 (26/09) : deux lecteurs, un par pack, chacun toutes les
-- 10 min et sans synchronisation ; une fenetre plus courte que le pas
-- ferait disparaitre un pack sur deux selon l'instant du calcul.
recent AS (SELECT DISTINCT ON (module) module, ts FROM cellule
WHERE ts > (SELECT t FROM mx) - interval '15 minutes' ORDER BY module, ts DESC),
spreads AS (SELECT c.module, max(c.mv) - min(c.mv) AS ecart,
max(CASE WHEN c.pack IS NOT NULL AND c.rang IS NOT NULL
THEN c.pack * 10 + c.rang END) AS repere
FROM cellule c JOIN recent rc ON rc.module = c.module AND rc.ts = c.ts
GROUP BY c.module)
SELECT (SELECT max(ecart) FROM spreads) AS spread_max,
(SELECT module FROM spreads ORDER BY ecart DESC NULLS LAST LIMIT 1) AS weakest,
(SELECT repere FROM spreads ORDER BY ecart DESC NULLS LAST LIMIT 1) AS weakest_repere,
(SELECT round(EXTRACT(EPOCH FROM (now() - t)) / 60)::int FROM mx) AS age_min,
(SELECT count(*) FROM spreads)::int AS modules_lus`)
.catch(async (e) => { await muet('base injoignable').catch(() => {}); throw e; });
// Capteur unique pour l'agent (25/09) : releve perime => ni ecart ni module
// visibles (G13b du banc OpenClaw). Publie avant le test de table vide, pour
// que « muet » soit dit aussi quand il n'y a rien.
const cel = etatCellules(r);
await publierCapteur('sensor.evcs_cellules', cel.etat, {
friendly_name: 'EVCS cellules — ecart max du pack', unit_of_measurement: typeof cel.etat === 'number' ? 'mV' : undefined,
icon: 'mdi:battery-heart-variant', ...cel.attributs,
});
// Les trois helpers evcs_cell_* du 24/09 sont retires (25/09) : un
// input_number garde sa derniere valeur quand le releve perime, c'etait
// precisement le defaut G13b. sensor.evcs_cellules les remplace.
return r && r.age_min !== null ? 1 : 0;
}
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();
sourcePrevisionActive = st.get('input_select.evcs_balance_forecast_source')?.state === 'vrm' ? 'vrm' : 'forecast.solar';
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),
anomalie: await detecterAnomalieCharge(sql, st).catch((e) => {
console.error(`detection anomalie charge en echec : ${e.message}`); return 0;
}),
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;
}),
kilometrage: DRY ? 0 : await ingererReleves(sql, (q, v) => client.query(q, v), st, journaliser).catch((e) => {
console.error(`releves km en echec : ${e.message}`); return 0;
}) + await ingererNiveauSaisi(sql, st).catch((e) => {
console.error(`niveau saisi en echec : ${e.message}`); return 0;
}) + await ingererJeep(sql, (q, v) => client.query(q, v), st, journaliser,
(branche, niveau, motif) => etiqueterJeepUconnect(sql, client, st, branche, niveau, motif)).catch((e) => {
console.error(`jeep uconnect en echec : ${e.message}`); return 0;
}),
pvDiagnostic: DRY ? 0 : await publierDiagnosticPv(st).catch((e) => {
console.error(`diagnostic PV 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;
}),
vrm: (DRY || (vrmPublie && new Date().getMinutes() % 15 !== 7)) ? 0 : await publierPrevisionVrm(sql).then((n) => { vrmPublie = true; return n; }).catch((e) => {
console.error(`prevision VRM : ${e.message}`); return 0;
}),
nuitMaison: (DRY || (nuitMaisonPubliee && new Date().getMinutes() % 5 !== 2)) ? 0 : await publierNuitMaison(client).then((n) => { nuitMaisonPubliee = true; return n; }).catch((e) => {
console.error(`nuit de la maison : ${e.message}`); return 0;
}),
chauffeEau24h: (DRY || (chauffeEauPublie && new Date().getMinutes() % 5 !== 4)) ? 0 : await publierChauffeEau24h().then((n) => { chauffeEauPublie = true; return n; }).catch((e) => {
console.error(`chauffe-eau 24 h : ${e.message}`); return 0;
}),
bilanNuit: (DRY || (bilanNuitPublie && new Date().getMinutes() % 5 !== 1)) ? 0 : await publierBilanNuit(client).then((n) => { bilanNuitPublie = true; return n; }).catch((e) => {
console.error(`bilan de nuit : ${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;
}),
// Publie le detail cellule dans des helpers HA (pour l'agent vocal), APRES
// l'ingestion pour utiliser le relevé qui vient d'arriver.
cellulesHelpers: DRY ? 0 : await publierCellulesHelpers(client).catch((e) => {
console.error(`publication helpers cellule 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} anomalie=${r.anomalie} 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}`);
// Les deux capteurs de l'agent vocal ne doivent pas rester figes sur leur
// dernier etat quand le cycle tombe (base injoignable : cycle() n'est meme
// pas appele). Le diagnostic PV ne depend que de HA ; le detail cellule, lui,
// passe a « muet ».
if (!DRY) {
await etats().then(publierDiagnosticPv).catch(() => {});
await publierCapteur('sensor.evcs_cellules', 'muet', {
friendly_name: 'EVCS cellules — ecart max du pack', icon: 'mdi:battery-heart-variant',
statut: 'muet', explication: `Detail cellule indisponible (${dernierEchec}).`,
}).catch(() => {});
}
}
// 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));
}