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. | | **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. | | **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 ⚠️ **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 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 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 **La moins risquée de toutes, et à faire en premier.** La page de l'appareil
13/09 à 21:35 : le Cerbo ré-inventorie donc à l'occasion. Peut-être n'a-t-il porte un bouton **« Détecter à nouveau la batterie »** (Paramètres → Appareils →
simplement jamais rescanné depuis que les deux packs sont en place. 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 ». - Fermer EDF, relevé « avant ».
- Redémarrer le **Cerbo** (interface GX → Paramètres → Général → Redémarrer). - Appuyer sur **Détecter à nouveau la batterie**, attendre le retour du nom.
Ne PAS toucher aux modules ni aux câbles. - Relevé « après ».
- Attendre le retour complet, relevé « après ».
| résultat | conclusion | | résultat | conclusion |
|---|---| |---|---|
| 9 modules / 650 Ah | **c'était un inventaire périmé.** Terminé. | | 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é. 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 ## É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 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 »', { origine: 'proprietaire', motif: 'le drapeau dit desormais « un humain a confirme »',
confirmees_de_vive_voix: 6, date_arbitrage: '2026-09-13' }); 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( await client.query(
'CREATE INDEX IF NOT EXISTS session_charge_debut_idx ON session_charge (debut DESC)'); '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 source = txt(ordre.source);
const confirmedAt = txt(ordre.confirmed_at); const confirmedAt = txt(ordre.confirmed_at);
const demandees = Array.isArray(ordre.sessions) ? ordre.sessions.filter(Boolean) : []; 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 // 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 // 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 enIso = (x) => { const d = new Date(x); return Number.isNaN(+d) ? String(x) : d.toISOString(); };
const cibles = new Set(demandees.map(enIso)); const cibles = new Set(demandees.map(enIso));
let frontiereConnue = true; 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( const { rows } = await lire(
`WITH bas AS ( `WITH bas AS (
SELECT max(ts) AS t FROM evenement SELECT max(ts) AS t FROM evenement
@@ -1119,7 +1148,10 @@ async function appliquerEtiquetteConcierge(sql, client, st) {
[d]); [d]);
// Aucune borne : le journal ne remonte pas assez loin. On n'invente pas // 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. // 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)); for (const r of rows) cibles.add(enIso(r.debut));
} }
@@ -1158,16 +1190,82 @@ async function appliquerEtiquetteConcierge(sql, client, st) {
return 0; 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 `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)`, 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', await journaliser(sql, 'vehicule',
`Etiquette ${label} appliquee par le concierge (${cibles.size} session${cibles.size > 1 ? 's' : ''}, ${source})`, cibles.size
detail); ? `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; 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) { async function cycle(client) {
// En blanc, on compte ce qu'on ECRIRAIT. Un essai a blanc qui renvoie // 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 // 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), evenement: await detecterEvenements(sql, st),
etiquette: await appliquerEtiquetteConcierge(sql, client, st).catch((e) => { etiquette: await appliquerEtiquetteConcierge(sql, client, st).catch((e) => {
console.error(`etiquette concierge en echec : ${e.message}`); return 0; 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. // 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 // Hors essai a blanc : l'insertion est massive et idempotente, la simuler