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

- 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.

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