From d31878c66a5af11361a33ddd95a20f87e6b5418c Mon Sep 17 00:00:00 2001 From: Tallagrand Thierry Date: Mon, 14 Sep 2026 21:14:38 +0200 Subject: [PATCH] Le concierge etiquette le branchement, plus la session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Il demandait « quel vehicule ? » a l ouverture d une session de charge. Trop tard, et ca s est vu ce soir : a 19:55 la voiture est branchee, le selecteur perime depuis six jours, et aucune question ne peut partir parce qu aucune session n existe — la borne ne tire rien, il fait nuit. La premiere session de la nuit serait nee etiquetee VW sans que personne ne soit consulte. On etiquette desormais le BRANCHEMENT. C est d ailleurs l invariant du projet — un cable, un vehicule — et ca rend la question possible des l instant ou on sait qu il y a une voiture. Deux temps. Le lecteur accepte une clef `branchement` en plus des `sessions`, et memorise l etiquette dans une table dediee ; une passe differee, a chaque cycle, la pose sur les sessions du branchement AU FUR ET A MESURE qu elles s ouvrent, sans nouvelle question. Bornee par le debranchement suivant, avec les deux secondes de tolerance du 09/09 — une session s ouvre jusqu a 62 ms avant que sa transition de branchement soit journalisee. L ancien contrat reste accepte : une etiquette portant des sessions fonctionne comme avant. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01GwpFpy2aQ2rJok6tNQSbVj --- docs/PROTOCOLE_LVHUB_CERBO.md | 46 +++++++++++--- tools/historise-evcs.mjs | 114 +++++++++++++++++++++++++++++++--- 2 files changed, 145 insertions(+), 15 deletions(-) diff --git a/docs/PROTOCOLE_LVHUB_CERBO.md b/docs/PROTOCOLE_LVHUB_CERBO.md index 4bdba7d..4637a03 100644 --- a/docs/PROTOCOLE_LVHUB_CERBO.md +++ b/docs/PROTOCOLE_LVHUB_CERBO.md @@ -24,6 +24,17 @@ capacite_installee = 400 Ah (attendu : 650 = 4 x 100 + 5 x 50) | **Les deux packs sont sains** | 75 cellules du pack 2 à `SOHCount 0` / `Normal`, compteurs 0-1. Pack 1 neuf, aucune alarme. | | **L'inventaire a changé le 13/09 à 21:35:09** | Événement isolé — ce n'est **pas** un redémarrage de Home Assistant, dont le dernier gros pic date du 06/09 14:21. | +## Ce que les captures de la console GX ajoutent (14/09 au soir) + +| observation | pourquoi ça compte | +|---|---| +| **Un seul appareil Pylontech** est listé dans Configuration Système → Batteries : `AIRTRIA 8 US2000C`. L'autre entrée est `AIRTRIA MP2`, le moniteur du MultiPlus. | Le hub ne présente pas deux BMS au Cerbo mais **un seul, agrégé**. Le problème n'est donc pas « le Cerbo ignore un appareil » : c'est cet appareil unique qui n'annonce que quatre modules. | +| Le nom porte **« 8 US2000C »** | Ce device a été créé quand le parc comptait huit modules. Le nom a survécu à l'évolution du parc, pas l'inventaire. | +| Moniteur de batterie = **Automatic**, sélection retenue = le BMS Pylontech | Une sélection manuelle existe. À ne PAS toucher avant l'étape 4 : le BMS est le bon choix, c'est lui qui protège les cellules. | +| **Capacité installée / disponible = 400 Ah / 0,0 Ah** | La capacité *disponible* à zéro est anormale et n'a jamais été expliquée. À relever à chaque étape : si elle se remplit après un rescan, c'est le signe que l'inventaire s'est rafraîchi. | +| Les deux moniteurs donnent des courants différents — BMS −56,9 A, MP2 −37,1 A | Confirme que les deux mesurent à des points différents. Ne pas les comparer comme s'ils devaient coïncider. | +| Le GX tourne en **Venus OS Large**, avec **Node-RED** accessible | Moyen de lire ou d'agir directement sur le GX si les étapes 1 à 3 ne suffisent pas. Non utilisé pour l'instant. | + ⚠️ **Le courant mesuré au parc n'est PAS un indicateur d'énumération.** Le Cerbo lit le shunt, donc tout le parc, y compris ce que son BMS n'énumère pas. Les seuls critères valables sont `nombre_de_modules_en_ligne` et @@ -55,24 +66,43 @@ Lecture seule, journal horodaté dans `docs/mesures/`. --- -## Étape 1 — Forcer une ré-énumération, sans toucher au câblage +## Étape 1 — « Détecter à nouveau la batterie », le rescan ciblé -**La moins risquée, et à faire en premier.** L'inventaire a changé une fois, le -13/09 à 21:35 : le Cerbo ré-inventorie donc à l'occasion. Peut-être n'a-t-il -simplement jamais rescanné depuis que les deux packs sont en place. +**La moins risquée de toutes, et à faire en premier.** La page de l'appareil +porte un bouton **« Détecter à nouveau la batterie »** (Paramètres → Appareils → +AIRTRIA 8 US2000C, en bas). Il annonce lui-même jusqu'à 30 secondes pendant +lesquelles le nom peut être incorrect. C'est exactement l'outil qu'il faut : un +rafraîchissement de l'inventaire, sans redémarrage et sans toucher un câble. + +L'hypothèse qu'il teste est sérieuse : le device s'appelle encore +« 8 US2000C », un inventaire qui n'existe plus. Et l'inventaire a bel et bien +changé une fois, le 13/09 à 21:35:09 — événement isolé, vérifié comme n'étant +pas un redémarrage de Home Assistant. - Fermer EDF, relevé « avant ». -- Redémarrer le **Cerbo** (interface GX → Paramètres → Général → Redémarrer). - Ne PAS toucher aux modules ni aux câbles. -- Attendre le retour complet, relevé « après ». +- Appuyer sur **Détecter à nouveau la batterie**, attendre le retour du nom. +- Relevé « après ». | résultat | conclusion | |---|---| | 9 modules / 650 Ah | **c'était un inventaire périmé.** Terminé. | -| toujours 4 / 400 | le Cerbo rescanne et ne retient qu'un groupe → étape 2 | +| le nom change mais toujours 4 / 400 | le rescan marche et ne trouve qu'un groupe → étape 1bis | +| rien ne bouge | le rescan n'a pas eu lieu → étape 1bis | Retour arrière : aucun, rien n'a été modifié. +## Étape 1bis — Redémarrer le Cerbo + +Si le rescan ciblé n'a rien donné, le redémarrage complet reprend toute la +détection depuis le début. + +- EDF toujours fermé, relevé « avant ». +- GX → Paramètres → Général → Redémarrer. Ne PAS toucher aux câbles. +- Attendre le retour complet, relevé « après ». + +Même grille de lecture qu'à l'étape 1. Si l'inventaire reste à 4 / 400 → +étape 2. + ## Étape 2 — Inverser l'ordre de mise sous tension Sur une chaîne Pylontech, l'ordre d'allumage détermine qui prend le rôle de diff --git a/tools/historise-evcs.mjs b/tools/historise-evcs.mjs index 3f04503..1ebdab8 100644 --- a/tools/historise-evcs.mjs +++ b/tools/historise-evcs.mjs @@ -551,6 +551,28 @@ async function migrer(client) { { origine: 'proprietaire', motif: 'le drapeau dit desormais « un humain a confirme »', confirmees_de_vive_voix: 6, date_arbitrage: '2026-09-13' }); } + // ------------------------------------------------------------------- + // ETIQUETTE DE BRANCHEMENT (14/09/2026) + // ------------------------------------------------------------------- + // Le concierge demandait « quel vehicule ? » a l ouverture d une SESSION de + // charge. Trop tard : le 14/09 a 19:55 le vehicule etait branche, le + // selecteur perime depuis six jours, et aucune question ne pouvait partir + // parce qu aucune session n existait — la borne ne tirait rien, il faisait + // nuit. La premiere session de la nuit serait donc nee mal etiquetee. + // + // On etiquette desormais le BRANCHEMENT, pas les sessions. C est d ailleurs + // l invariant du projet : un cable, un vehicule. L etiquette se pose des le + // branchement et s applique aux sessions AU FUR ET A MESURE qu elles + // s ouvrent — cette table est la memoire qui le permet. + await client.query(` + CREATE TABLE IF NOT EXISTS etiquette_branchement ( + branchement timestamptz PRIMARY KEY, + label text NOT NULL, + source_airtria text NOT NULL, + confirmed_at text, + pose_a timestamptz NOT NULL DEFAULT now(), + sessions_vues integer NOT NULL DEFAULT 0 + )`); await client.query( 'CREATE INDEX IF NOT EXISTS session_charge_debut_idx ON session_charge (debut DESC)'); @@ -1039,7 +1061,11 @@ async function appliquerEtiquetteConcierge(sql, client, st) { const source = txt(ordre.source); const confirmedAt = txt(ordre.confirmed_at); const demandees = Array.isArray(ordre.sessions) ? ordre.sessions.filter(Boolean) : []; - if (!label || !demandees.length) return 0; + // 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 @@ -1101,7 +1127,10 @@ async function appliquerEtiquetteConcierge(sql, client, st) { 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; - for (const d of demandees) { + // 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 @@ -1119,7 +1148,10 @@ async function appliquerEtiquetteConcierge(sql, client, st) { [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. - if (!rows.length) { frontiereConnue = false; continue; } + // 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)); } @@ -1158,16 +1190,82 @@ async function appliquerEtiquetteConcierge(sql, client, st) { return 0; } - const { rowCount } = await sql( + // On MEMORISE l etiquette au niveau du branchement. C est ce qui permet de + // repondre avant qu une session existe : les sessions qui s ouvriront ensuite + // la recevront au cycle suivant, sans nouvelle question. + const { rows: bornes } = await lire( + `SELECT max(ts) AS t FROM evenement + WHERE genre = 'vehicule' + AND detail->>'entite' = 'binary_sensor.victron_evcs_vehicle_connected' + AND detail->>'apres' = 'on' + AND ts <= $1::timestamptz + interval '1 second'`, + [brancheDemande || demandees[0]]); + const branchement = bornes[0]?.t || null; + if (branchement) { + await sql( + `INSERT INTO etiquette_branchement (branchement, label, source_airtria, confirmed_at) + VALUES ($1,$2,$3,$4) + ON CONFLICT (branchement) DO UPDATE + SET label = EXCLUDED.label, source_airtria = EXCLUDED.source_airtria, + confirmed_at = EXCLUDED.confirmed_at, pose_a = now()`, + [branchement, label, source, confirmedAt || null]); + } + + 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]); + [[...cibles], label, sur]) : { rowCount: 0 }; await journaliser(sql, 'vehicule', - `Etiquette ${label} appliquee par le concierge (${cibles.size} session${cibles.size > 1 ? 's' : ''}, ${source})`, - detail); + 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 }); return (rowCount || 0) + 1; } +// --------------------------------------------------------------------- +// APPLIQUER LES ETIQUETTES MEMORISEES AUX SESSIONS QUI S OUVRENT +// --------------------------------------------------------------------- +// Second temps du concierge : l etiquette est posee AU BRANCHEMENT, les +// sessions arrivent apres. Cette passe les rattrape a chaque cycle, sans +// nouvelle question et sans intervention. +// +// ⚠️ Elle n applique qu aux sessions du branchement etiquete, bornees par le +// debranchement suivant — l invariant « un cable, un vehicule ». Les 2 secondes +// de tolerance sur la borne basse sont celles du 09/09 : une session s ouvre +// jusqu a 62 ms AVANT que sa transition de branchement soit journalisee. +async function appliquerEtiquettesEnAttente(sql, client) { + if (DRY) return 0; + const { rows } = await client.query( + `SELECT branchement, label, source_airtria FROM etiquette_branchement + WHERE branchement > now() - interval '48 hours' ORDER BY branchement`); + let n = 0; + for (const e of rows) { + const sur = e.source_airtria === 'whatsapp'; + const { rowCount } = await sql( + `UPDATE session_charge s SET vehicule = $2, vehicule_sur = $3 + WHERE s.debut >= $1::timestamptz - interval '2 seconds' + AND s.debut < coalesce(( + SELECT min(ts) FROM evenement + WHERE genre = 'vehicule' + AND detail->>'entite' = 'binary_sensor.victron_evcs_vehicle_connected' + AND detail->>'apres' = 'off' AND ts > $1::timestamptz), 'infinity'::timestamptz) + AND (s.vehicule IS DISTINCT FROM $2 OR s.vehicule_sur IS DISTINCT FROM $3)`, + [e.branchement, e.label, sur]); + if (rowCount) { + await journaliser(sql, 'vehicule', + `Etiquette ${e.label} posee sur ${rowCount} session(s) ouverte(s) depuis le branchement`, + { origine: 'airtria', source_airtria: e.source_airtria, label: e.label, + branchement: e.branchement, applique_a: rowCount, differe: true }); + await client.query( + 'UPDATE etiquette_branchement SET sessions_vues = sessions_vues + $2 WHERE branchement = $1', + [e.branchement, rowCount]); + n += rowCount; + } + } + return n; +} + 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 @@ -1191,6 +1289,8 @@ async function cycle(client) { evenement: await detecterEvenements(sql, st), etiquette: await appliquerEtiquetteConcierge(sql, client, st).catch((e) => { console.error(`etiquette concierge en echec : ${e.message}`); return 0; + }) + await appliquerEtiquettesEnAttente(sql, client).catch((e) => { + console.error(`etiquettes differees en echec : ${e.message}`); return 0; }), // 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