Le lecteur d etiquette du concierge, en essai a blanc — et 62 ms qui cassaient tout

Le kill-switch gate l ecriture, pas la lecture : tant qu il est a OFF, le
lecteur resout le branchement, applique ses refus et journalise ce qu il aurait
ecrit. L essai grandeur nature d Airtria eprouve donc aussi ma moitie, pas
seulement la leur.

Et il a servi immediatement. La resolution du branchement rendait deux sessions
sur trois : une session s ouvre AVANT que sa transition de branchement soit
journalisee, les deux etant ecrites dans le meme cycle — 62 ms d ecart le
09/09 — donc la session qui OUVRE un branchement etait exclue de son propre
groupe. Tolerance de deux secondes sur la borne basse ; les trois remontent.

Le meme controle, passe sur tout l historique, signale deux branchements
anterieurs a etiquettes contradictoires. Rien n est repare : un groupe mixte
peut aussi etre un echange de vehicule que le capteur a manque, et c est au
proprietaire de trancher.

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-13 21:43:50 +02:00
parent 223e51f49c
commit 7b5cddcc60
2 changed files with 223 additions and 14 deletions

View File

@@ -6278,18 +6278,62 @@ l'expose pas encore (moto hors service, reprise prevue fin septembre) : le
concierge en saurait alors plus que l'interface. Non bloquant, et c'est au
proprietaire d'ajouter l'option le moment venu (manuel §0).
### RESTE A FAIRE — ma part, en UN SEUL commit
1. **Lecteur du helper** dans `tools/historise-evcs.mjs` : jointure sur `debut`
en egalite stricte (repli ±1 s), extension au BRANCHEMENT entier lu dans
`evenement` (invariant « un cable, un vehicule », §65), refus d'un label
household en `regle-puissance`, refus + signalement si deux labels
differents visaient le meme branchement.
2. **Bascule de `vehicule_sur`** : `DEFAULT false`, `true` UNIQUEMENT sur
confirmation humaine. Aujourd'hui la colonne ment (elle ne passe a faux que
si le selecteur change PENDANT une session, d'ou les onze lignes fausses
toutes marquees « sur »). Les deux changements sont indissociables : basculer
le defaut sans le lecteur marquerait tout « non sur », poser le lecteur sans
la bascule garderait la colonne menteuse.
### Le lecteur est ECRIT, en ESSAI A BLANC (13/09 21:40)
`appliquerEtiquetteConcierge()` dans `tools/historise-evcs.mjs`. **Le
kill-switch gate l'ECRITURE, pas la lecture** : tant qu'il est a OFF, le
lecteur lit le helper, resout le branchement, applique les deux refus et
JOURNALISE ce qu'il aurait ecrit — sans rien ecrire. L'essai grandeur nature
d'Airtria eprouve donc AUSSI ma moitie (jointure sur leurs clefs verbatim,
elargissement au groupe) avant que quoi que ce soit ne compte.
⚠️ **Et il a deja trouve un defaut, des le premier essai.** La resolution du
branchement rendait **2 sessions sur 3** pour le triplet du mercredi. Cause :
une session s'OUVRE avant que la transition de branchement soit journalisee,
les deux etant ecrites dans le meme cycle et `ecrireSession` passant avant
`detecterEvenements` — le 09/09, session a **15:55:15.423** et evenement
« on » a **15:55:15.485**, soit **62 ms** plus tard. `s.debut >= bas.t`
excluait donc la session qui OUVRE le branchement. Corrige par une tolerance de
2 secondes sur la borne basse ; les trois sessions remontent. Aucun ecart
`off` → `on` observe n'est inferieur a deux minutes, la marge ne peut pas
happer le branchement precedent.
### RESTE A FAIRE — TROIS pieces, UN SEUL commit, au passage du kill-switch a ON
1. **Bascule de `vehicule_sur`** : `DEFAULT false`, `true` UNIQUEMENT sur
confirmation humaine.
2. **Libelle Grafana.** La colonne est LUE par le tableau « Les vehicules » :
un compteur « Etiquette douteuse » et une colonne « Doute » qui imprime
litteralement `selecteur change`. Ce motif devient faux avec la nouvelle
semantique et doit devenir honnete (« non confirme »).
3. Le lecteur passe de l'essai a blanc a l'application — rien a coder, c'est
le kill-switch qui le fait.
⚠️ Les trois sont **soudees**. Basculer le defaut pendant que le kill-switch
est OFF marquerait 100 % des sessions « douteuses » — rien ne pouvant les
repasser a vrai — avec un motif qui ne correspond a rien, et le proprietaire le
verrait sur son tableau. Poser le lecteur sans la bascule garderait la colonne
menteuse.
### Le controle d'invariant trouve DEUX branchements mixtes anterieurs
En eprouvant la resolution de branchement sur tout l'historique, deux groupes
portent des etiquettes contradictoires — un seul cable, deux vehicules :
| branchement | sessions | etiquettes |
|---|---|---|
| ven 04/09 18:57 → sam 05/09 15:02 | JEEP 9,40 · VW 2,20 · VW 3,49 | **JEEP/VW** |
| lun 07/09 14:26 → mar 08/09 09:00 | VW 0,10 · JEEP 3,50 · VW 5,20 | **JEEP/VW** |
Le second contient la session de 0,10 kWh en 2 min 42 s que §58 et §59
attribuent explicitement a la **JEEP** (courriel Uconnect a 14:29), et le
proprietaire a lui-meme bascule le selecteur sur JEEP a 16:28:32 — mais la
session de nuit qui suit est etiquetee VW. Le premier contredit le recit de
§57, qui parlait d'une session de nuit JEEP suivie de la VW alors que le
journal ne montre **aucun debranchement** entre les deux.
⚠️ **Un groupe mixte ne prouve pas un mauvais etiquetage** : il peut aussi
signaler un echange de vehicule que le capteur de connexion a manque — cas
explicitement prevu par P11, qui se rattrape sur `charging_time`. L'invariant
dit « l'un des deux est faux », pas lequel. **Ne rien reparer sans l'arbitrage
du proprietaire** (regle de la memoire : demander plutot que deduire).
Declencheur : Airtria dit « ca publie pour de vrai » apres une VRAIE reponse
WhatsApp. D'ici la le helper est vide et le kill-switch OFF — rien a appliquer.

