`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
281 lines
14 KiB
Markdown
281 lines
14 KiB
Markdown
# 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.ChargingSchedulesChanged` porte un champ additif
|
||
`degradedMode` (bool). Elle est émise aux **transitions** du mode dégradé (entrée/sortie),
|
||
en plus des recalculs de planning. Voir `INTERFACE.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/` :
|
||
1. Charger branché, surplus PV → charge en cours.
|
||
2. Geler les updates du compteur (mock).
|
||
3. Vérifier qu'après 90 s simulés, les consignes de repli sont émises avec le bon `reason`
|
||
et que `degradedMode` est à `true`.
|
||
4. 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** :
|
||
|
||
1. **Signal temps réel** (`smartchargingmanager.cpp` ligne 127) :
|
||
```cpp
|
||
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()`.
|
||
|
||
2. **Appel cyclique** (`smartchargingmanager.cpp` ligne 313, dans `update()`) :
|
||
```cpp
|
||
verifyOverloadProtection(currentDateTime); // position 3 du cycle
|
||
```
|
||
Filet 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) |
|