diff --git a/HANDOFF.md b/HANDOFF.md index 02c0ac2..0ba963c 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -6805,3 +6805,149 @@ Mesure de la session OpenClaw qui tranche l'autre question : le coder seul occup --- *Document mis a jour le : 16/09/2026 — nuit P12 sans un kWh achete, battement de coeur, guichet d'ordres, telemetrie BMS periMEe identifiee* + +## 70. Nuit du 16 au 17/09/2026 — Le chien de garde arretait la charge qu'il devait proteger + +### Le symptome +Charge nuit de la VW sur le surplus batterie (P12), demarree a 01:24:46 a 6 A. +Toutes les quelques minutes, la borne tombe a 0 W pendant 30 s a 3 min puis +repart. Neuf episodes entre 01:27 et 02:09. + +### Ce que le controleur EVCS a fait : RIEN, et c'est le point +La table `decision` ne contient que deux lignes sur toute la periode — +`start_battery_assist_night_min_current` a 01:24:00 puis +`charging_hold_battery_assist_night` a 01:24:31. Le controleur a tenu sa +consigne de bout en bout. Ce n'est pas un oscillateur de regle de plus. + +Le journal de bord de Home Assistant le dit sans ambiguite, et c'est le +**contexte** qui tranche : + +``` +01:34:31 switch.victron_evcs_charging -> off ctx: (vide) <- l'etat REVIENT du materiel +01:34:32 switch.victron_evcs_charging -> on ctx: switch turn_on call_service +01:34:36 sensor.victron_evcs_status_raw -> 6 (waiting_for_start) +01:35:04 switch.victron_evcs_charging -> on ctx: switch turn_on call_service +01:35:04 status_raw -> 21 puis 2 (la charge repart) +``` + +Un changement d'etat SANS contexte de service n'a pas ete commande par Home +Assistant : c'est le registre `evcharger/40/StartStop` du GX qui est passe a 0. +Le `turn_on` qui suit est le controleur qui reessaie, comme il doit. + +### La cause : le chien de garde (HANDOFF §33), et sa correlation est parfaite +``` +23:27:31 ACT W/.../evcharger/40/StartStop = 0 coupure borne 23:27:34 -> 23:28:08 +23:31:31 ACT ... coupure 23:31:34 -> 23:32:10 +23:34:31 ACT ... coupure 23:34:34 -> 23:35:12 +23:41:31 / 23:45:31 / 23:56:31 / 00:03:31 / 00:08:31 (idem, UTC) +``` +Neuf actions, neuf coupures, trois secondes apres chacune, **et aucune coupure +sans action**. Le message d'alerte porte la signature du defaut : + + ALERT CONTROLEUR MUET — derniere execution il y a NaNs. + +### ⚠️ POURQUOI NaN : n8n INSCRIT UNE EXECUTION AVANT DE LA DEMARRER +Charge reelle capturee en sondant l'API a 1 Hz : + +```json +{"id":"1269354","status":"new","startedAt":null,"stoppedAt":null, ...} +``` + +`Date.parse(null)` vaut NaN, et le test etait ecrit `!(ageSec <= 600)` — +forme choisie pour echouer du cote sur. Avec NaN elle declare donc mort **le +controleur qui vient precisement de demarrer un cycle**. Le signal de vie etait +retourne contre lui-meme. + +Mesure : **3 sondages sur 968 tombent dans cette fenetre, soit 0,3 %** et elle +dure moins d'une seconde. Le chien de garde ne sonde qu'une fois par minute : +par hasard pur il devrait y tomber une fois par mois. + +### Ce qui a change le 16/09 : une DERIVE DE PHASE, pas un changement de code +La seconde a laquelle il sonde derive d'environ **vingt secondes par jour** +(`setInterval` + le temps de chaque cycle). Historique de sa phase : + +| jour | seconde de sondage | evenements NaN | +|---|---|---:| +| 02 au 15/09 | :49 -> :00 -> :51 ... | 1 a 2 par jour | +| **16/09** | **:10 -> :31** | **34** | +| 17/09 (02:00) | :31 | en cours | + +Elle est venue se poser **exactement sur les instants de declenchement du +controleur (:00 et :30)**. C'est un battement de phase, pas une regression : le +defaut dormait depuis la mise en service du 01/09. + +Verifie aussi : les ecritures `StartStop = 0` n'apparaissent QUE les 16 et +17/09. Avant, le chien de garde tombait sur la meme fenetre mais la borne etait +a l'arret — il n'y avait rien a couper. + +### ⚠️ ET LA NUIT DU 15 AU 16 ETAIT DEJA TOUCHEE — correction a la §69 +J'ai presente cette nuit-la comme irreprochable. Elle ne l'etait pas : + +``` +01:57:13 -> 02:00:12 (3 minutes) +02:46:13 -> 02:46:55 +03:49:13 -> 03:51:32 +``` +trois coupures, chacune trois secondes apres une action du chien de garde. Ce +que j'avais verifie — « 6 A du debut a la fin, `evcs_current` n'a produit aucun +point pendant 153 min » — reste exact et ne prouvait rien : la CONSIGNE de +courant ne bouge pas quand la borne s'arrete, seule la PUISSANCE tombe. J'ai +lu l'entite qui ne pouvait pas montrer le defaut. + +### Second defaut, latent et plus grave : deux definitions du « repos » +`CFG.idleSetpointW` valait **50 W** alors que le controleur est passe a **700 W** +le 06/09 (§57). `dangerousSetpoint = setpoint > 50 + 230` etait donc **VRAI en +permanence en fonctionnement normal**, si bien que chaque fausse alerte ecrasait +aussi la consigne reseau a 50 W. + +Sans consequence cette nuit — EDF etait ouvert. Mais pendant une nuit ou le +reseau porte la maison, ramener la consigne a 50 W la fait reprendre par la +batterie : c'est exactement le defaut du 04/09 (§43), reintroduit par le chien +de garde. **Une valeur dupliquee dans deux programmes finit toujours par +diverger.** + +### Correctifs (deployes 17/09 02:13) +1. **Un horodatage manquant n'est plus une panne.** On prend le plus recent + `startedAt` EXPLOITABLE parmi les executions renvoyees ; une ligne sans + horodatage est ignoree comme reference mais ne vaut jamais « mort ». Si + aucune des trois n'est horodatee, on retombe cote sur. +2. **Valeur de repos portee a 700 W**, avec le commentaire qui dit ou est + l'autre exemplaire. Non lue dans Home Assistant a dessein : le chien de garde + doit fonctionner quand HA est mort. +3. **Une panne doit DURER pour compter** : deux lectures « mort » consecutives + avant d'agir. Meme doctrine que la tolerance temporelle de la politique + batterie (§35) et que le relachement du contacteur (§57). Le seuil de silence + vaut deja 600 s ; confirmer coute 60 s de reaction et supprime toute cette + classe de faux positif. + +### Verification — par REJEU, parce que l'absence de symptome ne prouve rien +Le redemarrage a remis la phase du chien de garde a :07, donc loin des :00/:30 : +ne plus voir de coupure cette nuit ne demontrerait rien. La preuve est un +serveur local rejouant **la charge JSON exacte capturee a 02:07:01** : + +| code | verdict | +|---|---| +| avant | `CONTROLEUR MUET — il y a NaNs` + les deux ecritures | +| apres | `controleur vivant (execution il y a 36s)`, aucune ecriture | + +Et le filet mord toujours sur une panne SOUTENUE (`--simulate-dead` en boucle) : +`1/2, on confirme au cycle suivant` puis alerte et `StartStop = 0` au second +cycle. Le correctif ne l'a pas desarme. + +### Cout de l'episode +Neuf coupures de 30 s a 3 min a ~1,6 kW : de l'ordre de **0,2 kWh** de charge +perdue, negligeable. Ce qui ne l'est pas : neuf ouvertures-fermetures du relais +de la borne et du contacteur du vehicule en quarante minutes, et neuf +renegociations du signal pilote — precisement le geste dont §58 et §59 ont +montre qu'il finit par produire des `charged` prematures. + +### Lecon a retenir +Le chien de garde a ete concu pour que « une seule action par panne » empeche un +oscillateur (§33). Le verrou a fonctionne : une action, puis silence. Mais il se +rearme des que le controleur est revu vivant — **une seconde plus tard ici**. +Un verrou contre la repetition ne protege de rien si le signal qui le rearme +oscille lui aussi. **Ce n'est pas le nombre d'actions qu'il faut borner, c'est +la confiance dans la detection.** + +--- +*Document mis a jour le : 17/09/2026 (~02:20) — chien de garde : `startedAt` absent lu comme une panne, valeur de repos divergente, confirmation sur deux cycles* diff --git a/tools/watchdog-evcs.mjs b/tools/watchdog-evcs.mjs index 802c5e6..732be06 100644 --- a/tools/watchdog-evcs.mjs +++ b/tools/watchdog-evcs.mjs @@ -81,8 +81,18 @@ const CFG = { livenessMaxAgeSec: Number(process.env.WATCHDOG_MAX_AGE_SEC || 600), periodSec: Number(process.env.WATCHDOG_PERIOD_SEC || 60), - // Valeur de repos de la consigne réseau : le défaut Victron, pas un choix. - idleSetpointW: Number(process.env.WATCHDOG_IDLE_SETPOINT_W || 50), + // Valeur de repos de la consigne reseau. ⚠️ ELLE DOIT ETRE CELLE DU + // CONTROLEUR, sans quoi le chien de garde juge « laissee haute » une consigne + // parfaitement normale. C'etait le cas depuis le 06/09/2026 : le controleur + // est passe de 200 a 700 W (`gridSetpointIdleW` dans INPUT_PARSE, helper + // optionnel `input_number.evcs_grid_setpoint_idle_w`) et cette valeur-ci est + // restee a 50, si bien que `dangerousSetpoint` etait VRAI en permanence — et + // que chaque fausse alerte ecrasait la consigne a 50 W. Sur une nuit ou le + // reseau porte la maison, cela lui fait reprendre la batterie (cf. §43). + // Elle n'est pas lue dans Home Assistant a dessein : le chien de garde doit + // fonctionner quand HA est mort. A tenir a jour a la main, ici et dans + // deploy/watchdog/watchdog.env. + idleSetpointW: Number(process.env.WATCHDOG_IDLE_SETPOINT_W || 700), // Au-delà de quoi la consigne est jugée « laissée haute ». Un pas de courant // EVCS à 230 V, la granularité à laquelle le contrôleur déplace la puissance. setpointSlackW: Number(process.env.WATCHDOG_SETPOINT_SLACK_W || 230), @@ -149,7 +159,32 @@ async function controllerLiveness() { const runs = (await r.json()).data || []; if (!runs.length) return { alive: false, ageSec: null, detail: 'aucune exécution connue' }; - const ageSec = (Date.now() - Date.parse(runs[0].startedAt)) / 1000; + // ⚠️ UNE EXECUTION PAS ENCORE DEMARREE N'EST PAS UNE PANNE, C'EST LE + // CONTRAIRE. n8n insere la ligne d'execution AVANT de la lancer : pendant + // quelques centaines de millisecondes elle porte `status: "new"` et + // `startedAt: null`. `Date.parse(null)` vaut NaN, et la forme `!(NaN <= X)` + // — choisie pour echouer du cote sur — declarait alors le controleur mort + // alors qu'il venait precisement de demarrer un cycle. + // + // Mesure du 17/09/2026 : 3 sondages sur 968 tombent dans cette fenetre, + // soit 0,3 %. Mais le chien de garde ne sonde qu'une fois par minute et sa + // phase DERIVE d'environ vingt secondes par jour ; quand elle vient se + // poser sur les instants de declenchement du controleur (:00 et :30), il + // tombe dedans plusieurs fois par heure. Nuit du 16 au 17/09 : neuf arrets + // de la charge de la VW, chacun trois secondes apres une action du chien + // de garde, correlation un pour un. + // + // On prend donc le plus recent horodatage EXPLOITABLE parmi les executions + // renvoyees : une ligne sans `startedAt` est ignoree comme reference, mais + // elle ne vaut jamais « mort ». Si AUCUNE des trois n'est horodatee, on + // retombe cote sur. + const horodatees = runs + .map((e) => Date.parse(e.startedAt)) + .filter((t) => Number.isFinite(t)); + if (!horodatees.length) { + return { alive: false, ageSec: null, detail: 'aucune exécution horodatée' }; + } + const ageSec = (Date.now() - Math.max(...horodatees)) / 1000; if (!(ageSec <= CFG.livenessMaxAgeSec)) { return { alive: false, ageSec, detail: `dernière exécution il y a ${Math.round(ageSec)}s` }; } @@ -224,6 +259,16 @@ function gxWrite(p, value) { let armed = true; // faux = action déjà faite, on attend un signe de vie let lastAliveLog = 0; +// ⚠️ UNE PANNE DOIT DURER POUR COMPTER. Meme doctrine que la tolerance +// temporelle de la politique batterie (HANDOFF §35) et que le relachement du +// contacteur (§57) : un franchissement de seuil vu sur UN echantillon est un +// tirage, pas un fait. Le seuil de silence vaut deja 600 s ; exiger deux +// lectures « mort » consecutives ne retarde la reaction que d'une periode +// (60 s) et supprime toute cette classe de faux positif, y compris un hoquet +// reseau isole entre les deux NAS. +const CONFIRMATIONS_REQUISES = 2; +let mortsConsecutives = 0; + async function cycle() { gxKeepalive(); const live = await controllerLiveness(); @@ -231,6 +276,7 @@ async function cycle() { if (live.alive) { if (!armed) log('INFO', `contrôleur revenu (${live.detail}) — chien de garde réarmé`); armed = true; + mortsConsecutives = 0; // Trace discrète : une ligne par heure suffit à prouver qu'il veille. if (Date.now() - lastAliveLog > 3600e3) { lastAliveLog = Date.now(); @@ -239,6 +285,13 @@ async function cycle() { return; } + mortsConsecutives += 1; + if (mortsConsecutives < CONFIRMATIONS_REQUISES) { + log('WARN', `contrôleur peut-être muet (${live.detail}) — ` + + `${mortsConsecutives}/${CONFIRMATIONS_REQUISES}, on confirme au cycle suivant avant d'agir`); + return; + } + const setpointW = gxRead(T.setpoint); const evPowerW = gxRead(T.evPower); const evOn = gxRead(T.evStartStop);