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

@@ -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}`
);
}
}