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

2
.gitignore vendored
View File

@@ -8,3 +8,5 @@ node_modules/
# Jeton HA local (ne jamais versionner) # Jeton HA local (ne jamais versionner)
.env .env
deploy/watchdog/watchdog.env
deploy/watchdog/logs/

View File

@@ -2183,3 +2183,79 @@ attente depuis le 27/08 est donc caduque.
Le contrôleur écrit maintenant la consigne réseau (§31). S'il s'arrête en pleine Le contrôleur écrit maintenant la consigne réseau (§31). S'il s'arrête en pleine
session, elle reste haute et la substitution de journée revient. Ce risque session, elle reste haute et la substitution de journée revient. Ce risque
n'existait pas avant cette nuit : il justifie à lui seul un chien de garde. n'existait pas avant cette nuit : il justifie à lui seul un chien de garde.
## 33. Session du 01/09/2026 (nuit) — Chien de garde EVCS sur le second Synology
### Ce qu'il protège
Le contrôleur pilote depuis §31 la consigne de puissance réseau de l'ESS. S'il
s'arrête **en pleine session**, elle reste haute et le réseau se substitue au
solaire toute la journée suivante. Ce risque est né cette nuit ; le chien de
garde existe d'abord pour lui. Il couvre au passage l'incident du 09/07
(NAS éteint la nuit, EDF laissé fermé jusqu'au matin).
### Le signal de vie : surtout PAS un helper Home Assistant
Piège trouvé en le construisant, et vérifié : **HA ne rafraîchit pas
l'horodatage d'un `input_text` réécrit à l'identique.** À 02:52,
`victron_evcs_surplus_state` paraissait figé depuis 2 440 s alors que le
contrôleur tournait normalement (`charging_hold_grid` stable depuis 02:12, et la
raison tronquée à 255 caractères perdait justement les champs variables). Un
helper HA est un mauvais battement de cœur.
Signal retenu : **l'API d'exécutions de n8n**
(`GET /api/v1/executions?workflowId=...&limit=3`). Elle prouve que la boucle
s'exécute, et son absence couvre indifféremment « n8n arrêté », « NAS éteint »
et « réseau coupé ». Zéro modification du contrôleur, zéro helper à créer.
Cas supplémentaire traité : trois exécutions consécutives en `error` = le
contrôleur tourne à vide (typiquement HA inaccessible) → considéré mort.
### Ce qu'il fait, et surtout ce qu'il ne fait pas
- Il ne prend **aucune** décision d'énergie : ni seuil, ni démarrage. Il remet
l'état sûr par défaut (consigne → 50 W, borne arrêtée) et se tait.
- Il ne touche **pas** au contacteur EDF. C'est un Tuya *cloud* (§32), donc un
actionneur non fiable en secours ; et ce n'est pas nécessaire : contacteur
fermé + consigne au repos = autoconsommation normale, la maison passe sur la
batterie jusqu'au SOC minimal puis le réseau reprend seul. État sûr.
- Il n'utilise **ni HA ni le cloud** pour agir : MQTT direct sur le GX Venus
(10.10.10.9, portail `c0619ab34ac5`), troisième machine indépendante des deux
NAS. Topics vérifiés en lecture avant tout code :
`settings/0/Settings/CGwacs/AcPowerSetPoint`, `evcharger/40/StartStop`,
`evcharger/40/Ac/Power`, `battery/512/Soc`, `system/0/Ac/Grid/L1/Power`.
Broker sans authentification, keepalive `R/<portail>/keepalive` obligatoire.
- **Une seule action par panne**, puis verrou jusqu'au retour d'un signe de vie.
Sans ce verrou, une coupure réseau entre les deux NAS ferait rejouer l'action
à chaque cycle pendant que le contrôleur, vivant, la défait : un oscillateur
de plus, dans un projet qui en a déjà corrigé quatre.
### Réglages
Seuil de silence **600 s** (20 cycles manqués) : assez long pour ignorer un
hoquet, assez court pour qu'une panne nocturne ne coûte pas une journée de
production. Période 60 s. Consigne de repos 50 W (défaut Victron).
### Tests passés
1. Contrôleur vivant → aucune écriture, une ligne de veille par heure.
2. Panne simulée (`--simulate-dead --dry-run`) → détection, deux écritures
annoncées et supprimées, message d'alerte complet avec l'état du GX.
3. Verrou : 5 cycles de panne consécutifs → **1 alerte, 2 écritures**, puis
silence.
### Déploiement (action utilisateur)
`deploy/watchdog/compose.yaml`, à lancer **sur 10.10.10.22 uniquement** :
```
cd /volume2/projects/EVCS/EVCS/deploy/watchdog
cp watchdog.env.exemple watchdog.env # y coller la clé API n8n
docker compose up -d && docker compose logs -f
```
Le dépôt est monté en **lecture seule** ; seul le journal est écrit, hors dépôt.
`node_modules` (14 Mo, `mqtt` 5.15.2) est **sans dépendance native** — le
répertoire installé depuis le poste fonctionne tel quel dans le conteneur
alpine, et il vit déjà physiquement sur 10.10.10.22 (`/mnt/projects` = export
`10.10.10.22:/volume2/projects`).
⚠️ **Ne jamais le lancer sur 10.10.10.17** : un chien de garde hébergé par la
machine qu'il surveille meurt avec elle.
### Ce qu'il ne couvre pas encore
Le contacteur EDF reste incommandable si internet tombe (Tuya cloud). Un
contacteur local — ou un `localtuya` — est le prochain vrai gain de résilience.
La sauvegarde (6 Mo d'identité vers Gitea, 6,1 Go d'historique en hebdomadaire
avec `sqlite3 .backup`) et le secours froid n8n restent à faire.

View File

@@ -0,0 +1,42 @@
# Chien de garde EVCS — à déployer sur le SECOND Synology (10.10.10.22).
#
# Il surveille le contrôleur qui tourne sur 10.10.10.17 et, s'il se tait,
# remet la consigne de puissance réseau de l'ESS à sa valeur de repos et
# arrête la borne — en MQTT direct sur le GX Venus (10.10.10.9), donc sans
# passer par Home Assistant ni par le cloud Tuya.
#
# ⚠️ NE JAMAIS le lancer sur 10.10.10.17 : un chien de garde hébergé par la
# machine qu'il surveille meurt avec elle.
#
# Mise en service :
# cd /volume2/projects/EVCS/EVCS/deploy/watchdog
# cp watchdog.env.exemple watchdog.env puis y coller la clé API n8n
# docker compose up -d
# docker compose logs -f
#
# Vérification sans risque, avant de le laisser en service :
# docker compose run --rm evcs-watchdog node tools/watchdog-evcs.mjs --once
# docker compose run --rm evcs-watchdog node tools/watchdog-evcs.mjs --once --dry-run --simulate-dead
services:
evcs-watchdog:
image: node:22-alpine
container_name: evcs-watchdog
restart: always
working_dir: /app
network_mode: bridge
volumes:
# Le dépôt en LECTURE SEULE : le chien de garde n'a aucune raison
# d'écrire dans le code qu'il exécute.
- /volume2/projects/EVCS/EVCS:/app:ro
# Son journal, seul point d'écriture, hors du dépôt.
- ./logs:/var/log/evcs-watchdog:rw
env_file:
- watchdog.env
environment:
- TZ=Europe/Paris
- WATCHDOG_LOG=/var/log/evcs-watchdog/watchdog-evcs.log
command: ["node", "tools/watchdog-evcs.mjs"]
healthcheck:
test: ["CMD", "node", "-e", "process.exit(0)"]
interval: 5m

View File

@@ -0,0 +1,14 @@
# Copier en `watchdog.env` (exclu de git) et renseigner la clé.
# La clé API n8n se trouve dans ~/.config/opencode/opencode.json
# (mcp.n8n-mcp.environment.N8N_API_KEY) sur le poste de travail.
N8N_API_KEY=
# Tout le reste a des valeurs par défaut correctes ; à ne changer qu'en
# connaissance de cause.
# N8N_URL=https://n8n.airtria.pro
# EVCS_WORKFLOW_ID=ATswvGfG4KJUls0K
# GX_MQTT_HOST=10.10.10.9
# GX_PORTAL_ID=c0619ab34ac5
# WATCHDOG_MAX_AGE_SEC=600 # 20 cycles manqués avant de conclure à la panne
# WATCHDOG_PERIOD_SEC=60
# WATCHDOG_IDLE_SETPOINT_W=50 # défaut Victron

23
logs/watchdog-evcs.log Normal file
View File

@@ -0,0 +1,23 @@
2026-09-01T00:57:08.672Z INFO démarrage — n8n https://n8n.airtria.pro (workflow ATswvGfG4KJUls0K), GX 10.10.10.9, seuil 600s, période 60s
2026-09-01T00:57:08.892Z INFO GX 10.10.10.9:1883 — connecté
2026-09-01T00:57:12.160Z INFO veille — contrôleur vivant (exécution il y a 12s), consigne 5817 W
2026-09-01T00:57:12.598Z INFO démarrage — n8n https://n8n.airtria.pro (workflow ATswvGfG4KJUls0K), GX 10.10.10.9, seuil 600s, période 60s [DRY-RUN] [PANNE SIMULÉE]
2026-09-01T00:57:12.756Z INFO GX 10.10.10.9:1883 — connecté
2026-09-01T00:57:15.762Z ALERT CONTRÔLEUR MUET — panne simulée (--simulate-dead). Consigne 5817 W, borne 3604 W, SOC 49%, réseau 4509 W. Retour à l'état sûr.
2026-09-01T00:57:15.768Z DRY écriture SUPPRIMÉE W/c0619ab34ac5/settings/0/Settings/CGwacs/AcPowerSetPoint = 50
2026-09-01T00:57:15.773Z DRY écriture SUPPRIMÉE W/c0619ab34ac5/evcharger/40/StartStop = 0
2026-09-01T00:57:15.778Z 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).
2026-09-01T00:57:40.191Z INFO démarrage — n8n https://n8n.airtria.pro (workflow ATswvGfG4KJUls0K), GX 10.10.10.9, seuil 600s, période 5s [DRY-RUN] [PANNE SIMULÉE]
2026-09-01T00:57:40.432Z INFO GX 10.10.10.9:1883 — connecté
2026-09-01T00:57:43.439Z ALERT CONTRÔLEUR MUET — panne simulée (--simulate-dead). Consigne 5817 W, borne 3373 W, SOC 49%, réseau 4614 W. Retour à l'état sûr.
2026-09-01T00:57:43.444Z DRY écriture SUPPRIMÉE W/c0619ab34ac5/settings/0/Settings/CGwacs/AcPowerSetPoint = 50
2026-09-01T00:57:43.449Z DRY écriture SUPPRIMÉE W/c0619ab34ac5/evcharger/40/StartStop = 0
2026-09-01T00:57:43.453Z 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).
2026-09-01T00:58:05.614Z INFO arrêt (SIGTERM)
2026-09-01T00:58:16.094Z INFO démarrage — n8n https://n8n.airtria.pro (workflow ATswvGfG4KJUls0K), GX 10.10.10.9, seuil 600s, période 5s [DRY-RUN] [PANNE SIMULÉE]
2026-09-01T00:58:16.273Z INFO GX 10.10.10.9:1883 — connecté
2026-09-01T00:58:19.280Z ALERT CONTRÔLEUR MUET — panne simulée (--simulate-dead). Consigne 5817 W, borne 3455 W, SOC 49%, réseau 4598 W. Retour à l'état sûr.
2026-09-01T00:58:19.287Z DRY écriture SUPPRIMÉE W/c0619ab34ac5/settings/0/Settings/CGwacs/AcPowerSetPoint = 50
2026-09-01T00:58:19.292Z DRY écriture SUPPRIMÉE W/c0619ab34ac5/evcharger/40/StartStop = 0
2026-09-01T00:58:19.297Z 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).
2026-09-01T00:58:41.503Z INFO arrêt (SIGINT)

