Patrick Schurig ab5b9757f4 feat(délestage §2): le disjoncteur passe par la résolution, au watt près
`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
2026-08-30 11:58:23 +02:00

241 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 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é :
1. **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.
2. **Sérieuse** : un compteur qui publierait `currentPhaseB` **sans** `currentPowerPhaseB`
entre dans la première branche, y lit `0`, 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) |