`phasePowerLimit` devient un `DrawCap{Breaker, perPhaseW}` et
`evPhaseAllowanceW()` traverse `resolveDrawMargin()` — la mécanique du
délestage est branchée, et rien ne bouge. C'est le but : à comportement
constant, tout écart observé ensuite est un défaut, pas une évolution.
L'égalité est obtenue par CONSTRUCTION. `phaseDrawW()` inverse
`calculateAllowanceAmpere()` phase par phase plutôt que de relire les états
du compteur : les lectures amont ne sont pas symétriques entre phases, et
les recopier ici en aurait fait deux versions vouées à diverger.
Deux déplacements sans changement de valeur : l'écrêtage `qMax(0, …)`
descend chez le consommateur (la résolution rend une marge SIGNÉE, dont
l'étape 4 a besoin pour dire de combien délester), et le facteur 230 remonte
dans le plafond, qui s'exprime en watts comme les autres.
Vérification : quatre suites diffées ligne à ligne contre une référence
capturée avant l'édition — charging 48/48 et simulation 50/50 identiques,
loadmodel 21/21, spotmarket 32/32. Contre-épreuve : à 1 V près la suite ne
discrimine pas (consignes en ampères entiers), donc cet essai ne prouve
rien ; à un facteur dix, 7 échecs. Le chemin est vivant.
Au passage, une `\note` du .h affirmait qu'on rend à la borne ce qu'elle
tire déjà. Le code ne l'a jamais fait, et c'est délibérément de sécurité :
sur une phase en dépassement la marge est négative, et c'est ce signe qui
ramène la borne à son minimum.
Décisions 6.1 à 6.3 gravées dans le design, et défaut amont consigné dans
SAFETY.md — non corrigé sciemment, il appartient à un [upstream-fix] isolé.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
12 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 connu — calculateAllowanceAmpere() est asymétrique entre phases
Relevé le 2026-08-30, non corrigé, sciemment. rootmeter.cpp calcule la place libre
différemment selon la phase :
| Phase | Condition testée | Valeur réellement lue |
|---|---|---|
| A | hasState("currentPhaseA") |
currentPhaseA — ampères, correct |
| B | hasState("currentPhaseB") |
currentPowerPhaseB / 230 — watts, et voltagePhaseB ignoré |
| C | hasState("currentPhaseC") |
currentPowerPhaseC / 230 — idem |
Deux conséquences, d'inégale gravité :
- Bénigne : sur B et C la tension réelle est ignorée au profit de 230 V, alors que la branche suivante, elle, la lit. Erreur de quelques pour-cent.
- Sérieuse : un compteur qui publierait
currentPhaseBsanscurrentPowerPhaseBentre dans la première branche, y lit0, et rend une place libre égale au plafond entier. La protection de surcharge serait alors aveugle sur B et C — elle n'écrêterait rien, sans rien signaler.
Pourquoi ce n'est pas corrigé dans ce lot. Le mock du banc publie les six états, donc le
cas 2 n'y est pas atteint et aucun relevé machine ne le montre. Surtout, le correctif change
le comportement : il rendrait la protection plus restrictive sur B et C, donc écrêterait des
bornes qui ne l'étaient pas. Il appartient à un commit [upstream-fix] isolé, avec son propre
test, et non à une étape dont tout l'intérêt est d'être à comportement constant.
Ce que le moteur en fait en attendant. EnergyArbitrator::phaseDrawW() obtient le tirage
par phase en inversant cette fonction plutôt qu'en relisant les états. C'est délibéré :
recopier les lectures amont ici en ferait deux versions vouées à diverger, et la version ETM
« corrigée » masquerait le défaut au lieu de le laisser visible là où il est.
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) |