From 8f526bdcd6d50ae94f8324a7beb2d21667b4acba Mon Sep 17 00:00:00 2001 From: Tallagrand Thierry Date: Mon, 5 Oct 2026 14:01:43 +0200 Subject: [PATCH] Etiquettes : la derniere reponse humaine fait foi (correction de permutation), rejeu du 04/10 --- HANDOFF.md | 26 +++++++++++++- tests/kilometrage/rejeu-correction.mjs | 47 ++++++++++++++++++++++++++ tools/historise-evcs.mjs | 47 +++++++++++++++++++++----- 3 files changed, 110 insertions(+), 10 deletions(-) create mode 100644 tests/kilometrage/rejeu-correction.mjs diff --git a/HANDOFF.md b/HANDOFF.md index 82dad80..79ad46a 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -1738,5 +1738,29 @@ charge en ce moment ». Laisse volontairement : jours d'apres-midi VW/ZERO « un tous les jours pour le matin, ne pas risquer un depart quotidien). Sauvegardes `evcs_master.yaml.bak-20261004-{onglets,retouches,edf-session}`. +### 05/10 — Premiere identification JEEP reussie, et la correction humaine qui etait refusee +- **Identification Uconnect en reel (05/10)** : JEEP branchee 13:29:38, la voiture publie 13:31:34 + (branchee, 25 %), HA relit 13:44:09, mon service identifie a 13:44:10 (etiquette uconnect, + niveau 25 %), le concierge envoie la ligne d'info a 13:50:01 sans question (SPARK, journal du + cron). Delai ~20 min (cloud 15 min + cron */10). ⚠️ Le 04/10 a 20:56 elle n'avait PAS joue : + la Jeep ne publie que si sa charge DEMARRE ; le soir en « sans EDF » la question du concierge + reste normale (elle a fonctionne, reponse a 21:35). Les releves km se font aussi pendant les + trajets (112 740 / 743 / 749 / 763 km le 05/10). +- **Defaut trouve sur la capture du proprietaire** : 04/10 16:55, JEEP notee a 16:59 ; 17:16 « Non + maintenant c'est la zero » (permutation rapide, aucune transition vue par la borne) ; le concierge + repond « ✅ Noté : ZERO » mais mon service REFUSE (« le branchement porte deja l'etiquette JEEP, + incompatible avec ZERO ») -> base restee JEEP, confirmation FAUSSE. J'avais affirme a SPARK que la + correction humaine primait toujours : faux dans ce cas, rectifie aupres de lui. +- **Correctif (a), accord du proprietaire** (`appliquerEtiquetteConcierge`) : contradiction + source + HUMAINE -> la derniere reponse fait foi : sessions terminees avant la correction inchangees, + session en cours et suivantes au nouveau label ; `etiquette_branchement.applique_depuis` (nouvelle + colonne) borne aussi `appliquerEtiquettesEnAttente` ; evenement « Etiquette corrigee par le + proprietaire : X -> Y a partir de HH:MM ». Contradiction non humaine (regle de puissance) toujours + refusee. Rejeu du cas reel en transaction annulee `tests/kilometrage/rejeu-correction.mjs` 6/6 + (2/6 sur l'ancien code). Le « Noté » du concierge redevient vrai sans changement chez SPARK. +- **Session du 04/10 16:56-17:39 (2,4 kWh) coupee au prorata du temps a 17:16** (proprietaire) : + JEEP 1,07 kWh / ZERO 1,33 kWh, trace dans `brut` et evenement ; etiquette du branchement ZERO + depuis 17:16. + --- -*Document mis a jour le : 04/10/2026 (~16:30) — EVCS Master en quatre onglets ; Jeep lue par Uconnect et identifiee seule, capacite JEEP 10 kWh, concierge variante (a) ; releve par photo (SPARK), hass-uconnect en attente ; ecart semaine/week-end mesure (pas de profil week-end la nuit) ; nuit du budget : budget borne au plancher prudent, sans EDF = voiture bloquee meme EDF ferme (batterie seule) ; Energie : sauts corriges ; tableau Energie complete (Sud/Ouest, batterie par integration, borne ; aucun redemarrage HA) ; kilometrage des vehicules (km/kWh, capacite, releves de reference, agent cable 15/15) ; a verifier le 04/10 au matin : nuit du budget JEEP ; concierge : vehicule non confirme demande, heure de saisie ; Grafana vehicules en tableau ; Grafana vehicules : ZERO et Invite ajoutes, session 16:46 reetiquetee ZERO ; prevision SOC 10 h ~35 % ; concierge fiable : niveau note par le script, une ligne, dedoublonnage, simulation par drapeau ; agent : message incompris = question, bourse sur demande explicite (banc final 6/6) ; a verifier : nuit du samedi 03 au dimanche 04/10 (budget < besoin JEEP), cron orange-cloture lundi 05/10 apres 17:40, comparaison VRM / Forecast.Solar lundi 05/10* +*Document mis a jour le : 05/10/2026 (~14:30) — identification JEEP reussie en reel, correction humaine qui prime (refus supprime), session du 04/10 coupee ; EVCS Master en quatre onglets ; Jeep lue par Uconnect et identifiee seule, capacite JEEP 10 kWh, concierge variante (a) ; releve par photo (SPARK), hass-uconnect en attente ; ecart semaine/week-end mesure (pas de profil week-end la nuit) ; nuit du budget : budget borne au plancher prudent, sans EDF = voiture bloquee meme EDF ferme (batterie seule) ; Energie : sauts corriges ; tableau Energie complete (Sud/Ouest, batterie par integration, borne ; aucun redemarrage HA) ; kilometrage des vehicules (km/kWh, capacite, releves de reference, agent cable 15/15) ; a verifier le 04/10 au matin : nuit du budget JEEP ; concierge : vehicule non confirme demande, heure de saisie ; Grafana vehicules en tableau ; Grafana vehicules : ZERO et Invite ajoutes, session 16:46 reetiquetee ZERO ; prevision SOC 10 h ~35 % ; concierge fiable : niveau note par le script, une ligne, dedoublonnage, simulation par drapeau ; agent : message incompris = question, bourse sur demande explicite (banc final 6/6) ; a verifier : nuit du samedi 03 au dimanche 04/10 (budget < besoin JEEP), cron orange-cloture lundi 05/10 apres 17:40, comparaison VRM / Forecast.Solar lundi 05/10* diff --git a/tests/kilometrage/rejeu-correction.mjs b/tests/kilometrage/rejeu-correction.mjs new file mode 100644 index 0000000..7fd9e8d --- /dev/null +++ b/tests/kilometrage/rejeu-correction.mjs @@ -0,0 +1,47 @@ +// Rejoue la correction humaine du 04/10 (JEEP -> ZERO a 17:16) sur la vraie +// base, dans une transaction ANNULEE. A lancer dans le conteneur d'historisation. +import fs from 'node:fs'; +import pg from 'pg'; +const src = fs.readFileSync(new URL('../../tools/historise-evcs.mjs', import.meta.url), 'utf8'); +const debut = src.indexOf('async function appliquerEtiquetteConcierge(sql, client, st) {'); +let i = src.indexOf('{', debut), prof = 0, fin = i; +for (; fin < src.length; fin++) { if (src[fin] === '{') prof++; else if (src[fin] === '}' && --prof === 0) break; } +const corps = src.slice(debut, fin + 1); +const c = new pg.Client(); await c.connect(); +let ok = 0, ko = 0; const eq = (n, a, b) => { if (JSON.stringify(a) === JSON.stringify(b)) ok++; else { ko++; console.log('ECHEC', n, JSON.stringify(a), '!=', JSON.stringify(b)); } }; +try { + await c.query('BEGIN'); + await c.query('ALTER TABLE etiquette_branchement ADD COLUMN IF NOT EXISTS applique_depuis timestamptz'); + const journal = []; + const deps = { + txt: (x) => (x ?? '').toString().trim(), LABELS: new Set(['VW', 'JEEP', 'MEGANE', 'INVITE', 'ZERO']), + HOUSEHOLD: new Set(['VW', 'JEEP']), CANAUX_HUMAINS: new Set(['whatsapp', 'proprietaire']), DRY: false, + journaliser: async (sql, g, r, d) => { journal.push(r); await sql(`INSERT INTO evenement (genre, source, resume, detail) VALUES ($1,'test',$2,$3)`, [g, r, JSON.stringify(d || {})]); }, + piloterSelecteurVehicule: async () => 0, + }; + const fn = new Function(...Object.keys(deps), 'let dernierBlancVu = null; return (' + corps.replace('async function appliquerEtiquetteConcierge', 'async function') + ');')(...Object.values(deps)); + const sql = (q, p) => c.query(q, p); + const st = (label, conf) => new Map([ + ['input_text.airtria_vehicle_label', { state: JSON.stringify({ branchement: '2026-10-04 16:55:45.120519+02', label, confirmed_at: conf, source: 'whatsapp' }) }], + ['input_boolean.airtria_concierge_enabled', { state: 'on' }]]); + // Une session terminee AVANT la correction, pour verifier qu'elle garde JEEP. + await c.query(`INSERT INTO session_charge (debut, fin, terminee, vehicule, vehicule_sur, energie_kwh, energie_station_kwh) + VALUES ('2026-10-04T14:56:00Z','2026-10-04T14:56:30Z',true,'JEEP',true,0.05,0.1)`); + await c.query(`INSERT INTO evenement (genre, source, resume, detail) VALUES ('vehicule','test','avant',$1)`, + [JSON.stringify({ origine: 'airtria', label: 'JEEP', sessions: ['2026-10-04T14:56:00.000Z', '2026-10-04T14:56:55.447Z'] })]); + await fn(sql, c, st('ZERO', '2026-10-04T15:16:50.000Z')); + const s = (await c.query(`SELECT debut, vehicule, vehicule_sur FROM session_charge WHERE debut BETWEEN '2026-10-04T14:50Z' AND '2026-10-04T16:00Z' ORDER BY debut`)).rows; + eq('session terminee avant la correction garde JEEP', s[0].vehicule, 'JEEP'); + eq('session en cours a 17:16 passe ZERO, confirmee', [s[1].vehicule, s[1].vehicule_sur], ['ZERO', true]); + const e = (await c.query(`SELECT label, applique_depuis FROM etiquette_branchement WHERE branchement = '2026-10-04T14:55:45.120Z'`)).rows[0]; + eq('etiquette du branchement = ZERO depuis 17:16:50', [e.label, e.applique_depuis?.toISOString()], ['ZERO', '2026-10-04T15:16:50.000Z']); + eq('journal de correction', journal.some(r => r.startsWith('Etiquette corrigee par le proprietaire : JEEP -> ZERO')), true); + eq('pas de refus', journal.some(r => r.includes('REFUSEE')), false); + // Une contradiction NON humaine reste refusee. + journal.length = 0; + const st2 = new Map([['input_text.airtria_vehicle_label', { state: JSON.stringify({ branchement: '2026-10-04 16:55:45.120519+02', label: 'MEGANE', confirmed_at: '2026-10-04T15:30:00.000Z', source: 'regle-puissance' }) }], + ['input_boolean.airtria_concierge_enabled', { state: 'on' }]]); + await fn(sql, c, st2); + eq('contradiction par la regle de puissance : refusee', journal.some(r => r.includes('REFUSEE')), true); +} finally { await c.query('ROLLBACK'); await c.end(); } +console.log(`${ok}/${ok + ko}`); process.exit(ko ? 1 : 0); diff --git a/tools/historise-evcs.mjs b/tools/historise-evcs.mjs index c4180f0..dc9441a 100644 --- a/tools/historise-evcs.mjs +++ b/tools/historise-evcs.mjs @@ -599,6 +599,9 @@ async function migrer(client) { pose_a timestamptz NOT NULL DEFAULT now(), sessions_vues integer NOT NULL DEFAULT 0 )`); + // Correction humaine (05/10) : l'etiquette ne vaut que pour les sessions non + // terminees a cet instant (NULL = tout le branchement). + await client.query('ALTER TABLE etiquette_branchement ADD COLUMN IF NOT EXISTS applique_depuis timestamptz'); await client.query( 'CREATE INDEX IF NOT EXISTS session_charge_debut_idx ON session_charge (debut DESC)'); @@ -1323,10 +1326,34 @@ async function appliquerEtiquetteConcierge(sql, client, st) { WHERE x.value = ANY($1::text[]))`, [[...cibles]]); const contradiction = deja.map((r) => r.label).filter((l) => l && l !== label); + // CORRECTION HUMAINE (05/10/2026, decision du proprietaire) : sa DERNIERE + // reponse fait foi. Cas reel du 04/10 : JEEP notee a 16:59, puis « Non + // maintenant c'est la zero » a 17:16 — permutation rapide que la borne n'a + // pas vue. L'ancien refus laissait JEEP en base pendant que le concierge + // repondait « Noté : ZERO ». Desormais : les sessions TERMINEES avant la + // correction gardent leur etiquette (c'etait le bon vehicule a ce moment-la), + // la session en cours et les suivantes prennent la nouvelle. Une + // contradiction NON humaine (regle de puissance) reste refusee. + let correctionDepuis = null; if (contradiction.length) { - return refuser( - `le branchement porte deja l etiquette ${contradiction.join('/')}, incompatible avec ${label}`, - { sessions: [...cibles], deja: contradiction }); + if (!CANAUX_HUMAINS.has(source)) { + return refuser( + `le branchement porte deja l etiquette ${contradiction.join('/')}, incompatible avec ${label}`, + { sessions: [...cibles], deja: contradiction }); + } + correctionDepuis = Number.isFinite(Date.parse(confirmedAt)) ? new Date(confirmedAt) : new Date(); + const { rows: encore } = await lire( + `SELECT debut FROM session_charge WHERE debut = ANY($1::timestamptz[]) + AND (fin IS NULL OR fin > $2 OR NOT terminee)`, [[...cibles], correctionDepuis]); + const gardees = cibles.size - encore.length; + cibles.clear(); + for (const r of encore) cibles.add(enIso(r.debut)); + await journaliser(sql, 'vehicule', + `Etiquette corrigee par le proprietaire : ${contradiction.join('/')} -> ${label} a partir de ` + + `${correctionDepuis.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit', timeZone: 'Europe/Paris' })}` + + (gardees ? ` (${gardees} session${gardees > 1 ? 's' : ''} terminee${gardees > 1 ? 's' : ''} avant : inchangee${gardees > 1 ? 's' : ''})` : ''), + { origine: 'correction', source_airtria: source, label, avant: contradiction, + confirmed_at: confirmedAt, depuis: correctionDepuis.toISOString(), gardees }); } const sur = CANAUX_HUMAINS.has(source); @@ -1361,12 +1388,13 @@ async function appliquerEtiquetteConcierge(sql, client, st) { 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) + `INSERT INTO etiquette_branchement (branchement, label, source_airtria, confirmed_at, applique_depuis) + VALUES ($1,$2,$3,$4,$5) 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]); + confirmed_at = EXCLUDED.confirmed_at, pose_a = now(), + applique_depuis = EXCLUDED.applique_depuis`, + [branchement, label, source, confirmedAt || null, correctionDepuis]); } // Le controleur lit le selecteur : la reponse doit y arriver, pas seulement @@ -1439,7 +1467,7 @@ async function etiqueterJeepUconnect(sql, client, st, branche, niveau) { async function appliquerEtiquettesEnAttente(sql, client) { if (DRY) return 0; const { rows } = await client.query( - `SELECT branchement, label, source_airtria FROM etiquette_branchement + `SELECT branchement, label, source_airtria, applique_depuis FROM etiquette_branchement WHERE branchement > now() - interval '48 hours' ORDER BY branchement`); let n = 0; for (const e of rows) { @@ -1452,8 +1480,9 @@ async function appliquerEtiquettesEnAttente(sql, client) { WHERE genre = 'vehicule' AND detail->>'entite' = 'binary_sensor.victron_evcs_vehicle_connected' AND detail->>'apres' = 'off' AND ts > $1::timestamptz), 'infinity'::timestamptz) + AND ($4::timestamptz IS NULL OR s.fin IS NULL OR s.fin > $4::timestamptz OR NOT s.terminee) AND (s.vehicule IS DISTINCT FROM $2 OR s.vehicule_sur IS DISTINCT FROM $3)`, - [e.branchement, e.label, sur]); + [e.branchement, e.label, sur, e.applique_depuis]); if (rowCount) { await journaliser(sql, 'vehicule', `Etiquette ${e.label} posee sur ${rowCount} session(s) ouverte(s) depuis le branchement`,