DESIGN_HC_BATTERIE.md : la règle des heures creuses, les modes de borne concernés et le changement de comportement, la lecture du modèle SunSpec 124 (StorCtl_Mod = bit qui ACTIVE la limite, OutWRte en %, séquence NO_DISCHARGE du plugin) qui fixe l'ordre des écritures, le minuteur de retour comme sûreté (et l'hypothèse non levée du plugin sur le 124), l'invariant 8 et les règles de réserve, ce qui est publié, les preuves, et ce que le lot ne fait pas. SAFETY.md : le repli L2 lève la limite de décharge explicitement. INVENTAIRE_MOCK.md : la classe controllableStorage, et ce qu'elle n'émule pas. DESIGN_PREVISION_PV.md §32.4 : la décision e1 est prise, pointeur. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
281 lines
15 KiB
Markdown
281 lines
15 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 | Rendue à son **automatisme** : une limite de décharge posée en heures creuses (lot e1) est **levée explicitement** à la transition (`enableDischarging = false`, puis `dischargingRate` au maximum). Le moteur ne commande jamais de charge réseau, il n'y a donc rien d'autre à replier. Si la levée échoue, le minuteur de retour de l'onduleur (`revertTime`) la fait tomber seul : c'est pour cela qu'une batterie sans minuteur n'est jamais commandée. |
|
||
|
||
« 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) |
|