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:
68
HANDOFF.md
68
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
|
concierge en saurait alors plus que l'interface. Non bloquant, et c'est au
|
||||||
proprietaire d'ajouter l'option le moment venu (manuel §0).
|
proprietaire d'ajouter l'option le moment venu (manuel §0).
|
||||||
|
|
||||||
### RESTE A FAIRE — ma part, en UN SEUL commit
|
### Le lecteur est ECRIT, en ESSAI A BLANC (13/09 21:40)
|
||||||
1. **Lecteur du helper** dans `tools/historise-evcs.mjs` : jointure sur `debut`
|
`appliquerEtiquetteConcierge()` dans `tools/historise-evcs.mjs`. **Le
|
||||||
en egalite stricte (repli ±1 s), extension au BRANCHEMENT entier lu dans
|
kill-switch gate l'ECRITURE, pas la lecture** : tant qu'il est a OFF, le
|
||||||
`evenement` (invariant « un cable, un vehicule », §65), refus d'un label
|
lecteur lit le helper, resout le branchement, applique les deux refus et
|
||||||
household en `regle-puissance`, refus + signalement si deux labels
|
JOURNALISE ce qu'il aurait ecrit — sans rien ecrire. L'essai grandeur nature
|
||||||
differents visaient le meme branchement.
|
d'Airtria eprouve donc AUSSI ma moitie (jointure sur leurs clefs verbatim,
|
||||||
2. **Bascule de `vehicule_sur`** : `DEFAULT false`, `true` UNIQUEMENT sur
|
elargissement au groupe) avant que quoi que ce soit ne compte.
|
||||||
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
|
⚠️ **Et il a deja trouve un defaut, des le premier essai.** La resolution du
|
||||||
toutes marquees « sur »). Les deux changements sont indissociables : basculer
|
branchement rendait **2 sessions sur 3** pour le triplet du mercredi. Cause :
|
||||||
le defaut sans le lecteur marquerait tout « non sur », poser le lecteur sans
|
une session s'OUVRE avant que la transition de branchement soit journalisee,
|
||||||
la bascule garderait la colonne menteuse.
|
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
|
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.
|
WhatsApp. D'ici la le helper est vide et le kill-switch OFF — rien a appliquer.
|
||||||
|
|||||||
@@ -905,6 +905,168 @@ async function ingererCellules(client) {
|
|||||||
return lignes;
|
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) {
|
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
|
||||||
@@ -926,6 +1088,9 @@ async function cycle(client) {
|
|||||||
prevision: await ecrirePrevision(sql, st),
|
prevision: await ecrirePrevision(sql, st),
|
||||||
session: await ecrireSession(sql, client, st),
|
session: await ecrireSession(sql, client, st),
|
||||||
evenement: await detecterEvenements(sql, 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.
|
// 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
|
||||||
// n'apprendrait rien.
|
// n'apprendrait rien.
|
||||||
@@ -937,13 +1102,13 @@ async function cycle(client) {
|
|||||||
return 0;
|
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) {
|
if (total > 0 || ONCE) {
|
||||||
console.log(
|
console.log(
|
||||||
`${new Date().toLocaleString('fr-FR', { timeZone: 'Europe/Paris' })} ` +
|
`${new Date().toLocaleString('fr-FR', { timeZone: 'Europe/Paris' })} ` +
|
||||||
`${DRY ? '[BLANC] ' : ''}mesures=${r.mesures} bilan=${r.bilan} ` +
|
`${DRY ? '[BLANC] ' : ''}mesures=${r.mesures} bilan=${r.bilan} ` +
|
||||||
`decision=${r.decision} prevision=${r.prevision} session=${r.session} evenement=${r.evenement} ` +
|
`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}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user