Le concierge etiquette le branchement, plus la session

Il demandait « quel vehicule ? » a l ouverture d une session de charge. Trop
tard, et ca s est vu ce soir : a 19:55 la voiture est branchee, le selecteur
perime depuis six jours, et aucune question ne peut partir parce qu aucune
session n existe — la borne ne tire rien, il fait nuit. La premiere session de
la nuit serait nee etiquetee VW sans que personne ne soit consulte.

On etiquette desormais le BRANCHEMENT. C est d ailleurs l invariant du projet —
un cable, un vehicule — et ca rend la question possible des l instant ou on sait
qu il y a une voiture.

Deux temps. Le lecteur accepte une clef `branchement` en plus des `sessions`, et
memorise l etiquette dans une table dediee ; une passe differee, a chaque cycle,
la pose sur les sessions du branchement AU FUR ET A MESURE qu elles s ouvrent,
sans nouvelle question. Bornee par le debranchement suivant, avec les deux
secondes de tolerance du 09/09 — une session s ouvre jusqu a 62 ms avant que sa
transition de branchement soit journalisee.

L ancien contrat reste accepte : une etiquette portant des sessions fonctionne
comme avant.

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-14 21:14:38 +02:00
parent 0d597e7efc
commit d31878c66a
2 changed files with 145 additions and 15 deletions

View File

