From 7b5cddcc60a2fd7f262ddda6e571d3a305e95edd Mon Sep 17 00:00:00 2001 From: Tallagrand Thierry Date: Sun, 13 Sep 2026 21:43:50 +0200 Subject: [PATCH] =?UTF-8?q?Le=20lecteur=20d=20etiquette=20du=20concierge,?= =?UTF-8?q?=20en=20essai=20a=20blanc=20=E2=80=94=20et=2062=20ms=20qui=20ca?= =?UTF-8?q?ssaient=20tout?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01GwpFpy2aQ2rJok6tNQSbVj --- HANDOFF.md | 68 +++++++++++++--- tools/historise-evcs.mjs | 169 ++++++++++++++++++++++++++++++++++++++- 2 files changed, 223 insertions(+), 14 deletions(-) diff --git a/HANDOFF.md b/HANDOFF.md index ed06589..4ca03b8 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -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. diff --git a/tools/historise-evcs.mjs b/tools/historise-evcs.mjs index fc10008..301241e 100644 --- a/tools/historise-evcs.mjs +++ b/tools/historise-evcs.mjs @@ -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}` ); } }