Chien de garde EVCS : remise en etat sur quand le controleur se tait

Le controleur pilote depuis cette nuit la consigne de puissance reseau.
S il meurt en pleine session, elle reste haute et le reseau reprend la
place du solaire toute la journee. Ce chien de garde repare ce risque.

Signal de vie : l API d executions n8n, pas un helper HA. Verifie que HA
ne rafraichit pas l horodatage d un input_text reecrit a l identique --
surplus_state paraissait fige depuis 40 min alors que tout tournait.

Il agit en MQTT direct sur le GX Venus, troisieme machine independante
des deux NAS, jamais via HA ni via le cloud Tuya. Il ne decide rien : il
remet la consigne au repos, arrete la borne, et se tait. Une seule
action par panne, verrou jusqu au retour du controleur.

Tests : controleur vivant, panne simulee, et verrou sur 5 cycles.

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 02:59:56 +02:00
parent 807ae45b7f
commit ccd764beb9
8 changed files with 943 additions and 0 deletions

289
tools/watchdog-evcs.mjs Normal file
View File

@@ -0,0 +1,289 @@
#!/usr/bin/env node
/**
* CHIEN DE GARDE EVCS — remise en état sûr quand le contrôleur ne répond plus.
*
* POURQUOI
* Depuis le 01/09/2026 le contrôleur EVCS pilote lui-même la consigne de
* puissance réseau de l'ESS (HANDOFF §31). C'est ce qui rend une session
* réseau réelle — mais c'est aussi un risque nouveau : s'il s'arrête EN PLEINE
* SESSION, la consigne reste haute et le réseau se substitue au solaire toute
* la journée suivante (HANDOFF §26/§28). Ce chien de garde existe d'abord pour
* ça. Il couvre au passage l'incident du 09/07/2026 (NAS éteint la nuit, EDF
* laissé fermé jusqu'au matin).
*
* OÙ IL TOURNE
* Sur le SECOND Synology (10.10.10.22), pas sur celui qui héberge HA et n8n.
* Un chien de garde hébergé par la machine qu'il surveille ne sert à rien.
*
* CE QU'IL NE FAIT PAS, ET POURQUOI
* 1. Il ne prend JAMAIS de décision d'énergie. Il ne démarre rien, ne charge
* rien, ne calcule aucun seuil. Il remet le système dans son état sûr par
* défaut et s'arrête là. Deux automates qui décident sur le même contacteur,
* c'est la pathologie que ce projet passe son temps à retirer.
* 2. Il ne touche PAS au contacteur EDF. D'abord parce que c'est un Tuya
* *cloud* (HANDOFF §32) : le commander dépend d'internet, donc ce n'est pas
* un actionneur fiable en secours. Ensuite parce que ce n'est pas
* nécessaire : avec la consigne ramenée à sa valeur de repos, l'ESS
* retrouve son autoconsommation normale — la maison passe sur la batterie
* jusqu'au SOC minimal, puis le réseau prend le relais tout seul. Contacteur
* fermé + consigne au repos est un état SÛR.
* 3. Il ne passe ni par Home Assistant ni par le cloud pour agir : il écrit en
* MQTT directement sur le GX Venus (10.10.10.9), une TROISIÈME machine,
* indépendante des deux NAS. C'est ce qui lui permet d'agir quand HA est
* mort avec le reste.
*
* SIGNAL DE VIE
* L'API d'exécutions de n8n, pas un helper HA. Vérifié le 01/09 : HA ne
* rafraîchit PAS l'horodatage d'un `input_text` réécrit à l'identique, donc
* `victron_evcs_surplus_state` paraissait figé depuis 40 min alors que le
* contrôleur tournait normalement. Un helper HA est un mauvais battement de
* cœur. Une exécution n8n horodatée en est un bon : elle prouve que la boucle
* s'exécute, et son absence couvre aussi bien « n8n arrêté » que « NAS éteint »
* que « réseau coupé ».
*
* UNE SEULE ACTION PAR PANNE
* Après avoir agi, il se verrouille jusqu'au retour d'un signe de vie. Sans ce
* verrou, une simple coupure réseau entre les deux NAS ferait rejouer l'action
* à chaque cycle pendant que le contrôleur, bien vivant, la défait — un
* oscillateur de plus. Une écriture, puis silence.
*
* Usage :
* node tools/watchdog-evcs.mjs (boucle, mode réel)
* node tools/watchdog-evcs.mjs --once (un seul cycle)
* node tools/watchdog-evcs.mjs --dry-run (n'écrit jamais sur le GX)
* node tools/watchdog-evcs.mjs --once --dry-run --simulate-dead
* (rejoue une panne, sans risque)
*/
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import mqtt from 'mqtt';
const ROOT = path.resolve(path.dirname(new URL(import.meta.url).pathname), '..');
const ARGS = new Set(process.argv.slice(2));
const ONCE = ARGS.has('--once');
const DRY = ARGS.has('--dry-run');
const SIMULATE_DEAD = ARGS.has('--simulate-dead');
const CFG = {
n8nUrl: process.env.N8N_URL || 'https://n8n.airtria.pro',
workflowId: process.env.EVCS_WORKFLOW_ID || 'ATswvGfG4KJUls0K',
gxHost: process.env.GX_MQTT_HOST || '10.10.10.9',
gxPort: Number(process.env.GX_MQTT_PORT || 1883),
portalId: process.env.GX_PORTAL_ID || 'c0619ab34ac5',
evChargerInstance: process.env.GX_EVCHARGER_INSTANCE || '40',
// Un contrôleur vivant s'exécute toutes les 30 s. Dix minutes = vingt cycles
// manqués : assez pour ne pas réagir à un hoquet, assez court pour qu'une
// panne nocturne ne coûte pas une journée de production.
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),
// 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),
logFile: process.env.WATCHDOG_LOG || path.join(ROOT, 'logs', 'watchdog-evcs.log'),
};
// ---------------------------------------------------------------- journal
function log(level, msg) {
const line = `${new Date().toISOString()} ${level.padEnd(5)} ${msg}`;
console.log(line);
try {
fs.mkdirSync(path.dirname(CFG.logFile), { recursive: true });
// Rotation naïve : le chien de garde ne doit jamais remplir le disque
// qu'il est censé protéger.
try {
if (fs.statSync(CFG.logFile).size > 5e6) {
fs.renameSync(CFG.logFile, CFG.logFile + '.1');
}
} catch { /* pas encore de fichier */ }
fs.appendFileSync(CFG.logFile, line + '\n');
} catch (e) {
console.error('journal indisponible :', e.message);
}
}
// ---------------------------------------------------------------- clé n8n
function loadApiKey() {
if (process.env.N8N_API_KEY) return process.env.N8N_API_KEY;
try {
const cfg = JSON.parse(
fs.readFileSync(path.join(os.homedir(), '.config/opencode/opencode.json'), 'utf8')
);
const key = cfg?.mcp?.['n8n-mcp']?.environment?.N8N_API_KEY;
if (key) return key;
} catch { /* absent */ }
// Repli pour un déploiement hors poste de travail (conteneur sur 10.10.10.22) :
// le .env du dépôt, exclu de git comme le jeton HA.
try {
const envPath = path.join(ROOT, '.env');
const m = fs.readFileSync(envPath, 'utf8').match(/^\s*N8N_API_KEY\s*=\s*(.+?)\s*$/m);
if (m) return m[1].replace(/^["']|["']$/g, '');
} catch { /* absent */ }
log('FATAL', 'Clé API n8n introuvable (N8N_API_KEY ou opencode.json).');
process.exit(1);
}
const API_KEY = loadApiKey();
// ------------------------------------------------------------- signal de vie
/**
* Renvoie { alive, ageSec, detail }. Toute panne de lecture vaut « mort » :
* si le chien de garde ne peut pas joindre n8n, c'est déjà une anomalie, et
* l'action qu'il déclenche est de toute façon un retour à l'état sûr.
*/
async function controllerLiveness() {
if (SIMULATE_DEAD) return { alive: false, ageSec: null, detail: 'panne simulée (--simulate-dead)' };
try {
const ctl = AbortSignal.timeout(10000);
const r = await fetch(
`${CFG.n8nUrl}/api/v1/executions?workflowId=${encodeURIComponent(CFG.workflowId)}&limit=3`,
{ headers: { 'X-N8N-API-KEY': API_KEY }, signal: ctl }
);
if (!r.ok) return { alive: false, ageSec: null, detail: `n8n HTTP ${r.status}` };
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;
if (!(ageSec <= CFG.livenessMaxAgeSec)) {
return { alive: false, ageSec, detail: `dernière exécution il y a ${Math.round(ageSec)}s` };
}
// Le contrôleur s'exécute mais échoue à chaque fois : il tourne à vide
// (typiquement Home Assistant inaccessible). Trois de suite pour ne pas
// réagir à une erreur isolée.
if (runs.length >= 3 && runs.every(e => e.status === 'error')) {
return { alive: false, ageSec, detail: '3 dernières exécutions en erreur' };
}
return { alive: true, ageSec, detail: `exécution il y a ${Math.round(ageSec)}s` };
} catch (e) {
return { alive: false, ageSec: null, detail: `n8n injoignable (${e.message})` };
}
}
// ------------------------------------------------------------------- GX MQTT
const T = {
setpoint: `settings/0/Settings/CGwacs/AcPowerSetPoint`,
evStartStop: `evcharger/${CFG.evChargerInstance}/StartStop`,
evPower: `evcharger/${CFG.evChargerInstance}/Ac/Power`,
soc: `battery/512/Soc`,
gridPower: `system/0/Ac/Grid/L1/Power`,
};
const READ = (p) => `N/${CFG.portalId}/${p}`;
const WRITE = (p) => `W/${CFG.portalId}/${p}`;
const gx = { client: null, cache: new Map(), connected: false };
function gxConnect() {
return new Promise((resolve) => {
const c = mqtt.connect(`mqtt://${CFG.gxHost}:${CFG.gxPort}`, {
connectTimeout: 8000,
reconnectPeriod: 5000,
clientId: `evcs-watchdog-${Math.random().toString(16).slice(2, 8)}`,
});
c.on('connect', () => {
gx.connected = true;
for (const p of Object.values(T)) c.subscribe(READ(p));
// Venus ne publie que si on l'entretient : keepalive obligatoire.
c.publish(`R/${CFG.portalId}/keepalive`, '');
log('INFO', `GX ${CFG.gxHost}:${CFG.gxPort} — connecté`);
resolve();
});
c.on('reconnect', () => { gx.connected = false; });
c.on('close', () => { gx.connected = false; });
c.on('error', (e) => log('WARN', `MQTT: ${e.message}`));
c.on('message', (t, m) => {
try {
const v = JSON.parse(m.toString());
if (v && typeof v === 'object' && 'value' in v) gx.cache.set(t, v.value);
} catch { /* charge utile inattendue : ignorée */ }
});
gx.client = c;
setTimeout(resolve, 9000); // ne jamais bloquer la boucle sur le broker
});
}
function gxKeepalive() {
if (gx.client && gx.connected) gx.client.publish(`R/${CFG.portalId}/keepalive`, '');
}
const gxRead = (p) => gx.cache.get(READ(p));
function gxWrite(p, value) {
if (DRY) { log('DRY', `écriture SUPPRIMÉE ${WRITE(p)} = ${value}`); return; }
if (!gx.client || !gx.connected) { log('ERROR', `GX injoignable : ${p} = ${value} NON écrit`); return; }
gx.client.publish(WRITE(p), JSON.stringify({ value }));
log('ACT', `${WRITE(p)} = ${value}`);
}
// -------------------------------------------------------------------- boucle
let armed = true; // faux = action déjà faite, on attend un signe de vie
let lastAliveLog = 0;
async function cycle() {
gxKeepalive();
const live = await controllerLiveness();
if (live.alive) {
if (!armed) log('INFO', `contrôleur revenu (${live.detail}) — chien de garde réarmé`);
armed = true;
// Trace discrète : une ligne par heure suffit à prouver qu'il veille.
if (Date.now() - lastAliveLog > 3600e3) {
lastAliveLog = Date.now();
log('INFO', `veille — contrôleur vivant (${live.detail}), consigne ${gxRead(T.setpoint) ?? '?'} W`);
}
return;
}
const setpointW = gxRead(T.setpoint);
const evPowerW = gxRead(T.evPower);
const evOn = gxRead(T.evStartStop);
const dangerousSetpoint =
Number.isFinite(setpointW) && setpointW > CFG.idleSetpointW + CFG.setpointSlackW;
const evActive = evOn === 1 || (Number.isFinite(evPowerW) && evPowerW > 100);
if (!dangerousSetpoint && !evActive) {
log('WARN', `contrôleur muet (${live.detail}) — mais rien à reprendre ` +
`(consigne ${setpointW ?? '?'} W, borne ${evActive ? 'active' : 'arrêtée'})`);
return;
}
if (!armed) return; // déjà agi pour cette panne : on se tait
log('ALERT', `CONTRÔLEUR MUET — ${live.detail}. ` +
`Consigne ${setpointW ?? '?'} W, borne ${evPowerW ?? '?'} W, SOC ${gxRead(T.soc) ?? '?'}%, ` +
`réseau ${gxRead(T.gridPower) ?? '?'} W. Retour à l'état sûr.`);
if (dangerousSetpoint) gxWrite(T.setpoint, CFG.idleSetpointW);
if (evActive) gxWrite(T.evStartStop, 0);
armed = false;
log('ALERT', 'Action unique effectuée. Aucune autre écriture avant le retour du contrôleur. ' +
'Le contacteur EDF n\'est PAS touché (Tuya cloud, et consigne au repos = état sûr).');
}
// ---------------------------------------------------------------------- main
log('INFO', `démarrage — n8n ${CFG.n8nUrl} (workflow ${CFG.workflowId}), GX ${CFG.gxHost}, ` +
`seuil ${CFG.livenessMaxAgeSec}s, période ${CFG.periodSec}s` +
`${DRY ? ' [DRY-RUN]' : ''}${SIMULATE_DEAD ? ' [PANNE SIMULÉE]' : ''}`);
await gxConnect();
if (ONCE) {
await new Promise(r => setTimeout(r, 3000)); // laisser le cache se remplir
await cycle();
gx.client?.end();
process.exit(0);
}
await new Promise(r => setTimeout(r, 3000));
await cycle();
setInterval(() => { cycle().catch(e => log('ERROR', `cycle: ${e.message}`)); }, CFG.periodSec * 1000);
for (const sig of ['SIGINT', 'SIGTERM']) {
process.on(sig, () => { log('INFO', `arrêt (${sig})`); gx.client?.end(); process.exit(0); });
}