Files
EVCS/tools/historise-evcs.mjs
Tallagrand Thierry c3ef58dd18 Une session de charge ne raconte plus celle d avant
Le journal a annonce deux fois 4,20 kWh : la vraie charge de nuit, puis onze
minutes de charge diurne. L energie de la borne etait echantillonnee a chaque
cycle et conservee par un GREATEST — un operateur monotone, qui ne peut que
surestimer et qui verrouille la premiere valeur vue. Au premier echantillon de
la session de midi, le capteur portait encore le total de la nuit.

Confrontation des cinq lignes a l integration relue dans InfluxDB sur les
periodes de charge reelles : ecarts de 1, 5, 14, 60 et 1135 %.

Le compteur cumulatif de la borne devient la mesure de reference : releve a
l ouverture, sa difference a la cloture donne l energie sans ambiguite de
semantique ni erreur d integration. last_session_energy est lu une seule fois,
a la cloture, et enregistre comme observation ; le journal ne l affiche que
s il s ecarte de plus de 10 %, ce qui rend visible le cas d une session
commencee avant le demarrage du service au lieu de le substituer en silence.
Les treize capteurs de la borne entrent enfin dans l historisation InfluxDB :
c est leur absence qui empechait d etablir cette semantique.

Second defaut de la meme fonction : l heure de debranchement etait posee en
bloc sur toutes les sessions non marquees des dix-huit dernieres heures. La
session de nuit de la JEEP a ainsi herite du debranchement de la VW — l inverse
du role de cette colonne. La duree de branchement la borne desormais.

Reparation idempotente des lignes existantes, limitee a ce qui a ete verifie.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KKzkkVTG6KXnGXFFC7X9XJ
2026-09-06 14:03:17 +02:00

784 lines
37 KiB
JavaScript

