Le concierge etiquette le branchement, plus la session

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GwpFpy2aQ2rJok6tNQSbVj
This commit is contained in:
Tallagrand Thierry
2026-09-14 21:14:38 +02:00
parent 0d597e7efc
commit d31878c66a
2 changed files with 145 additions and 15 deletions

View File

@@ -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

View File

@@ -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