Patrick Schurig b29489bdb5 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

12 KiB
Raw Blame History

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

    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()) :

    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)