# 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 1 (normal) | | 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) - Une notification nymea `EnergyManagerChanged` est émise avec un flag `degradedMode: true`. - L'application affiche : *"Supervision compteur perdue — charge EV maintenue au minimum"*. ### Alertes externes (hors de ce repo) Les notifications push/mail/SMS sont déclenchées via l'infra ETM (n8n) sur réception de l'événement nymea. Aucun code de notification externe dans ce plugin. ### 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. ### 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. --- ## 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) |