#!/usr/bin/env node
// =====================================================================
// Historisation EVCS — alimente PostgreSQL depuis Home Assistant.
// =====================================================================
//
// POURQUOI PAS DEPUIS n8n. C'était l'idée de départ (« n8n calcule déjà ce qui
// compte, l'écrire est une ligne de plus »). Mais tout ce que n8n calcule, il
// le PUBLIE déjà dans des helpers Home Assistant : SOC requis, heure de
// croisement, décisions, prévisions. Les lire ici donne la même information
// sans toucher aux trois workflows de production qu'on vient de stabiliser, et
// sans créer d'identifiant de connexion dans n8n.
//
// Ce service tourne sur 10.10.10.22, à côté de la base : si 10.10.10.17 tombe,
// il n'y a de toute façon plus rien à enregistrer, mais la mémoire déjà
// constituée, elle, survit.
//
// L'HORODATAGE EST CELUI DE HOME ASSISTANT, pas celui du sondage. Une valeur
// inchangée depuis le cycle précédent porte donc la même clé primaire et n'est
// pas réécrite (ON CONFLICT DO NOTHING) : on enregistre quand la grandeur a
// bougé, pas quand on a regardé.
//
// Usage :
// node tools/historise-evcs.mjs # boucle
// node tools/historise-evcs.mjs --once # un cycle puis sortie
// node tools/historise-evcs.mjs --once --dry-run # sans écrire
// =====================================================================
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import pg from 'pg';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const ONCE = process.argv.includes('--once');
const DRY = process.argv.includes('--dry-run');
const HA_URL = process.env.HA_URL || 'http://10.10.10.17:8123';
const INTERVAL_S = Number(process.env.HISTORISE_INTERVAL_S || 60);
function fromEnvFile(cle) {
const p = path.join(ROOT, '.env');
if (!fs.existsSync(p)) return null;
for (const l of fs.readFileSync(p, 'utf8').split('\n')) {
const m = l.match(new RegExp(`^\\s*${cle}\\s*=\\s*(.+?)\\s*$`));
if (m) return m[1].replace(/^["']|["']$/g, '');
}
return null;
}
const HA_TOKEN = process.env.HA_TOKEN || fromEnvFile('HA_TOKEN');
if (!HA_TOKEN) {
console.error('HA_TOKEN absent (ni variable d\'environnement, ni .env).');
process.exit(1);
}
// ---------------------------------------------------------------------
// Grandeurs suivies. Élargir ici est sans risque : une clé nouvelle
// commence simplement à s'enregistrer. Retirer une clé n'efface rien.
// ---------------------------------------------------------------------
const MESURES = [
'sensor.victron_battery_soc',
'sensor.victron_battery_current',
'sensor.victron_battery_power',
'sensor.victron_battery_voltage',
'sensor.victron_pv_power_system',
'sensor.victron_consumption_power_l1',
'sensor.victron_grid_power_l1',
'sensor.victron_evcs_ac_power',
'sensor.victron_evcs_current',
'sensor.victron_vebus_dc_power_system',
'sensor.sun_solar_elevation',
'sensor.victron_max_charge_current_setting',
// Le plafond impose par le BMS lui-meme, a distinguer de la limite DVCC
// ci-dessus que l'Auto Charge Manager ecrit. C'est LUI qui s'effondre quand
// une cellule atteint 3550 mV, et il commande tout le parc.
'sensor.airtria_8_us2000c_maximum_allowed_charge_current',
'sensor.airtria_8_us2000c_maximum_allowed_charging_voltage',
'sensor.airtria_8_us2000c_maximum_allowed_discharge_current',
'sensor.victron_max_charge_voltage_setting',
'number.gx_device_ac_power_setpoint',
'input_number.evcs_balance_required_soc',
'input_number.evcs_balance_energy_to_cover_kwh',
'input_number.victron_evcs_night_target_soc',
];
// ---------------------------------------------------------------------
// EVENEMENTS DETECTES AUTOMATIQUEMENT
// ---------------------------------------------------------------------
// « Une courbe sans ses evenements ne s'interprete pas » : six mois plus tard,
// une discontinuite ne veut rien dire sans la ligne qui dit ce qui s'est passe
// ce jour-la. Le service voit passer les etats toutes les minutes — autant
// qu'il consigne lui-meme ce qu'il peut constater, plutot que d'attendre que
// quelqu'un y pense.
//
// On suit des etats DISCRETS dont chaque changement merite une ligne. Les
// grandeurs continues n'ont rien a faire ici (elles sont dans `mesure`), et les
// decisions du controleur non plus (elles sont dans `decision`) : un evenement
// qui double une autre table est du bruit.
const TRANSITIONS_SUIVIES = [
['sensor.airtria_8_us2000c_tension_de_cellule_elevee', 'batterie', 'Alarme tension de cellule'],
// Avec quatre modules le plafond de decharge tombe a 100 A, soit ~5,1 kW,
// quand la maison a deja pointe a 8 kW en cuisinant. Cette alarme dira si
// les pointes du soir touchent la limite.
['sensor.airtria_8_us2000c_high_discharge_current', 'batterie', 'Alarme courant de decharge'],
['sensor.airtria_8_us2000c_low_voltage', 'batterie', 'Alarme tension basse'],
['switch.edf_general_ok_switch', 'reseau', 'Contacteur EDF'],
['input_boolean.victron_evcs_night_grid_session_committed','reseau', 'Session reseau de nuit'],
['input_boolean.victron_evcs_night_grid_takeover_needed', 'reseau', 'Reprise reseau nuit'],
['input_boolean.victron_grid_hysteresis_latch', 'reseau', 'Verrou hysteresis'],
['binary_sensor.victron_evcs_vehicle_connected', 'vehicule', 'Vehicule branche'],
['input_select.victron_evcs_vehicle', 'intervention','Vehicule selectionne'],
['input_select.victron_evcs_strategy', 'intervention','Strategie'],
['sensor.gx_device_ess_batterylife_state', 'ess', 'Etat BatteryLife de l ESS'],
['input_text.victron_charge_mode', 'ess', 'Mode Auto Charge Manager'],
['input_boolean.evcs_balance_grid_needed', 'bilan', 'Reseau necessaire'],
['input_boolean.evcs_balance_storm_active', 'bilan', 'Vigilance orage'],
['input_boolean.evcs_balance_pv_censored', 'bilan', 'Production bridee'],
];
// Heure de demarrage de Home Assistant, sans capteur d'uptime — il n'y en a pas
// sur cette installation. Au redemarrage, HA restaure ses helpers et leur pose
// un `last_changed` neuf : le 04/09, 352 entites partageaient la seconde du
// redemarrage, dont 183 input_number. Le MODE des last_changed des
// input_number est donc l'heure de demarrage. Aucune entite codee en dur, et
// l'estimation se corrige d'elle-meme : entre deux redemarrages, seuls les
// quelques helpers reellement ecrits s'ecartent du mode.
function demarrageHaMs(st) {
const paquets = new Map();
for (const [id, e] of st) {
if (!id.startsWith('input_number.')) continue;
const t = Date.parse(e.last_changed);
if (!Number.isFinite(t)) continue;
const cle = Math.floor(t / 1000);
paquets.set(cle, (paquets.get(cle) || 0) + 1);
}
let meilleur = null, n = 0;
for (const [cle, c] of paquets) if (c > n) { n = c; meilleur = cle; }
// Moins de 10 helpers d'accord : signature trop faible pour affirmer quoi que
// ce soit. On ne devine pas.
return n >= 10 ? meilleur * 1000 : null;
}
async function journaliser(sql, genre, resume, detail) {
await sql(
`INSERT INTO evenement (genre, source, resume, detail) VALUES ($1, $2, $3, $4)`,
[genre, 'historisation', resume, JSON.stringify(detail || {})]
);
console.log(` evenement [${genre}] ${resume}`);
}
const num = (v) => {
const n = Number.parseFloat(v);
return Number.isFinite(n) ? n : null;
};
const txt = (s) => (s ?? '').toString().trim();
async function etats() {
const r = await fetch(`${HA_URL}/api/states`, {
headers: { Authorization: `Bearer ${HA_TOKEN}` },
});
if (!r.ok) throw new Error(`HA ${r.status}`);
const tout = await r.json();
const m = new Map();
for (const s of tout) m.set(s.entity_id, s);
return m;
}
// ---------------------------------------------------------------------
// Écritures. Chacune est idempotente : rejouer un cycle n'invente rien.
// ---------------------------------------------------------------------
async function ecrireMesures(sql, st) {
let n = 0;
for (const id of MESURES) {
const e = st.get(id);
if (!e) continue;
const v = num(e.state);
if (v === null) continue; // 'unknown' / 'unavailable' : on ne devine pas
const { rowCount } = await sql(
`INSERT INTO mesure (ts, cle, valeur) VALUES ($1, $2, $3)
ON CONFLICT (cle, ts) DO NOTHING`,
[e.last_updated, id, v]
);
n += rowCount;
}
return n;
}
async function ecrireBilan(sql, st) {
const marqueur = st.get('input_text.evcs_balance_last_run');
if (!marqueur || !txt(marqueur.state)) return 0;
const g = (id) => st.get(id);
const { rowCount } = await sql(
`INSERT INTO bilan (ts, statut, soc_pct, energie_a_couvrir_kwh, soc_requis_pct,
soc_requis_prudent_pct, heure_croisement, reseau_necessaire,
reseau_energie_kwh, manque_stockage_kwh, pv_censure,
motif_censure, orage_niveau, orage_reserve_kwh, brut)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15)
ON CONFLICT (ts) DO NOTHING`,
[
marqueur.last_updated,
txt(g('input_text.evcs_balance_status')?.state),
num(g('sensor.victron_battery_soc')?.state),
num(g('input_number.evcs_balance_energy_to_cover_kwh')?.state),
num(g('input_number.evcs_balance_required_soc')?.state),
num(g('input_number.evcs_balance_required_soc_prudent')?.state),
txt(g('input_text.evcs_balance_crossing_hour')?.state),
txt(g('input_boolean.evcs_balance_grid_needed')?.state) === 'on',
num(g('input_number.evcs_balance_grid_energy_needed_kwh')?.state),
num(g('input_number.evcs_balance_storage_shortfall_kwh')?.state),
txt(g('input_boolean.evcs_balance_pv_censored')?.state) === 'on',
txt(g('input_text.evcs_balance_censor_reason')?.state),
txt(g('input_text.evcs_balance_storm_level')?.state),
num(g('input_number.evcs_balance_storm_reserve_kwh')?.state),
JSON.stringify({
raison: txt(g('input_text.evcs_balance_reason')?.state),
fenetre_prochain_deficit: txt(g('input_text.evcs_balance_next_deficit_window')?.state),
soc_requis_prochain_deficit: num(g('input_number.evcs_balance_next_deficit_required_soc')?.state),
}),
]
);
return rowCount;
}
async function ecrireDecision(sql, st) {
const etat = st.get('input_text.victron_evcs_surplus_state');
if (!etat) return 0;
const g = (id) => st.get(id);
const { rowCount } = await sql(
`INSERT INTO decision (ts, profil, source_mode, etat, decision, charge_desiree,
courant_a, soc_pct, cible_matin_pct, takeover_reseau,
session_engagee, raison, brut)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)
ON CONFLICT (ts) DO NOTHING`,
[
etat.last_updated,
txt(g('input_text.victron_evcs_operating_mode')?.state),
txt(g('input_text.victron_evcs_source_mode')?.state),
txt(etat.state),
txt(g('input_text.victron_evcs_surplus_current_decision')?.state),
num(g('sensor.victron_evcs_ac_power')?.state) > 0,
num(g('number.victron_evcs_set_current_command')?.state),
num(g('sensor.victron_battery_soc')?.state),
num(g('input_number.victron_evcs_night_target_soc')?.state),
txt(g('input_boolean.victron_evcs_night_grid_takeover_needed')?.state) === 'on',
txt(g('input_boolean.victron_evcs_night_grid_session_committed')?.state) === 'on',
txt(g('input_text.victron_evcs_surplus_reason')?.state),
JSON.stringify({
session_nuit: txt(g('input_boolean.victron_evcs_night_session_active')?.state),
edf: txt(g('switch.edf_general_ok_switch')?.state),
strategie: txt(g('input_select.victron_evcs_strategy')?.state),
vehicule: txt(g('input_select.victron_evcs_vehicle')?.state),
}),
]
);
return rowCount;
}
// La prévision n'a de valeur que datée de sa PUBLICATION : sans cela on ne peut
// pas mesurer son biais, qui est précisément la question ouverte (§42).
async function ecrirePrevision(sql, st) {
const marqueur = st.get('input_text.evcs_balance_forecast_updated');
if (!marqueur || !txt(marqueur.state)) return 0;
const publieA = marqueur.last_updated;
const auj = new Date(publieA);
let n = 0;
for (const [id, decalageJours] of [
['input_text.evcs_balance_forecast_today', 0],
['input_text.evcs_balance_forecast_tomorrow', 1],
]) {
const e = st.get(id);
if (!e) continue;
const v = txt(e.state).split(',').map(Number);
if (v.length !== 24 || v.some((x) => !Number.isFinite(x))) continue;
const jour = new Date(auj.getTime() + decalageJours * 86400000)
.toLocaleDateString('sv-SE', { timeZone: 'Europe/Paris' }); // AAAA-MM-JJ
const { rowCount } = await sql(
`INSERT INTO prevision (publie_a, jour, total_kwh, horaire)
VALUES ($1,$2,$3,$4) ON CONFLICT (publie_a, jour) DO NOTHING`,
[publieA, jour, v.reduce((a, b) => a + b, 0), v]
);
n += rowCount;
}
return n;
}
// Memoire entre cycles. Au PREMIER passage on enregistre l'etat sans rien
// journaliser : sinon chaque redemarrage du service produirait douze fausses
// transitions. Seul le demarrage du service lui-meme est consigne.
const memoire = { amorce: false, demarrageHa: null, valeurs: new Map() };
async function detecterEvenements(sql, st) {
if (!memoire.amorce) {
for (const [id] of TRANSITIONS_SUIVIES.map((t) => [t[0]])) {
const e = st.get(id);
if (e) memoire.valeurs.set(id, txt(e.state));
}
memoire.demarrageHa = demarrageHaMs(st);
memoire.amorce = true;
await journaliser(sql, 'systeme', 'Service d historisation demarre', {
suivis: TRANSITIONS_SUIVIES.length,
});
return 1;
}
let n = 0;
// Redemarrage de Home Assistant. Tolerance de 5 s : l'estimation par mode
// peut bouger d'une seconde sans qu'il se soit rien passe.
const demarrage = demarrageHaMs(st);
if (
demarrage !== null &&
memoire.demarrageHa !== null &&
demarrage > memoire.demarrageHa + 5000
) {
await journaliser(sql, 'systeme', 'Home Assistant a redemarre', {
demarre_a: new Date(demarrage).toISOString(),
precedent: new Date(memoire.demarrageHa).toISOString(),
});
n++;
}
if (demarrage !== null) memoire.demarrageHa = demarrage;
// Transitions suivies.
for (const [id, genre, libelle] of TRANSITIONS_SUIVIES) {
const e = st.get(id);
if (!e) continue;
const cur = txt(e.state);
// Une lecture indisponible n'est pas une transition : c'est une absence de
// lecture. La consigner ferait deux lignes de bruit a chaque hoquet MQTT.
if (cur === '' || cur === 'unavailable' || cur === 'unknown') continue;
const prec = memoire.valeurs.get(id);
if (prec === undefined) { memoire.valeurs.set(id, cur); continue; }
if (prec === cur) continue;
await journaliser(sql, genre, `${libelle} : ${prec} -> ${cur}`, {
entite: id, avant: prec, apres: cur,
horodatage_ha: e.last_changed,
});
memoire.valeurs.set(id, cur);
n++;
}
return n;
}
// ---------------------------------------------------------------------
// Le service fait evoluer son propre schema. `schema.sql` n'est applique qu'a
// la CREATION de la base : une table ajoutee plus tard n'y arriverait jamais.
// `IF NOT EXISTS` rend l'operation rejouable a chaque demarrage.
// ---------------------------------------------------------------------
async function migrer(client) {
await client.query(`
CREATE TABLE IF NOT EXISTS session_charge (
debut timestamptz PRIMARY KEY,
fin timestamptz NOT NULL,
terminee boolean NOT NULL DEFAULT false,
vehicule text,
vehicule_sur boolean NOT NULL DEFAULT true,
energie_kwh double precision NOT NULL DEFAULT 0,
p_max_w double precision,
courant_max_a double precision,
consigne_max_a double precision,
source_mode text,
debranche_a timestamptz,
brut jsonb
)`);
// Part venue du reseau. On se fie au `source_mode` du controleur — son
// INTENTION, sans ambiguite — et non a la puissance reseau instantanee, qui
// peut alimenter la maison pendant que le soleil alimente la voiture.
await client.query(
'ALTER TABLE session_charge ADD COLUMN IF NOT EXISTS energie_reseau_kwh '
+ 'double precision NOT NULL DEFAULT 0');
// Le compteur de la BORNE, qui fait foi. Mon integration de la puissance
// est robuste mais ne couvre que ce que le service a vu : le 05/09, la
// session avait commence a 01:25 et le detecteur n'a compte qu'a partir de
// 03:16 — 0,75 kWh au lieu de 8. La borne, elle, compte depuis le debut.
await client.query(
'ALTER TABLE session_charge ADD COLUMN IF NOT EXISTS energie_station_kwh double precision');
// 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`);
}
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)');
// Le role de lecture doit voir la nouvelle table. S'il n'existe pas encore,
// on ne fait pas echouer le demarrage pour autant.
await client.query(`DO $$ BEGIN
GRANT SELECT ON session_charge TO evcs_lecture;
EXCEPTION WHEN undefined_object THEN NULL; END $$`);
}
// ---------------------------------------------------------------------
// Sessions de charge.
//
// Pourquoi une table plutot qu'un calcul a la demande : l'etiquette du
// vehicule n'existe QUE pendant la session. `input_select.victron_evcs_vehicle`
// est un reglage manuel, il change, et rien ne conserve sa valeur au moment ou
// la charge a eu lieu. Sans cet enregistrement, une session d'il y a trois
// semaines est anonyme pour toujours.
//
// ⚠️ On n'essaie PAS de deviner le vehicule. Mesure du 05/09 : a consigne
// identique de 20 A, la VW accepte 3 680 W et la JEEP 3 655 W — 0,1 A d'ecart.
// Ils sont electriquement indiscernables. Et l'heure ne dit rien non plus : en
// semaine la JEEP charge le midi et la VW la nuit, mais le week-end c'est
// l'inverse. On enregistre ce que le selecteur disait, et on note s'il a change
// en cours de route.
// ---------------------------------------------------------------------
const SEUIL_CHARGE_W = 300; // au-dela, la voiture prend vraiment du courant
const FIN_APRES_MIN = 10; // sous le seuil pendant ce temps : session close
// "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 = coalesce($7, vehicule),
vehicule_sur = vehicule_sur AND ($7 IS NULL OR vehicule IS NULL OR vehicule = $7)
WHERE debut = $1`,
[ouverte.debut, maintenant, p, dt,
num(g('sensor.victron_evcs_current')?.state),
num(g('sensor.victron_evcs_set_current')?.state), vehicule,
txt(g('input_text.victron_evcs_source_mode')?.state).startsWith('Réseau') ? 1 : 0]);
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);
const parCompteur = compteur !== null && ouverte.compteur_debut_kwh !== null
&& compteur >= ouverte.compteur_debut_kwh
? compteur - Number(ouverte.compteur_debut_kwh) : 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;
}
// ---------------------------------------------------------------------
// 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;
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
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, null, null, 'suivi']);
}
}
for (let i = 0; i < val.length; i += 500) {
const lot = val.slice(i, i + 500);
const ph = lot.map((_, k) => `($${k * 8 + 1},$${k * 8 + 2},$${k * 8 + 3},$${k * 8 + 4},$${k * 8 + 5},$${k * 8 + 6},$${k * 8 + 7},$${k * 8 + 8})`).join(',');
const r = await client.query(
`INSERT INTO cellule (ts, module, cellule, mv, soc, courant_ma, etat, source)
VALUES ${ph} ON CONFLICT (ts, module, cellule) DO NOTHING`, lot.flat());
lignes += r.rowCount;
}
}
return lignes;
}
async function cycle(client) {
// En blanc, on compte ce qu'on ECRIRAIT. Un essai a blanc qui renvoie
// toujours zero ne verifie rien — c'est le piege classique du test qui ne
// regarde que le chemin heureux.
const sql = DRY
? async (q, p) => {
if (process.env.HISTORISE_VERBOSE) {
console.log(' ' + q.split('\n')[0].trim() + ' <- ' + JSON.stringify(p).slice(0, 120));
}
return { rowCount: 1 };
}
: (q, p) => client.query(q, p);
const st = await etats();
const r = {
mesures: await ecrireMesures(sql, st),
bilan: await ecrireBilan(sql, st),
decision: await ecrireDecision(sql, st),
prevision: await ecrirePrevision(sql, st),
session: await ecrireSession(sql, client, st),
evenement: await detecterEvenements(sql, st),
// 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.
cellules: DRY ? 0 : await ingererCellules(client).catch((e) => {
console.error(`ingestion cellules en echec : ${e.message}`);
return 0;
}),
};
const total = r.mesures + r.bilan + r.decision + r.prevision + r.evenement + r.session + r.cellules;
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} cellules=${r.cellules}`
);
}
}
const client = new pg.Client({
host: process.env.PGHOST || '10.10.10.22',
port: Number(process.env.PGPORT || 5432),
user: process.env.PGUSER || 'evcs',
database: process.env.PGDATABASE || 'evcs',
password: process.env.PGPASSWORD,
});
if (!DRY) {
await client.connect();
await migrer(client); // schema a jour avant le premier cycle
}
console.log(
`Historisation EVCS — HA=${HA_URL} base=${DRY ? '(blanc)' : client.host + ':' + client.port} ` +
`intervalle=${INTERVAL_S}s`
);
if (ONCE) {
await cycle(client);
if (!DRY) await client.end();
process.exit(0);
}
// Une panne d'un cycle ne doit pas tuer la boucle : Home Assistant redémarre,
// le réseau hoquette, la base se recharge. On journalise et on retente.
// Un episode d'indisponibilite est un evenement a part entiere : c'est
// exactement ce qu'on voudra retrouver devant un trou dans les courbes. Il est
// journalise au RETOUR, avec sa duree — au moment de la panne, la base n'est
// peut-etre pas joignable non plus.
let echecDepuis = null;
for (;;) {
try {
await cycle(client);
if (echecDepuis !== null) {
const duree = Math.round((Date.now() - echecDepuis) / 1000);
try {
await journaliser(client.query.bind(client), 'systeme',
`Home Assistant injoignable pendant ${duree} s`,
{ depuis: new Date(echecDepuis).toISOString(), secondes: duree });
} catch { /* la base non plus n'etait peut-etre pas la : on n'insiste pas */ }
echecDepuis = null;
}
} catch (e) {
if (echecDepuis === null) echecDepuis = Date.now();
console.error(`${new Date().toISOString()} cycle en echec : ${e.message}`);
}
await new Promise((r) => setTimeout(r, INTERVAL_S * 1000));
}