@@ -551,6 +551,28 @@ async function migrer(client) {
{ origine: 'proprietaire', motif: 'le drapeau dit desormais « un humain a confirme »',
confirmees_de_vive_voix: 6, date_arbitrage: '2026-09-13' });
}
// -------------------------------------------------------------------
// ETIQUETTE DE BRANCHEMENT (14/09/2026)
// -------------------------------------------------------------------
// Le concierge demandait « quel vehicule ? » a l ouverture d une SESSION de
// charge. Trop tard : le 14/09 a 19:55 le vehicule etait branche, le
// selecteur perime depuis six jours, et aucune question ne pouvait partir
// parce qu aucune session n existait — la borne ne tirait rien, il faisait
// nuit. La premiere session de la nuit serait donc nee mal etiquetee.
//
// On etiquette desormais le BRANCHEMENT, pas les sessions. C est d ailleurs
// l invariant du projet : un cable, un vehicule. L etiquette se pose des le
// branchement et s applique aux sessions AU FUR ET A MESURE qu elles
// s ouvrent — cette table est la memoire qui le permet.
await client.query(`
CREATE TABLE IF NOT EXISTS etiquette_branchement (
branchement timestamptz PRIMARY KEY,
label text NOT NULL,
source_airtria text NOT NULL,
confirmed_at text,
pose_a timestamptz NOT NULL DEFAULT now(),
sessions_vues integer NOT NULL DEFAULT 0
)`);
await client.query(
'CREATE INDEX IF NOT EXISTS session_charge_debut_idx ON session_charge (debut DESC)');
@@ -1039,7 +1061,11 @@ async function appliquerEtiquetteConcierge(sql, client, st) {
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;
// Nouveau contrat : Airtria peut designer le BRANCHEMENT plutot que des
// sessions, ce qui lui permet de poser sa question des le branchement, avant
// qu une seule session existe. L ancien contrat reste accepte.
const brancheDemande = txt(ordre.branchement) || null;
if (!label || (!demandees.length && !brancheDemande)) 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
@@ -1101,7 +1127,10 @@ async function appliquerEtiquetteConcierge(sql, client, st) {
const enIso = (x) => { const d = new Date(x); return Number.isNaN(+d) ? String(x) : d.toISOString(); };
const cibles = new Set(demandees.map(enIso));
let frontiereConnue = true;
for (const d of demandees) {
// Une clef de branchement se resout comme une clef de session : on remonte au
// dernier « on » qui la precede. Elle peut donc ne designer AUCUNE session —
// c est precisement le cas qu on veut couvrir.
for (const d of (brancheDemande ? [...demandees, brancheDemande] : demandees)) {
const { rows } = await lire(
`WITH bas AS (
SELECT max(ts) AS t FROM evenement
@@ -1119,7 +1148,10 @@ async function appliquerEtiquetteConcierge(sql, client, st) {
[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; }
// Aucune session dans ce branchement n est NORMAL quand l etiquette est
// posee au branchement : la voiture est branchee et n a pas encore charge.
// Ce n est une frontiere inconnue que si la clef venait d une session.
if (!rows.length) { if (d !== brancheDemande) frontiereConnue = false; continue; }
for (const r of rows) cibles.add(enIso(r.debut));
}
@@ -1158,16 +1190,82 @@ async function appliquerEtiquetteConcierge(sql, client, st) {
return 0;
}
const { rowCount } = await sql(
// On MEMORISE l etiquette au niveau du branchement. C est ce qui permet de
// repondre avant qu une session existe : les sessions qui s ouvriront ensuite
// la recevront au cycle suivant, sans nouvelle question.
const { rows: bornes } = await lire(
`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'`,
[brancheDemande || demandees[0]]);
const branchement = bornes[0]?.t || null;
if (branchement) {
await sql(
`INSERT INTO etiquette_branchement (branchement, label, source_airtria, confirmed_at)
VALUES ($1,$2,$3,$4)
ON CONFLICT (branchement) DO UPDATE
SET label = EXCLUDED.label, source_airtria = EXCLUDED.source_airtria,
confirmed_at = EXCLUDED.confirmed_at, pose_a = now()`,
[branchement, label, source, confirmedAt || null]);
}
const { rowCount } = cibles.size ? 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]);
[[...cibles], label, sur]) : { rowCount: 0 };
await journaliser(sql, 'vehicule',
`Etiquette ${label} appliquee par le concierge (${cibles.size} session${cibles.size > 1 ? 's' : ''}, ${source})`,
detail);
cibles.size
? `Etiquette ${label} appliquee par le concierge (${cibles.size} session${cibles.size > 1 ? 's' : ''}, ${source})`
: `Etiquette ${label} retenue pour le branchement en cours (aucune session encore, ${source})`,
{ ...detail, branchement });
return (rowCount || 0) + 1;
}
// ---------------------------------------------------------------------
// APPLIQUER LES ETIQUETTES MEMORISEES AUX SESSIONS QUI S OUVRENT
// ---------------------------------------------------------------------
// Second temps du concierge : l etiquette est posee AU BRANCHEMENT, les
// sessions arrivent apres. Cette passe les rattrape a chaque cycle, sans
// nouvelle question et sans intervention.
//
// ⚠️ Elle n applique qu aux sessions du branchement etiquete, bornees par le
// debranchement suivant — l invariant « un cable, un vehicule ». Les 2 secondes
// de tolerance sur la borne basse sont celles du 09/09 : une session s ouvre
// jusqu a 62 ms AVANT que sa transition de branchement soit journalisee.
async function appliquerEtiquettesEnAttente(sql, client) {
if (DRY) return 0;
const { rows } = await client.query(
`SELECT branchement, label, source_airtria FROM etiquette_branchement
WHERE branchement > now() - interval '48 hours' ORDER BY branchement`);
let n = 0;
for (const e of rows) {
const sur = e.source_airtria === 'whatsapp';
const { rowCount } = await sql(
`UPDATE session_charge s SET vehicule = $2, vehicule_sur = $3
WHERE s.debut >= $1::timestamptz - interval '2 seconds'
AND s.debut < coalesce((
SELECT min(ts) FROM evenement
WHERE genre = 'vehicule'
AND detail->>'entite' = 'binary_sensor.victron_evcs_vehicle_connected'
AND detail->>'apres' = 'off' AND ts > $1::timestamptz), 'infinity'::timestamptz)
AND (s.vehicule IS DISTINCT FROM $2 OR s.vehicule_sur IS DISTINCT FROM $3)`,
[e.branchement, e.label, sur]);
if (rowCount) {
await journaliser(sql, 'vehicule',
`Etiquette ${e.label} posee sur ${rowCount} session(s) ouverte(s) depuis le branchement`,
{ origine: 'airtria', source_airtria: e.source_airtria, label: e.label,
branchement: e.branchement, applique_a: rowCount, differe: true });
await client.query(
'UPDATE etiquette_branchement SET sessions_vues = sessions_vues + $2 WHERE branchement = $1',
[e.branchement, rowCount]);
n += rowCount;
}
}
return n;
}
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
@@ -1191,6 +1289,8 @@ async function cycle(client) {
evenement: await detecterEvenements(sql, st),
etiquette: await appliquerEtiquetteConcierge(sql, client, st).catch((e) => {
console.error(`etiquette concierge en echec : ${e.message}`); return 0;
}) + await appliquerEtiquettesEnAttente(sql, client).catch((e) => {
console.error(`etiquettes differees 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