#!/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); }); }