diff --git a/HANDOFF.md b/HANDOFF.md index 2c3b8b2..58a7c1d 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -602,5 +602,21 @@ deja ailleurs (controleur EVCS), l'agent la RELAIE, il ne la recalcule jamais. Limite connue : si le service d'historisation meurt, les quatre capteurs se figent ; l'agent verifie `sensor.evcs_historisation_heartbeat`. +### Incident du matin 25/09 — le capteur de charge disait « solaire » la nuit +Le proprio : « l'agent a delire ce matin sur la charge voiture ». Cause = MON +capteur, pas l'agent. `chargeSolaire` classait toute source qui ne commence pas +par « Reseau » comme solaire : de **01:25 a 06:00**, `sensor.evcs_charge_solaire` +a publie `en_charge_solaire` alors que la JEEP chargeait sur la BATTERIE +(« Assistance batterie nuit », puis reseau force de 06:02 a 06:42 ; session +7,9 kWh dont 2,2 reseau). L'agent a relaye fidelement une fausse verite. +Correctif : correspondance EXPLICITE libelle par libelle de `sourceModeLabel` +(HA_SERVICE_CALLS_OUTPUT.js), nouveaux etats `en_charge_batterie`, +`en_charge_sonde`, et `en_charge` sans origine pour un libelle inconnu. Test +garde-fou qui lit la table DANS le code du controleur : un libelle ajoute la-bas +fait tomber les tests (mutation verifiee). Tests 32/32. +⚠️ Lecon : un capteur-verdict deplace la fragilite de la fiche vers le code ; le +code doit donc etre teste sur TOUT le domaine d'entree (ici les sept libelles), +pas seulement sur les cas du banc. + --- -*Document mis a jour le : 25/09/2026 — agent vocal : la partie energie relaie des capteurs-verdict (banc 15/15), cadrage multi-agents* +*Document mis a jour le : 25/09/2026 — capteur de charge corrige (origine explicite par libelle), incident du matin* diff --git a/tests/diagnostics-agent/run.mjs b/tests/diagnostics-agent/run.mjs index d104170..4c75912 100644 --- a/tests/diagnostics-agent/run.mjs +++ b/tests/diagnostics-agent/run.mjs @@ -1,7 +1,8 @@ // Tests des diagnostics publies pour l'agent vocal (tools/diagnostics-agent.mjs). // Les cas PV reprennent les gammes du banc OpenClaw (HANDOFF §77) : G3, G4, G5, G7. // node tests/diagnostics-agent/run.mjs -import { diagnosticPv, etatCellules, productionJour, chargeSolaire, MPPT_MODES } from '../../tools/diagnostics-agent.mjs'; +import fs from 'node:fs'; +import { diagnosticPv, etatCellules, productionJour, chargeSolaire, MPPT_MODES, ORIGINE_PAR_SOURCE } from '../../tools/diagnostics-agent.mjs'; const LIM = 'voltage_current_limited', ACT = 'mppt_active'; function st({ pv, conso, reseau, soc = 80, courant = 0, edf = 'off', modes }) { @@ -114,6 +115,14 @@ const charge = [ (r) => r.etat === 'en_attente' && r.attributs.raison_controleur === 'Excedent insuffisant' && r.attributs.excedent_w === -1854], ['en charge sur source solaire -> en_charge_solaire', borne({ p: 3600, source: 'Solaire' }), (r) => r.etat === 'en_charge_solaire'], ['en charge source reseau -> en_charge_reseau', borne({ p: 3600, source: 'Réseau HC autorisé' }), (r) => r.etat === 'en_charge_reseau'], + // Incident du 25/09 01:25-06:00 : JEEP sur batterie la nuit, publie « solaire ». + ['INCIDENT 25/09 : assistance batterie nuit -> en_charge_batterie, JAMAIS solaire', + borne({ p: 1198, source: 'Assistance batterie nuit', pv: 0, conso: 900, modes: 'off' }), + (r) => r.etat === 'en_charge_batterie' && !/solaire/i.test(r.attributs.explication.split('(')[0])], + ['sonde hybride -> en_charge_sonde', borne({ p: 2300, source: 'Probe hybride' }), (r) => r.etat === 'en_charge_sonde'], + ['potentiel solaire -> en_charge_solaire', borne({ p: 2300, source: 'Potentiel solaire' }), (r) => r.etat === 'en_charge_solaire'], + ['reseau force -> en_charge_reseau', borne({ p: 3600, source: 'Réseau forcé' }), (r) => r.etat === 'en_charge_reseau'], + ['libelle inconnu -> en_charge sans origine devinee', borne({ p: 3600, source: 'nouveau_mode' }), (r) => r.etat === 'en_charge'], ['non branchee -> non_branchee', borne({ branche: 'off' }), (r) => r.etat === 'non_branchee'], ['G7 excedent positif et bride, mais controleur bloque -> en_attente (on RELAIE, on ne rejuge pas)', borne({ pv: 5000, conso: 1500, modes: LIM, source: 'Bloqué', raison: 'Vehicule plein' }), @@ -127,6 +136,16 @@ for (const [nom, etats, test] of charge) { console.log(`${ok ? 'OK ' : 'KO '} ${nom}`); } -const n = cas.length + cel.length + prod.length + charge.length; +// Chaque libelle publie par le controleur doit avoir une origine explicite : +// lu dans SON code, pour qu'un libelle ajoute la-bas fasse tomber ce test. +const srcCtrl = fs.readFileSync(new URL('../../src/nodes/HA_SERVICE_CALLS_OUTPUT.js', import.meta.url), 'utf8'); +const bloc = srcCtrl.slice(srcCtrl.indexOf('const sourceModeLabel ='), srcCtrl.indexOf('context.sourceMode;', srcCtrl.indexOf('const sourceModeLabel ='))); +const libelles = [...bloc.matchAll(/\? '([^']+)'/g)].map((m) => m[1]).filter((l) => l !== 'Bloqué'); +const nonCouverts = libelles.filter((l) => !ORIGINE_PAR_SOURCE[l]); +const okLib = libelles.length >= 6 && nonCouverts.length === 0; +if (!okLib) ko++; +console.log(`${okLib ? 'OK ' : 'KO '} les ${libelles.length} libelles de source du controleur ont une origine${okLib ? '' : ` (manquent : ${nonCouverts.join(', ') || 'table introuvable'})`}`); + +const n = cas.length + cel.length + prod.length + charge.length + 1; console.log(`\n${n - ko}/${n}`); process.exit(ko ? 1 : 0); diff --git a/tools/diagnostics-agent.mjs b/tools/diagnostics-agent.mjs index 323e793..c4bcbc7 100644 --- a/tools/diagnostics-agent.mjs +++ b/tools/diagnostics-agent.mjs @@ -208,6 +208,29 @@ export function productionJour(st, maintenant = new Date()) { // l'expliquer (excedent, chargeurs brides), sans decider. export const SEUIL_CHARGE_W = 300; // meme seuil que les sessions de charge +// ⚠️ Correspondance EXPLICITE, libelle par libelle, de la table +// `sourceModeLabel` de src/nodes/HA_SERVICE_CALLS_OUTPUT.js. Defaut du +// 25/09 : la premiere version classait « tout ce qui n'est pas Reseau » comme +// solaire, et a publie `en_charge_solaire` de 01:25 a 06:00 pendant que la JEEP +// chargeait sur la BATTERIE en pleine nuit (« Assistance batterie nuit »). L'agent +// l'a repete fidelement. Un libelle inconnu donne `en_charge` sans origine : +// on ne devine jamais d'ou vient l'energie. +export const ORIGINE_PAR_SOURCE = { + 'Solaire': 'en_charge_solaire', + 'Potentiel solaire': 'en_charge_solaire', + 'Probe hybride': 'en_charge_sonde', + 'Assistance batterie nuit': 'en_charge_batterie', + 'Réseau HC autorisé': 'en_charge_reseau', + 'Réseau forcé': 'en_charge_reseau', +}; +const PHRASE_ORIGINE = { + en_charge_solaire: 'sur le solaire', + en_charge_sonde: 'en essai court pour provoquer la production solaire', + en_charge_batterie: 'sur la batterie de la maison (assistance de nuit)', + en_charge_reseau: 'sur le reseau EDF', + en_charge: 'depuis une source que le controleur ne precise pas', +}; + export function chargeSolaire(st) { const txt = (id) => { const s = st.get(id)?.state; return s === undefined || s === 'unavailable' || s === 'unknown' ? null : String(s); }; const branche = txt('binary_sensor.victron_evcs_vehicle_connected'); @@ -235,9 +258,10 @@ export function chargeSolaire(st) { return r('non_branchee', 'Aucun vehicule branche. Une fois branche, le controleur decidera lui-meme.'); } if (puissance > SEUIL_CHARGE_W) { - const reseau = /^R[ée]seau/.test(source); - return r(reseau ? 'en_charge_reseau' : 'en_charge_solaire', - `La voiture charge (${Math.round(puissance)} W), source decidee par le controleur : ${source}.`); + const etat = ORIGINE_PAR_SOURCE[source] || 'en_charge'; + return r(etat, + `La voiture charge (${Math.round(puissance)} W) ${PHRASE_ORIGINE[etat]} ` + + `(source decidee par le controleur : ${source}).`); } return r('en_attente', `Voiture branchee mais pas en charge. Le controleur dit : ${raison || 'sans raison publiee'} (source : ${source}).`);