Chemin d ecriture du chien de garde demontre, pas seulement simule

Sonde de 777 W reellement publiee sur le topic de consigne du GX,
relue, puis valeur d origine remise par le script. Le dry-run supprimait
exactement ce maillon.

Et la fin de session s est deroulee dans le bon ordre sans coordination :
consigne retombee a 50 W a 03:31:30 avec l etat de fin de charge, puis
contacteur ouvert 34 s plus tard. La demande baisse avant que le reseau
parte.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KKzkkVTG6KXnGXFFC7X9XJ
This commit is contained in:
Tallagrand Thierry
2026-09-01 03:36:09 +02:00
parent 32de4ffa35
commit a7ebaabb8b
2 changed files with 121 additions and 0 deletions

View File

@@ -2321,3 +2321,34 @@ réellement présente — c'est cette colonne-là qui compte.
2. **L'historique HA** (4,4 Go) : perdu en cas de restauration. Conséquence 2. **L'historique HA** (4,4 Go) : perdu en cas de restauration. Conséquence
réelle pour le chantier du bilan énergétique, qui s'appuie sur les réelle pour le chantier du bilan énergétique, qui s'appuie sur les
statistiques long terme. À traiter séparément, en hebdomadaire. statistiques long terme. À traiter séparément, en hebdomadaire.
### Validation de bout en bout, nuit du 01/09 (fin de session)
**Chemin d'écriture du chien de garde DÉMONTRÉ**, pas seulement simulé
(`tools/watchdog-selftest.mjs`, rejouable) : écriture réelle d'une sonde de
777 W sur `W/<portail>/settings/0/Settings/CGwacs/AcPowerSetPoint`, relecture
conforme, remise de la valeur d'origine par le script lui-même. Le `--dry-run`
supprimait justement ce maillon-là ; sans cette preuve, le chien de garde
n'était que probablement fonctionnel. Test fait EDF ouvert, donc consigne
inerte : risque nul.
**Et la séquence de fin de session s'est déroulée dans le bon ordre**, sans
que personne l'ait orchestrée :
| 03:31:30 | fin de charge → `night_recovery_done_idle`, et **la consigne retombe à 50 W dans le même cycle** |
| 03:32:04 | 34 s plus tard, l'hystérésis ouvre le contacteur (`turn_off_recovery_done`) |
La demande baisse AVANT que le réseau parte — le principe de la séquence
d'ouverture (§30), obtenu ici par construction et non par coordination. C'est
la garantie que le risque introduit en §31 (consigne laissée haute) ne se
matérialise pas en fonctionnement normal ; le chien de garde ne couvre plus que
le cas de la panne.
**Bilan de la nuit HC** : charge démarrée à 01:58 après correction, menée à son
terme à 03:31 sans jamais entamer la réserve (batterie tenue entre −2 et +1 A
pendant toute la session), SOC stable à 49 %.
**Clé API n8n** : désormais aussi dans `.env` à la racine (convention du
propriétaire), en plus de `~/.config/opencode/opencode.json`. Le repli du chien
de garde sur `.env` est vérifié — utile pour tout déploiement hors poste de
travail.

View File

@@ -0,0 +1,90 @@
#!/usr/bin/env node
/**
* Preuve de bout en bout du CHEMIN D'ÉCRITURE du chien de garde.
*
* Le `--dry-run` valide la détection, le verrou et le journal, mais il
* supprime précisément ce qui compte le jour d'une panne : la publication
* MQTT vers le GX. Si le broker refusait les topics `W/`, le chien de garde
* serait décoratif — et on ne l'apprendrait qu'au pire moment. Ce test écrit
* réellement, relit, puis REMET la valeur d'origine lui-même.
*
* Sûreté : il n'écrit que sur la consigne de puissance réseau, il mémorise la
* valeur trouvée avant de la modifier et la restaure quoi qu'il arrive
* (y compris sur erreur ou Ctrl-C). La valeur sonde est volontairement
* absurde (777 W) pour être reconnaissable dans un journal.
*
* Usage : node tools/watchdog-selftest.mjs
*/
import mqtt from 'mqtt';
const HOST = process.env.GX_MQTT_HOST || '10.10.10.9';
const PORT = Number(process.env.GX_MQTT_PORT || 1883);
const P = process.env.GX_PORTAL_ID || 'c0619ab34ac5';
const EV = process.env.GX_EVCHARGER_INSTANCE || '40';
const PROBE_W = 777;
const SETPOINT = 'settings/0/Settings/CGwacs/AcPowerSetPoint';
const STARTSTOP = `evcharger/${EV}/StartStop`;
const ACIN = 'system/0/Ac/Grid/L1/Power';
const cache = new Map();
const c = mqtt.connect(`mqtt://${HOST}:${PORT}`, { connectTimeout: 8000 });
const read = (p) => cache.get(`N/${P}/${p}`);
const write = (p, value) => c.publish(`W/${P}/${p}`, JSON.stringify({ value }));
const wait = (ms) => new Promise(r => setTimeout(r, ms));
let original = null;
async function restore(why) {
if (original === null) return;
write(SETPOINT, original);
await wait(1500);
console.log(` restauration (${why}) → consigne ${read(SETPOINT)} W`);
}
process.on('SIGINT', async () => { await restore('interruption'); process.exit(1); });
c.on('connect', async () => {
for (const t of [SETPOINT, STARTSTOP, ACIN]) c.subscribe(`N/${P}/${t}`);
c.publish(`R/${P}/keepalive`, '');
await wait(2500);
original = read(SETPOINT);
console.log(`GX ${HOST}:${PORT} — connecté`);
console.log(`état trouvé : consigne ${original} W · borne StartStop=${read(STARTSTOP)} · réseau ${read(ACIN)} W\n`);
if (typeof original !== 'number') {
console.log('❌ consigne illisible : le test ne peut pas garantir la restauration. ABANDON.');
c.end(); process.exit(1);
}
try {
console.log(`1) écriture réelle de la sonde : ${PROBE_W} W`);
write(SETPOINT, PROBE_W);
await wait(3000);
const seen = read(SETPOINT);
console.log(` relecture : ${seen} W`);
const ok = seen === PROBE_W;
console.log(ok
? ' ✅ le GX accepte les écritures du chien de garde\n'
: ` ❌ écriture SANS EFFET (attendu ${PROBE_W}) — le chien de garde serait décoratif\n`);
console.log(`2) remise de la valeur d'origine : ${original} W`);
write(SETPOINT, original);
await wait(3000);
const back = read(SETPOINT);
console.log(` relecture : ${back} W ${back === original ? '✅' : '❌'}\n`);
console.log(ok && back === original
? 'VERDICT : chemin d\'écriture DÉMONTRÉ, état d\'origine rétabli.'
: 'VERDICT : ANOMALIE — voir ci-dessus.');
process.exitCode = ok && back === original ? 0 : 1;
} catch (e) {
console.error('erreur :', e.message);
await restore('erreur');
process.exitCode = 1;
}
c.end();
setTimeout(() => process.exit(process.exitCode ?? 0), 500);
});
c.on('message', (t, m) => { try { const v = JSON.parse(m.toString()); if (v && 'value' in v) cache.set(t, v.value); } catch {} });
c.on('error', (e) => { console.error('MQTT:', e.message); process.exit(1); });
setTimeout(() => { console.error('délai dépassé'); process.exit(1); }, 30000);