Le chien de garde arretait la charge qu il devait proteger

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GwpFpy2aQ2rJok6tNQSbVj
This commit is contained in:
Tallagrand Thierry
2026-09-17 02:14:52 +02:00
parent 49a6ee88db
commit a6baad54dc
2 changed files with 202 additions and 3 deletions

View File

@@ -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* *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*

View File

@@ -81,8 +81,18 @@ const CFG = {
livenessMaxAgeSec: Number(process.env.WATCHDOG_MAX_AGE_SEC || 600), livenessMaxAgeSec: Number(process.env.WATCHDOG_MAX_AGE_SEC || 600),
periodSec: Number(process.env.WATCHDOG_PERIOD_SEC || 60), periodSec: Number(process.env.WATCHDOG_PERIOD_SEC || 60),
// Valeur de repos de la consigne réseau : le défaut Victron, pas un choix. // Valeur de repos de la consigne reseau. ⚠️ ELLE DOIT ETRE CELLE DU
idleSetpointW: Number(process.env.WATCHDOG_IDLE_SETPOINT_W || 50), // 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 // 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. // EVCS à 230 V, la granularité à laquelle le contrôleur déplace la puissance.
setpointSlackW: Number(process.env.WATCHDOG_SETPOINT_SLACK_W || 230), setpointSlackW: Number(process.env.WATCHDOG_SETPOINT_SLACK_W || 230),
@@ -149,7 +159,32 @@ async function controllerLiveness() {
const runs = (await r.json()).data || []; const runs = (await r.json()).data || [];
if (!runs.length) return { alive: false, ageSec: null, detail: 'aucune exécution connue' }; 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)) { if (!(ageSec <= CFG.livenessMaxAgeSec)) {
return { alive: false, ageSec, detail: `dernière exécution il y a ${Math.round(ageSec)}s` }; 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 armed = true; // faux = action déjà faite, on attend un signe de vie
let lastAliveLog = 0; 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() { async function cycle() {
gxKeepalive(); gxKeepalive();
const live = await controllerLiveness(); const live = await controllerLiveness();
@@ -231,6 +276,7 @@ async function cycle() {
if (live.alive) { if (live.alive) {
if (!armed) log('INFO', `contrôleur revenu (${live.detail}) — chien de garde réarmé`); if (!armed) log('INFO', `contrôleur revenu (${live.detail}) — chien de garde réarmé`);
armed = true; armed = true;
mortsConsecutives = 0;
// Trace discrète : une ligne par heure suffit à prouver qu'il veille. // Trace discrète : une ligne par heure suffit à prouver qu'il veille.
if (Date.now() - lastAliveLog > 3600e3) { if (Date.now() - lastAliveLog > 3600e3) {
lastAliveLog = Date.now(); lastAliveLog = Date.now();
@@ -239,6 +285,13 @@ async function cycle() {
return; 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 setpointW = gxRead(T.setpoint);
const evPowerW = gxRead(T.evPower); const evPowerW = gxRead(T.evPower);
const evOn = gxRead(T.evStartStop); const evOn = gxRead(T.evStartStop);