Patrick Schurig c88ba6ad45 [upstream-fix] rootmeter: la tension est locale à chaque phase
`voltage` était déclarée une seule fois hors des trois blocs et mutée par
l'un d'eux : une phase sans tension déclarée était calculée avec celle d'une
autre.

Et le défaut n'était pas celui qu'on croyait — le mesurer l'a montré.
L'interface energymeter rend currentPhaseA et voltagePhaseA OBLIGATOIRES, B
et C optionnels : la branche « puissance ÷ tension » de A est morte par
construction. La fuite n'allait donc pas de A vers B, comme la note d'origine
l'affirmait, mais de B vers C.

Démontré : B à 200 V, C à 4 000 W sans tension déclarée → 5,0 A de marge au
lieu de 7,6. Deux conditions, dont aucune n'était évidente avant d'écrire le
test : le masque doit couvrir B ET C — interroger C seule n'exécute pas le
bloc de B — et C doit être la phase la plus contrainte, sinon le qMin retient
B et masque l'écart. Mes deux premières versions du test passaient pour ces
deux raisons.

Le mock ne savait pas produire le cas : il a fallu meterPowerOnly, avec les
puissances et la tension de B mais 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.

draw.bindingPhase dit LAQUELLE des phases borne. perPhaseBound disait qu'une
phase borne, jamais laquelle : l'écran ne pouvait dire que « bridé » sans
dire par quoi. DRAW_CAP la porte aussi mais n'est émis que si une charge est
refusée — un cycle qui borde sans rien refuser laisserait l'écran muet.

Et dans AGENTS.md : une conclusion juste posée sur un raisonnement faux
survit jusqu'à ce qu'on bâtisse dessus, parce que la conclusion PROTÈGE le
raisonnement de l'examen. Un résultat faux se voit ; un résultat juste ferme
la question. Parade : quand une décision est écrite avec sa raison, la raison
mérite son propre contrôle — c'est elle que le lot suivant utilisera.

Suite : simulation 65/65, loadmodel 22/22, charging 48/48 identique à la
référence, spotmarket 32/32, doxygen 0 avertissement.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
2026-09-04 06:02:17 +02:00

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