View File

@@ -905,6 +905,168 @@ async function ingererCellules(client) {
return lignes;
}
// ---------------------------------------------------------------------
// ETIQUETTE DE VEHICULE POSEE PAR LE CONCIERGE (13/09/2026)
// ---------------------------------------------------------------------
// Le selecteur `input_select.victron_evcs_vehicle` est un reglage manuel, donc
// oubliable : du 07/09 au 13/09 il est reste sur VW et onze sessions en ont
// herite, dont six etaient fausses. L'agent conversationnel d'Airtria demande
// par WhatsApp quel vehicule est branche et depose la reponse dans un helper ;
// ce lecteur l'applique. Contrat complet : HANDOFF §66.
//
// Airtria n'ecrit JAMAIS dans cette base — seulement le helper. La base reste
// la propriete de ce service.
//
// {"sessions":["2026-09-12 11:55:54.493+02", …], <- verbatim, ms comprises
// "label":"VW|JEEP|MEGANE|INVITE|ZERO",
// "confirmed_at":"2026-09-13T18:04:11+02:00",
// "source":"whatsapp"|"regle-puissance"}
//
// ⚠️ LA PROVENANCE DECIDE DE LA CERTITUDE. `whatsapp` est une confirmation
// humaine et vaut `vehicule_sur = true` ; `regle-puissance` est une DEDUCTION
// (un invite reconnu a sa puissance) et reste a false. Marquer « sur » une
// deduction reintroduirait exactement le defaut que ce chantier corrige.
//
// ⚠️ LA REGLE DE PUISSANCE EST A SENS UNIQUE : au-dessus de ~4 200 W avec une
// consigne >= 20 A elle conclut « ni VW ni JEEP », jamais l'inverse — les deux
// vehicules du foyer plafonnent a 3 680 et 3 655 W, vingt-cinq watts d'ecart.
// Un label household venu de ce chemin est donc un bug par construction : on
// REFUSE et on signale, on n'applique pas.
const HOUSEHOLD = new Set(['VW', 'JEEP']);
const LABELS = new Set(['VW', 'JEEP', 'MEGANE', 'INVITE', 'ZERO']);
let dernierBlancVu = null; // anti-repetition du journal en essai a blanc
async function appliquerEtiquetteConcierge(sql, client, st) {
const brut = txt(st.get('input_text.airtria_vehicle_label')?.state);
if (!brut || brut === 'unknown' || brut === 'unavailable') return 0;
let ordre;
try { ordre = JSON.parse(brut); } catch {
return journaliser(sql, 'vehicule', 'Etiquette concierge illisible (JSON invalide)',
{ origine: 'airtria', brut: brut.slice(0, 200) }).then(() => 1);
}
const label = txt(ordre.label).toUpperCase();
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;
// 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
// ce qui permet d'eprouver la jointure sur de vraies clefs, pendant l'essai
// grandeur nature d'Airtria, avant que quoi que ce soit ne compte.
const appliquer = txt(st.get('input_boolean.airtria_concierge_enabled')?.state) === 'on';
const lire = DRY ? async () => ({ rows: [] }) : (q, v) => client.query(q, v);
const refuser = async (motif, detail) => {
if (!appliquer && dernierBlancVu === confirmedAt + motif) return 0;
dernierBlancVu = confirmedAt + motif;
await journaliser(sql, 'vehicule',
`${appliquer ? 'Etiquette concierge REFUSEE' : '[blanc] refus'} : ${motif}`,
{ origine: 'airtria', source_airtria: source, label, confirmed_at: confirmedAt,
refus: motif, ...detail });
return 1;
};
if (!LABELS.has(label)) return refuser(`label inconnu « ${label} »`, {});
if (source === 'regle-puissance' && HOUSEHOLD.has(label)) {
return refuser(
`${label} ne peut pas venir de la regle de puissance, qui ne sait dire que « ni VW ni JEEP »`,
{ sessions: demandees });
}
// Deja pose : on ne rejoue pas. La clef est l'horodatage de confirmation,
// qu'Airtria ne reutilise jamais d'une reponse a l'autre.
if (confirmedAt) {
const { rows } = await lire(
`SELECT 1 FROM evenement WHERE genre = 'vehicule'
AND detail->>'origine' = 'airtria' AND detail->>'confirmed_at' = $1 LIMIT 1`,
[confirmedAt]);
if (rows.length) return 0;
}
// UN CABLE, UN VEHICULE. La frontiere d'un branchement est un FAIT observe —
// les transitions de `binary_sensor.victron_evcs_vehicle_connected` — et
// JAMAIS un ecart de temps : le creux entre la session du soir et celle des
// heures creuses fait plus de cinq heures et ne se distingue pas d'une nuit
// entiere. C'est ce journal qui a montre, le 13/09, qu'une correction du
// mercredi etait physiquement impossible (trois sessions, un seul cable).
// ⚠️ Les 2 secondes de tolerance sur la borne basse ne sont pas decoratives.
// Une session s'OUVRE avant que la transition de branchement soit
// journalisee, les deux etant ecrites dans le meme cycle et `ecrireSession`
// passant avant `detecterEvenements` : le 09/09, session a 15:55:15.423 et
// evenement « on » a 15:55:15.485, soit 62 ms plus tard. Sans cette marge,
// la session qui OUVRE un branchement est exclue de son propre groupe — ce
// que le premier essai a blanc a effectivement montre (2 sessions sur 3).
const cibles = new Set(demandees);
let frontiereConnue = true;
for (const d of demandees) {
const { rows } = await lire(
`WITH bas AS (
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'),
haut AS (
SELECT min(ts) AS t FROM evenement, bas
WHERE genre = 'vehicule'
AND detail->>'entite' = 'binary_sensor.victron_evcs_vehicle_connected'
AND detail->>'apres' = 'off' AND bas.t IS NOT NULL AND ts > bas.t)
SELECT s.debut, s.vehicule FROM session_charge s, bas, haut
WHERE bas.t IS NOT NULL AND s.debut >= bas.t - interval '2 seconds'
AND (haut.t IS NULL OR s.debut < haut.t) ORDER BY s.debut`,
[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; }
for (const r of rows) cibles.add(new Date(r.debut).toISOString());
}
// Deux etiquettes DIFFERENTES sur le meme cable : soit une frontiere fausse,
// soit une ligne fausse. Dans les deux cas on ne tranche pas tout seul.
const { rows: deja } = await lire(
`SELECT DISTINCT e.detail->>'label' AS label FROM evenement e
WHERE e.genre = 'vehicule' AND e.detail->>'origine' = 'airtria'
AND e.detail ? 'sessions'
AND EXISTS (SELECT 1 FROM jsonb_array_elements_text(e.detail->'sessions') x
WHERE x.value = ANY($1::text[]))`,
[[...cibles]]);
const contradiction = deja.map((r) => r.label).filter((l) => l && l !== label);
if (contradiction.length) {
return refuser(
`le branchement porte deja l etiquette ${contradiction.join('/')}, incompatible avec ${label}`,
{ sessions: [...cibles], deja: contradiction });
}
const sur = source === 'whatsapp';
const detail = {
origine: 'airtria', source_airtria: source, label, confirmed_at: confirmedAt,
sessions: [...cibles], vehicule_sur: sur,
recues: demandees.length, elargies: cibles.size - demandees.length,
...(frontiereConnue ? {} : { frontiere_inconnue: true }),
};
if (!appliquer) {
// Essai a blanc : on montre ce qu'on aurait fait, une seule fois par ordre.
if (dernierBlancVu === confirmedAt) return 0;
dernierBlancVu = confirmedAt;
console.log(` [concierge, kill-switch OFF] appliquerait ${label} `
+ `(${source}, sur=${sur}) a ${cibles.size} session(s)`
+ (detail.elargies ? `, dont ${detail.elargies} ajoutee(s) par le branchement` : '')
+ (frontiereConnue ? '' : ' — frontiere de branchement INCONNUE'));
return 0;
}
const { rowCount } = 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]);
await journaliser(sql, 'vehicule',
`Etiquette ${label} appliquee par le concierge (${cibles.size} session${cibles.size > 1 ? 's' : ''}, ${source})`,
detail);
return (rowCount || 0) + 1;
}
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
@@ -926,6 +1088,9 @@ async function cycle(client) {
prevision: await ecrirePrevision(sql, st),
session: await ecrireSession(sql, client, st),
evenement: await detecterEvenements(sql, st),
etiquette: await appliquerEtiquetteConcierge(sql, client, st).catch((e) => {
console.error(`etiquette concierge 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
// n'apprendrait rien.
@@ -937,13 +1102,13 @@ async function cycle(client) {
return 0;
}),
};
const total = r.mesures + r.bilan + r.decision + r.prevision + r.evenement + r.session + r.cellules + r.observation + (r.rapport || 0);
const total = r.mesures + r.bilan + r.decision + r.prevision + r.evenement + r.session + r.cellules + r.observation + r.etiquette + (r.rapport || 0);
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} ` +
`observation=${r.observation} cellules=${r.cellules}`
`observation=${r.observation} etiquette=${r.etiquette} cellules=${r.cellules}`
);
}
}