# 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) |