From a6baad54dc2468bf26896974dce3bc2aa6186ccd Mon Sep 17 00:00:00 2001 From: Tallagrand Thierry Date: Thu, 17 Sep 2026 02:14:52 +0200 Subject: [PATCH] Le chien de garde arretait la charge qu il devait proteger MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nuit du 16 au 17/09 : neuf arrets de la charge nuit de la VW, de 30 s a 3 min, sans que le controleur EVCS ait bouge — sa table de decision ne porte que deux lignes sur toute la periode. Le journal de bord de Home Assistant montre le switch de charge passant a off SANS contexte de service, donc commande depuis le GX, puis le controleur qui reessaie au cycle suivant. Correlation un pour un avec les actions du chien de garde, trois secondes avant chacune, et aucune coupure sans action. Son message porte la signature : « CONTROLEUR MUET — derniere execution il y a NaNs ». Cause : n8n inscrit une execution AVANT de la demarrer, avec status "new" et startedAt null. Date.parse(null) vaut NaN, et le test !(ageSec <= 600) — forme choisie pour echouer du cote sur — declare alors mort le controleur qui vient precisement de demarrer un cycle. Le signal de vie retourne contre lui-meme. La fenetre dure moins d une seconde : 3 sondages sur 968 y tombent, 0,3 %. Ce qui a change le 16/09 n est pas le code mais la PHASE du chien de garde, qui derive d environ vingt secondes par jour et est venue se poser exactement sur les instants de declenchement du controleur (:00 et :30) — 34 evenements ce jour-la contre 1 a 2 les jours precedents. Le defaut dormait depuis le 01/09. Correction a la session precedente : la nuit du 15 au 16, presentee comme irreprochable, portait deja trois coupures dont une de trois minutes. Ce que j avais verifie — consigne a 6 A inchangee pendant 153 min — restait exact et ne prouvait rien : la consigne ne bouge pas quand la borne s arrete, seule la puissance tombe. Second defaut, latent et plus grave : CFG.idleSetpointW valait 50 W quand le controleur est passe a 700 W le 06/09, donc dangerousSetpoint etait vrai en permanence et 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, c est le defaut du 04/09 reintroduit. Trois correctifs : un horodatage manquant n est plus une panne (on prend le plus recent startedAt exploitable, repli cote sur si aucun) ; valeur de repos portee a 700 W avec le commentaire qui dit ou vit l autre exemplaire ; et une panne doit DURER — deux lectures consecutives avant d agir, meme doctrine que la tolerance batterie et le relachement du contacteur. Verification par REJEU et non par absence de symptome : le redemarrage a remis la phase a :07, donc une nuit calme ne prouverait rien. Un serveur local rejouant la charge JSON exacte capturee a 02:07:01 donne, code d avant, « MUET — il y a NaNs » et les deux ecritures ; code corrige, « controleur vivant (execution il y a 36s) » et aucune ecriture. Le filet mord toujours sur une panne soutenue : 1/2 puis alerte et StartStop = 0 au second cycle. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01GwpFpy2aQ2rJok6tNQSbVj --- HANDOFF.md | 146 ++++++++++++++++++++++++++++++++++++++++ tools/watchdog-evcs.mjs | 59 +++++++++++++++- 2 files changed, 202 insertions(+), 3 deletions(-) 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);