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