488
package-lock.json generated Normal file
View File

@@ -0,0 +1,488 @@
{
"name": "evcs-tools",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "evcs-tools",
"dependencies": {
"mqtt": "^5.10.1"
}
},
"node_modules/@babel/runtime": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
"integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@types/node": {
"version": "26.4.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.4.0.tgz",
"integrity": "sha512-faiGnoIrLH/V8cibOMEAZ8pMw6oXqSukl29ra4mN8GdaB2ZewzeaLj+INpV5N+Z1eKWzY+IzaIZH2EIR6YZRNQ==",
"dependencies": {
"undici-types": "~8.3.0"
}
},
"node_modules/@types/readable-stream": {
"version": "4.0.24",
"resolved": "https://registry.npmjs.org/@types/readable-stream/-/readable-stream-4.0.24.tgz",
"integrity": "sha512-NRvUNC/JFGPJvqdAfEve8oginbM6V08u5NzLWpG8MwA2kTPOLnqk+wpwuPT+mp3aUsxyuT6m2gnrPuHYCruzEg==",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/ws": {
"version": "8.18.1",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/abort-controller": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
"integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==",
"dependencies": {
"event-target-shim": "^5.0.0"
},
"engines": {
"node": ">=6.5"
}
},
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
]
},
"node_modules/bl": {
"version": "6.1.6",
"resolved": "https://registry.npmjs.org/bl/-/bl-6.1.6.tgz",
"integrity": "sha512-jLsPgN/YSvPUg9UX0Kd73CXpm2Psg9FxMeCSXnk3WBO3CMT10JMwijubhGfHCnFu6TPn1ei3b975dxv7K2pWVg==",
"dependencies": {
"@types/readable-stream": "^4.0.0",
"buffer": "^6.0.3",
"inherits": "^2.0.4",
"readable-stream": "^4.2.0"
}
},
"node_modules/broker-factory": {
"version": "3.1.15",
"resolved": "https://registry.npmjs.org/broker-factory/-/broker-factory-3.1.15.tgz",
"integrity": "sha512-ko+aWvgNuP49meGrdjUu7rC+Y+Wai3cCPxP3xWwHsHfehFjOh5ZQM2yC4gEB2UddeZ/YXhm0K1eG/L6fxym2Og==",
"dependencies": {
"@babel/runtime": "^7.29.7",
"fast-unique-numbers": "^9.0.27",
"tslib": "^2.8.1",
"worker-factory": "^7.0.50"
}
},
"node_modules/buffer": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
"integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"dependencies": {
"base64-js": "^1.3.1",
"ieee754": "^1.2.1"
}
},
"node_modules/buffer-from": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="
},
"node_modules/commist": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/commist/-/commist-3.2.0.tgz",
"integrity": "sha512-4PIMoPniho+LqXmpS5d3NuGYncG6XWlkBSVGiWycL22dd42OYdUGil2CWuzklaJoNxyxUSpO4MKIBU94viWNAw=="
},
"node_modules/concat-stream": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
"integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==",
"engines": [
"node >= 6.0"
],
"dependencies": {
"buffer-from": "^1.0.0",
"inherits": "^2.0.3",
"readable-stream": "^3.0.2",
"typedarray": "^0.0.6"
}
},
"node_modules/concat-stream/node_modules/readable-stream": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
"dependencies": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
"util-deprecate": "^1.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/event-target-shim": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz",
"integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==",
"engines": {
"node": ">=6"
}
},
"node_modules/events": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
"integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==",
"engines": {
"node": ">=0.8.x"
}
},
"node_modules/fast-unique-numbers": {
"version": "9.0.27",
"resolved": "https://registry.npmjs.org/fast-unique-numbers/-/fast-unique-numbers-9.0.27.tgz",
"integrity": "sha512-nDA9ADeINN8SA2u2wCtU+siWFTTDqQR37XvgPIDDmboWQeExz7X0mImxuaN+kJddliIqy2FpVRmnvRZ+j8i1/A==",
"dependencies": {
"@babel/runtime": "^7.29.2",
"tslib": "^2.8.1"
},
"engines": {
"node": ">=18.2.0"
}
},
"node_modules/help-me": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/help-me/-/help-me-5.0.0.tgz",
"integrity": "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg=="
},
"node_modules/ieee754": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
]
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
},
"node_modules/ip-address": {
"version": "10.7.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.0.tgz",
"integrity": "sha512-BGFsyJd5mpXp3rK6jIdADLNgpJUK1jnjzvYF8lK+VyDab9JAmqN0YOKDdP17HlgKb2+ehPgDc8EtnRLbGCAMhA==",
"engines": {
"node": ">= 12"
}
},
"node_modules/js-sdsl": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/js-sdsl/-/js-sdsl-4.3.0.tgz",
"integrity": "sha512-mifzlm2+5nZ+lEcLJMoBK0/IH/bDg8XnJfd/Wq6IP+xoCjLZsTOnV2QpxlVbX9bMnkl5PdEjNtBJ9Cj1NjifhQ==",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/js-sdsl"
}
},
"node_modules/lru-cache": {
"version": "10.4.3",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="
},
"node_modules/minimist": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/mqtt": {
"version": "5.15.2",
"resolved": "https://registry.npmjs.org/mqtt/-/mqtt-5.15.2.tgz",
"integrity": "sha512-VWZU2CSUY3U3oN0PSBRDE5SNsFi4zqqNeQ/uv3pZWqY3CrBXD/dhd0ZLjlsk5YnebGlrapi4lRVNJPUNQ5aZ3w==",
"dependencies": {
"@types/readable-stream": "^4.0.21",
"@types/ws": "^8.18.1",
"commist": "^3.2.0",
"concat-stream": "^2.0.0",
"debug": "^4.4.1",
"help-me": "^5.0.0",
"lru-cache": "^10.4.3",
"minimist": "^1.2.8",
"mqtt-packet": "^9.0.2",
"number-allocator": "^1.0.14",
"readable-stream": "^4.7.0",
"rfdc": "^1.4.1",
"socks": "^2.8.6",
"split2": "^4.2.0",
"worker-timers": "^8.0.23",
"ws": "^8.18.3"
},
"bin": {
"mqtt": "build/bin/mqtt.js",
"mqtt_pub": "build/bin/pub.js",
"mqtt_sub": "build/bin/sub.js"
},
"engines": {
"node": ">=16.0.0"
}
},
"node_modules/mqtt-packet": {
"version": "9.0.2",
"resolved": "https://registry.npmjs.org/mqtt-packet/-/mqtt-packet-9.0.2.tgz",
"integrity": "sha512-MvIY0B8/qjq7bKxdN1eD+nrljoeaai+qjLJgfRn3TiMuz0pamsIWY2bFODPZMSNmabsLANXsLl4EMoWvlaTZWA==",
"dependencies": {
"bl": "^6.0.8",
"debug": "^4.3.4",
"process-nextick-args": "^2.0.1"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
},
"node_modules/number-allocator": {
"version": "1.0.14",
"resolved": "https://registry.npmjs.org/number-allocator/-/number-allocator-1.0.14.tgz",
"integrity": "sha512-OrL44UTVAvkKdOdRQZIJpLkAdjXGTRda052sN4sO77bKEzYYqWKMBjQvrJFzqygI99gL6Z4u2xctPW1tB8ErvA==",
"dependencies": {
"debug": "^4.3.1",
"js-sdsl": "4.3.0"
}
},
"node_modules/process": {
"version": "0.11.10",
"resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz",
"integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==",
"engines": {
"node": ">= 0.6.0"
}
},
"node_modules/process-nextick-args": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="
},
"node_modules/readable-stream": {
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz",
"integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==",
"dependencies": {
"abort-controller": "^3.0.0",
"buffer": "^6.0.3",
"events": "^3.3.0",
"process": "^0.11.10",
"string_decoder": "^1.3.0"
},
"engines": {
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
}
},
"node_modules/rfdc": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz",
"integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA=="
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
]
},
"node_modules/smart-buffer": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz",
"integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==",
"engines": {
"node": ">= 6.0.0",
"npm": ">= 3.0.0"
}
},
"node_modules/socks": {
"version": "2.8.9",
"resolved": "https://registry.npmjs.org/socks/-/socks-2.8.9.tgz",
"integrity": "sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==",
"dependencies": {
"ip-address": "^10.1.1",
"smart-buffer": "^4.2.0"
},
"engines": {
"node": ">= 10.0.0",
"npm": ">= 3.0.0"
}
},
"node_modules/split2": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
"engines": {
"node": ">= 10.x"
}
},
"node_modules/string_decoder": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
"dependencies": {
"safe-buffer": "~5.2.0"
}
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="
},
"node_modules/typedarray": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA=="
},
"node_modules/undici-types": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz",
"integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="
},
"node_modules/worker-factory": {
"version": "7.0.50",
"resolved": "https://registry.npmjs.org/worker-factory/-/worker-factory-7.0.50.tgz",
"integrity": "sha512-hhwc0G+sFwM4qBuhJIUBn2p1Jf8v/FwmLUANBf/Q+Lt2uI8mfIZQhXaZQACodQD4R7Zp6cn/6702bIvNn2puJQ==",
"dependencies": {
"@babel/runtime": "^7.29.7",
"fast-unique-numbers": "^9.0.27",
"tslib": "^2.8.1"
}
},
"node_modules/worker-timers": {
"version": "8.0.34",
"resolved": "https://registry.npmjs.org/worker-timers/-/worker-timers-8.0.34.tgz",
"integrity": "sha512-WXL+Dqsm0G6dnC66rQsvM3tPT2adhbqSirWUCZGglkALOr8ocS0KlpU0iuKbjflJOlu3ydZikMEVrYnHOM9suw==",
"dependencies": {
"@babel/runtime": "^7.29.7",
"tslib": "^2.8.1",
"worker-timers-broker": "^8.0.18",
"worker-timers-worker": "^9.0.15"
}
},
"node_modules/worker-timers-broker": {
"version": "8.0.18",
"resolved": "https://registry.npmjs.org/worker-timers-broker/-/worker-timers-broker-8.0.18.tgz",
"integrity": "sha512-FrjzDVX1wKfZN0gRbCFqv8VHuTncG4sbI/WGEg4tSSQeIsnwqg4YBYWMAHYLJtUDEYYmiK65UKFrVMVk2irDSg==",
"dependencies": {
"@babel/runtime": "^7.29.7",
"broker-factory": "^3.1.15",
"fast-unique-numbers": "^9.0.27",
"tslib": "^2.8.1",
"worker-timers-worker": "^9.0.15"
}
},
"node_modules/worker-timers-worker": {
"version": "9.0.15",
"resolved": "https://registry.npmjs.org/worker-timers-worker/-/worker-timers-worker-9.0.15.tgz",
"integrity": "sha512-KKUe7lZ/Aignr51H6hOUik8LwTnIgojH/1lwhli8A8qIEIyewogZTpNpMW5B6BF7nmwBOkUoTYgf1H3QShcjSA==",
"dependencies": {
"@babel/runtime": "^7.29.7",
"tslib": "^2.8.1",
"worker-factory": "^7.0.50"
}
},
"node_modules/ws": {
"version": "8.21.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz",
"integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
}
}
}

9
package.json Normal file
View File

@@ -0,0 +1,9 @@
{
"name": "evcs-tools",
"private": true,
"type": "module",
"description": "Outillage EVCS. Seul le chien de garde a une dependance externe (client MQTT vers le GX Venus) ; tout le reste tourne sur le Node de base.",
"dependencies": {
"mqtt": "^5.10.1"
}
}

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); });
}