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:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user