`voltage` était déclarée une seule fois hors des trois blocs et mutée par l'un d'eux : une phase sans tension déclarée était calculée avec celle d'une autre. Et le défaut n'était pas celui qu'on croyait — le mesurer l'a montré. L'interface energymeter rend currentPhaseA et voltagePhaseA OBLIGATOIRES, B et C optionnels : la branche « puissance ÷ tension » de A est morte par construction. La fuite n'allait donc pas de A vers B, comme la note d'origine l'affirmait, mais de B vers C. Démontré : B à 200 V, C à 4 000 W sans tension déclarée → 5,0 A de marge au lieu de 7,6. Deux conditions, dont aucune n'était évidente avant d'écrire le test : le masque doit couvrir B ET C — interroger C seule n'exécute pas le bloc de B — et C doit être la phase la plus contrainte, sinon le qMin retient B et masque l'écart. Mes deux premières versions du test passaient pour ces deux raisons. Le mock ne savait pas produire le cas : il a fallu meterPowerOnly, avec les puissances et la tension de B mais sans les courants de B/C ni la tension de C. Troisième fois qu'ouvrir le mock révèle un défaut plutôt que de seulement débloquer un test. draw.bindingPhase dit LAQUELLE des phases borne. perPhaseBound disait qu'une phase borne, jamais laquelle : l'écran ne pouvait dire que « bridé » sans dire par quoi. DRAW_CAP la porte aussi mais n'est émis que si une charge est refusée — un cycle qui borde sans rien refuser laisserait l'écran muet. Et dans AGENTS.md : une conclusion juste posée sur un raisonnement faux survit jusqu'à ce qu'on bâtisse dessus, parce que la conclusion PROTÈGE le raisonnement de l'examen. Un résultat faux se voit ; un résultat juste ferme la question. Parade : quand une décision est écrite avec sa raison, la raison mérite son propre contrôle — c'est elle que le lot suivant utilisera. Suite : simulation 65/65, loadmodel 22/22, charging 48/48 identique à la référence, spotmarket 32/32, doxygen 0 avertissement. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
14 KiB
SAFETY.md — Modèle de sécurité ETM PowerSync Energy
Décision Patrick Schurig, validée en session 2026-06-08. Ce document est normatif.
Principes
Cinq couches indépendantes + signalisation locale optionnelle. Une couche supérieure peut tomber sans impacter les couches inférieures. Jamais de contournement logiciel d'une couche matérielle.
L0 — Disjoncteur (matériel)
Protège contre : surintensité physique, défaut d'isolement.
Responsable : installateur électricien, norme C15-100.
ETM ne touche pas à cette couche. Elle fonctionne que nymead soit vivant ou non.
L1 — Failsafe natif des bornes EV et charges pilotées
Protège contre : perte de communication borne ↔ nymead.
Ne couvre PAS : perte de communication compteur ↔ nymead (c'est le rôle de L2).
Responsable : configuration installateur sur chaque borne/appareil.
Checklist ETM à la mise en service :
- Borne EV : configurer le timeout de communication → repli sur courant minimum défini par l'installateur (jamais 0 A si le VE est branché, jamais maximum abonnement).
- Relais ECS : désactivation si absence de commande > N minutes (selon chauffe-eau).
- SG-Ready PAC : état 1 (normal, non piloté) si silence > N minutes.
Cette configuration est documentée dans la checklist de déploiement
(etm-powersync-deploy), pas ici.
L2 — Watchdog fraîcheur compteur (à implémenter en phase 3c)
Protège contre : compteur hors ligne, plugin gelé, nymead bloqué.
Responsable : code ETM dans EnergyArbitrator.
Comportement
Un QTimer (pas un signal meterChanged) tourne en permanence avec une période de 30 s.
À chaque tick : si QDateTime::currentDateTime() - m_lastMeterUpdate > 90 s
→ mode dégradé déclenché.
Choix de conception : QTimer et non signal powerBalanceChanged, car si le
compteur est muet, le signal ne fire plus — le watchdog doit rester actif précisément
dans ce cas.
Mode dégradé — consignes de repli (Variante B — décision Patrick)
À la transition vers le mode dégradé, les charges pilotées reçoivent une consigne de repli conservatrice : le repli n'INITIE rien, il borne ce qui tourne déjà.
| Charge | Consigne de repli |
|---|---|
| EV en charge | Clamp au courant minimum borne (maxChargingCurrentMinValue) |
| EV branché mais pas en charge | Inchangé — reste off (off possiblement volontaire : HC/spot à venir) |
| EV débranché | Aucune action |
| ECS | Relais coupé (palier 0, force=true) |
| SG-Ready PAC | État 2 (normal — mains off, la PAC chauffe selon son thermostat). JAMAIS état 1 (blocage) : bloquer une PAC sous compteur muet = maison qui ne chauffe plus sans raison visible. |
| Batterie | Aucune charge réseau (surplus uniquement, plafonné à 0 si compteur muet) |
« Maintenu » ≠ « démarré » : le mode dégradé ne force jamais l'activation d'une charge. La garantie "jamais 0 A si le VE est branché" appartient au failsafe L1 de la borne (timeout communication → courant minimum installateur), pas au repli logiciel.
Chaque consigne porte un decisionReason explicite :
"Compteur muet depuis >90 s — consigne de repli (L2 watchdog)".
Suspension de la planification : tant que le mode dégradé est actif, update()
exécute la sécurité L4 (position 3, intouchable) puis retourne immédiatement — ni
getPlan() ni dispatch. Sinon update() replanifierait sur le cache d'un compteur
mort et rallumerait les charges que le watchdog vient de couper, que le tick suivant
recouperait 30 s plus tard (oscillation). Le repli est donc appliqué une seule fois à
la transition ; les consignes tiennent jusqu'au retour du compteur.
Risque accepté : ~1,4 kW de tirage EV non supervisé pour un VE déjà en charge au moment de la bascule (minimum borne typique ≈ 6 A × 230 V × 1 phase). Ce tirage est atténué par L0 (disjoncteur) et L1 (failsafe borne). Le client est informé (voir Notification ci-dessous).
Limite : la notification part du contrôleur dégradé lui-même (best-effort). L'alerte indépendante de la défaillance du contrôleur est le rôle de L3 (watchdog systemd).
Notification client (dans ce repo)
- La notification JSON-RPC
NymeaEnergy.ChargingSchedulesChangedporte un champ additifdegradedMode(bool). Elle est émise aux transitions du mode dégradé (entrée/sortie), en plus des recalculs de planning. VoirINTERFACE.md. - L'application affiche : "Supervision compteur perdue — charge EV maintenue au minimum".
Limite — détection par fraîcheur uniquement
Le watchdog L2 détecte l'absence de mise à jour du compteur (plus de signal
powerBalanceChanged depuis > 90 s), pas une valeur figée. Un compteur qui continue
d'émettre une valeur strictement constante (capteur bloqué mais lien vivant) n'est pas
détecté par cette couche — m_lastMeterUpdate reste frais. Détecter une valeur figée
(variance nulle sur fenêtre) est hors scope L2 ; le cas est couvert au niveau matériel/L0
et par la supervision externe.
Signalisation locale (zéro cloud)
ETM PowerSync est 100 % autonome, zéro cloud : aucune alerte ne sort vers un service
distant (ni n8n, ni mail, ni push tiers). Le système est conçu pour fonctionner sans
internet (argument produit : autoconsommation, local-first). Le degradedMode est
signalé par deux canaux strictement locaux :
- Notification nymea in-app (déjà implémentée : champ
degradedMode) — canal principal vers le client connecté à l'application. - Signal sonore local optionnel (buzzer GPIO ou canal relais) piloté par une Règle
nymea déclenchée sur
degradedMode— pour le client sur place, sans application. Aucun code buzzer dans ce repo : le moteur expose l'état, la signalisation est une Thing nymea + une règle (configuration d'installation, cf.## Signalisation locale).
Aucun canal sortant réseau. Voir l'invariant « ZÉRO cloud » dans AGENTS.md.
Sortie du mode dégradé
Dès que le compteur fournit une nouvelle mesure (m_lastMeterUpdate remis à jour)
→ reprise normale au cycle suivant, degradedMode: false.
Scénario de simulation obligatoire (DoD 3c)
Scénario testMeterSilentFallback dans tests/auto/simulation/ :
- Charger branché, surplus PV → charge en cours.
- Geler les updates du compteur (mock).
- Vérifier qu'après 90 s simulés, les consignes de repli sont émises avec le bon
reasonet quedegradedModeest àtrue. - Dégeler le compteur → vérifier la reprise normale et
degradedMode: false.
L3 — Watchdog systemd sur nymead
Protège contre : crash de nymead, deadlock process, défaillance de L2.
Responsable : etm-powersync-deploy (hors scope de ce repo).
Nymead enregistre sd_notify(WATCHDOG=1) ; systemd le relance si absent > timeout.
À la reprise, L1 garantit la sécurité matérielle pendant le redémarrage.
L4 — Logique signal-driven existante (amont)
Protège contre : surcharge transitoire sur phases.
Responsable : SmartChargingManager::verifyOverloadProtection().
Double déclenchement (code amont vérifié)
verifyOverloadProtection() est déclenchée par deux mécanismes distincts :
-
Signal temps réel (
smartchargingmanager.cppligne 127) :connect(m_energyManager, &EnergyManager::powerBalanceChanged, this, [this]() { verifyOverloadProtection(QDateTime::currentDateTime()); });C'est le mécanisme principal : déclenché à chaque nouveau bilan de puissance émis par le compteur, sans attendre le prochain cycle
update(). -
Appel cyclique (
smartchargingmanager.cppligne 313, dansupdate()) :verifyOverloadProtection(currentDateTime); // position 3 du cycleFilet périodique — garantit une vérification même si le signal précède la mise à jour des états internes.
Note : en mode simulation, simulationCallUpdate() (ligne 295-299) appelle update()
puis verifyOverloadProtection() une seconde fois — double appel intentionnel dans
les tests, pas dans la production.
Défaut amont corrigé — calculateAllowanceAmpere() lisait un autre état que celui qu'elle testait
Relevé et corrigé le 2026-08-30 ([upstream-fix], commit isolé). rootmeter.cpp calculait
la place libre différemment selon la phase :
| Phase | Condition testée | Valeur lue AVANT | Valeur lue APRÈS |
|---|---|---|---|
| A | hasState("currentPhaseA") |
currentPhaseA — ampères |
inchangé |
| B | hasState("currentPhaseB") |
currentPowerPhaseB / 230 — watts |
currentPhaseB |
| C | hasState("currentPhaseC") |
currentPowerPhaseC / 230 — watts |
currentPhaseC |
La conséquence sérieuse. Un compteur publiant currentPhaseB sans currentPowerPhaseB
entrait dans la première branche, y lisait 0, et se voyait accorder une place libre égale au
plafond entier. La protection de surcharge était aveugle sur B et C — elle n'écrêtait rien,
et ne signalait rien. Une conséquence bénigne s'y ajoutait : sur B et C la tension réelle était
ignorée au profit de 230 V, alors que la branche suivante, elle, la lit.
Ce que le correctif change. Des bornes seront écrêtées qui ne l'étaient pas. Ce n'est pas une régression : c'est la protection qui redevient effective sur B et C. Une installation triphasée déséquilibrée dont la phase B était chargée voyait jusqu'ici le HEMS lui accorder du courant qu'elle n'avait pas — le disjoncteur restait le seul rempart, c'est-à-dire L0 employé comme régulateur au lieu de dernier recours.
L'angle mort qui l'a rendu invisible
Le mock pose currentPhaseB = currentPowerPhaseB / 230. Les deux états y sont cohérents par
construction, donc lire l'un ou l'autre y donne exactement le même nombre : aucun test de ce
dépôt ne pouvait trouver le défaut, quelle que soit sa qualité. Il a fallu, pour l'épingler,
désaccorder les états à la main — ce qu'aucun chemin normal du mock ne permet
(testPhaseAllowanceReadsTheStateItTests).
C'est le même angle mort que ceux relevés cette semaine, et il mérite d'être nommé comme une classe et non comme un incident : l'installation de référence n'exerce pas le cas. Le banc et les mocks reproduisent une installation — celle qu'on a. Un défaut qui ne se manifeste que sur une installation qu'on n'a pas est invisible aux deux à la fois, et la suite verte ne dit alors rien sur lui.
La parade n'est pas d'élargir le mock indéfiniment, mais de se poser la question à l'endroit où
elle se pose : quand un code lit un état d'appareil, demander ce qu'il fait si cet état est
absent — et se souvenir que stateValue() d'un état absent rend 0, valeur qui, dans un
calcul de marge, se confond avec « rien ne consomme ».
Défaut amont voisin, CORRIGÉ le 2026-09-04 — la tension fuyait d'une phase à l'autre
voltage était déclarée une seule fois hors des trois blocs, et mutée par celui d'une phase :
une phase qui ne publie pas sa propre tension était donc calculée avec celle d'une autre.
Mais le défaut n'était pas celui qu'on croyait, et le mesurer l'a montré. L'interface
energymeter rend currentPhaseA et voltagePhaseA obligatoires ; currentPhaseB/C et
voltagePhaseB/C sont optionnels. La branche « puissance ÷ tension » de la phase A est donc
morte par construction — la fuite n'allait pas de A vers B, comme la note d'origine
l'affirmait, mais de B vers C. Plus étroit, et bien réel.
Démontré : B à 200 V, C à 4 000 W sans tension déclarée → 5,0 A de marge annoncés au lieu de
7,6. Le masque doit couvrir B et C — interroger C seule n'exécute pas le bloc de B et ne
révèle rien — et C doit être la phase la plus contrainte, sinon le qMin retient B et masque
l'écart. Deux conditions, dont aucune n'était évidente avant d'avoir écrit le test.
Et il n'était pas atteignable au banc : le mock ne savait produire qu'un compteur publiant
tous les états. Il a fallu une classe meterPowerOnly — puissances et tension de B, sans les
courants de B/C ni la tension de C. Troisième fois qu'ouvrir le mock révèle un défaut plutôt
que de seulement débloquer un test (docs/INVENTAIRE_MOCK.md).
Note d'origine, conservée — elle décrivait mal le défaut
Dans la même fonction, voltage est déclarée une fois hors des trois blocs et mutée par
celui de la phase A. Un compteur ayant voltagePhaseA mais pas voltagePhaseB verrait donc la
phase B calculée avec la tension de A. Laissé en l'état, sciemment : le cas est plus contraint
que le précédent, aucun relevé ne le montre, et il appartient à son propre [upstream-fix]
avec son propre test.
Règle de code ETM
EnergyArbitrator::update() appelle verifyOverloadProtection() en position 3,
identique à l'amont. INTERDIT de déplacer cet appel ou de le conditionner.
La connexion signal est héritée via le constructeur parent — aucune reconnexion ETM.
Signalisation locale (optionnelle — hors de ce repo)
Protège contre : alarme silencieuse non détectée, exigeant une action humaine immédiate.
Ce plugin émet les événements (degradedMode, alarmes overload, etc.).
La signalisation est une Thing nymea (buzzer GPIO ou canal relais) + une Règle
déclenchée sur ces événements — configuration installation, documentée dans
etm-powersync-deploy.
Aucun code buzzer/relais dans ce repo. Principe : le moteur émet, la configuration d'installation décide quoi signaler.
Zéro cloud : toute la signalisation est locale (notification nymea in-app +
signalisation physique). Aucun appel réseau sortant vers un service distant — le système
fonctionne sans internet. Invariant gravé dans AGENTS.md.
Correspondance couches / scénarios de défaillance
| Défaillance | L0 | L1 | L2 | L3 | L4 | Signal local |
|---|---|---|---|---|---|---|
| Surintensité réseau | ✅ | ✅ | — | — | ✅ | optionnel |
| Borne perd le réseau IP | — | ✅ | — | — | — | — |
| Compteur hors ligne > 90 s | ✅ | — | ✅ | — | — | optionnel |
| Crash nymead | — | ✅ | — | ✅ | — | — |
Bug dans update() |
— | ✅ | ✅ | ✅ | ✅ | — |
| Optimiseur externe mort | — | — | — | — | — | repli rule-based (AGENTS §6) |