Files
EVCS/tools/watchdog-evcs.mjs
Tallagrand Thierry a6baad54dc 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
2026-09-17 02:14:52 +02:00

343 lines
16 KiB
JavaScript

#!/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 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),
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' };
// ⚠️ 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` };
}
// 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;
// ⚠️ 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();
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();
log('INFO', `veille — contrôleur vivant (${live.detail}), consigne ${gxRead(T.setpoint) ?? '?'} W`);
}
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);
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); });
}