5 Commits

Author SHA1 Message Date
Patrick Schurig
9e17a3c7fa fix(etm): ECS-413 — désactiver une charge la laisse en état sûr
Constat de banc du 2026-08-09 : un SetLoadConfig posant enabled: false sur une
charge alors au palier 3500 W détruisait l'adaptateur en laissant les TROIS RELAIS
FERMÉS, juste avant une intervention de câblage. Plus personne ne les commandait ;
ils y seraient restés indéfiniment.

applySafeState(now) est ajouté à ILoadAdapter, PURE VIRTUELLE : l'état sûr est
propre à chaque adaptateur et une formulation « tout couper » serait fausse.
  - RelayRouter          : tous relais ouverts
  - EtmVariableLoadAdapter : consigne 0 W
  - SgReadyAdapter       : ÉTAT 2 (normal, mains off) — JAMAIS l'état 1. Bloquer
                           une PAC n'est pas la mettre en sécurité, c'est arrêter
                           le chauffage sans raison visible (SAFETY.md).
  - EvAdapter            : sans effet, il n'est pas construit depuis LoadConfig.

L'application passe par le chemin d'action NORMAL avec force = true, celui du
mode dégradé L2 : le mécanisme de contournement des verrous existait déjà.

ORDRE, et c'est le point qui dépendait d'ECS-410 : l'état sûr est appliqué AVANT
la destruction, et la destruction passe par deleteLater(). Les écritures d'ECS-410
sont asynchrones avec `this` en contexte de connexion — détruire immédiatement
couperait les acquittements en vol, et on ne saurait pas si la mise en sécurité a
abouti, précisément dans le cas où elle échoue.

PÉRIMÈTRE BORNÉ. Rien de tout cela à l'arrêt du plugin ni au redémarrage de
nymead : l'état doit y être CONSERVÉ, c'est ce qu'ECS-411 relit, et couper l'eau
chaude à chaque redémarrage de service serait une régression. La désactivation est
un acte délibéré de l'opérateur ; un redémarrage n'en est pas un. Les charges
CONSERVÉES par le rebuild incrémental ne passent pas par ce chemin.

Test testEcsDisableLeavesSafeState, avec son CAS NÉGATIF en premier : un rebuild
qui ne change que le rang ne coupe rien — sans lui, ECS-412 serait annulé et
chaque changement de priorité couperait la charge. Puis le cas positif :
désactivation, relais ouvert, et il le reste même sous surplus au cycle suivant.

Build amd64 0 erreur. Simulation : 17/17.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 11:36:38 +02:00
Patrick Schurig
5ec101fbe4 fix(etm): ECS-410 — échec d'écriture relais, échelle bornée et défaut collant
writeRelay() jetait le ThingActionInfo* : aucun acquittement, aucun retour
arrière, available codé en dur à true, et m_currentStage mis à jour comme si tout
avait réussi — on annonçait une puissance non appliquée.

MODÈLE ASYNCHRONE. executeAction est asynchrone et update() ne doit jamais
attendre (AGENTS règle 5). « Attendre le résultat » ne veut donc pas dire bloquer
le cycle : l'écriture est émise, l'adaptateur retient combien d'acquittements il
attend (m_pending), le verdict tombe quand le compteur retombe à zéro, et la
conséquence est traitée au cycle suivant. Motif repris tel quel d'EvCharger
(evcharger.cpp:293-299), y compris `this` en contexte de connexion — si
l'adaptateur meurt, les callbacks sont coupés proprement.

INDÉTERMINATION. Pendant une transition, telemetry() annonce
max(m_stagePrev, m_stageTarget) : on ne sait pas ce qui est fermé, on annonce donc
la plus haute des deux puissances possibles. Même direction qu'ECS-411 (relais
injoignable supposé fermé) — ne jamais annoncer moins que ce qui peut être
appliqué. Sous-estimer fait sur-allouer les charges suivantes ; surestimer ne fait
que retarder une montée.

ÉCHELLE BORNÉE à trois barreaux, une tentative chacun, aucune boucle : cible →
retour arrière → arrêt total → défaut. Le retour arrière est asynchrone au même
titre et passe par le même compteur.

DÉFAUT COLLANT, pas clignotant. m_faulted est un verrou posé une seule fois, sans
délai ni expiration : available ne peut pas osciller d'un cycle à l'autre. Seul
NymeaEnergy.ClearLoadFault le lève — acte délibéré et journalisé de l'opérateur.
La reconstruction le lève aussi, mais par construction : un adaptateur neuf n'a
pas d'historique. À la levée, l'état matériel est RELU (ECS-411), pas supposé.

CANAL OUVERT. LoadContext n'avait AUCUN champ available : le publier aurait été
décoratif. Ajouté à LoadContextTelemetry, avec sa sémantique écrite noir sur
blanc — il gouverne l'allocation, PAS la comptabilité. Une charge en défaut ne
reçoit rien mais reste comptée : une puissance qu'on ne sait plus couper est de la
conso fixe, au même titre que la base de la maison. Le figeage est porté par
lockMin == lockMax == puissance crue engagée, jamais un plafond nul sous un
plancher non nul. Le même canal servira ECS-601.

OPTIMIZER_PROTOCOL.md mis à jour dans le MÊME lot, comme l'exige le §11 de la
spec : available, lockMinPowerW et lockMaxPowerW documentés avec leur sémantique.

clearFault() est PURE VIRTUELLE sur ILoadAdapter : les trois autres adaptateurs la
déclarent sans effet plutôt que d'hériter d'un défaut vide. Leur généralisation est
portée par ECS-414.

Test testEcsPartialFailure : cas nominal sans défaut, puis échelle complète via un
relais introuvable — défaut atteint, relais valide bien ramené à l'ouverture par la
tentative d'arrêt total, available faux, plancher == plafond == puissance comptée,
plus aucune commande une heure plus tard, puis levée délibérée.

Build amd64 0 erreur. Simulation : 16/16.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 11:32:51 +02:00
Patrick Schurig
40125d1026 fix(etm): ECS-411 — reprise du palier depuis l'état réel des relais
m_currentStage valait 0 à la construction alors que des contacts peuvent être
fermés : le moteur croyait 0 W pendant que le ballon tirait sa puissance. Même
famille de défaut qu'ECS-410 — annoncer une puissance non appliquée.

deduceStageFromThings() lit l'état réel des Things au constructeur, en trois
temps :

  1. correspondance EXACTE de l'ensemble de relais fermés avec un palier — cas
     nominal ;
  2. à défaut, correspondance par PUISSANCE. Ce cas se produit avec les encodages
     dédupliqués : deux combinaisons de même puissance existent, une seule est
     conservée dans la table. Toute somme de sous-ensemble figure nécessairement
     dans m_levels, qui est construit de ces sommes — la reprise aboutit donc
     toujours, et le premier applyAction() normalise l'encodage des relais ;
  3. hors table (troncature à MaxRelays) : palier MAXIMAL, jamais 0.

Un relais introuvable est supposé FERMÉ, avec avertissement. Le principe est
constant sur les trois branches : ne jamais annoncer moins que ce qui peut être
appliqué. Sous-estimer est le défaut qu'ECS-411 corrige ; surestimer ne fait que
retarder une montée en puissance.

Le repli initial sur le palier 0 — écrit dans un premier jet — reproduisait
exactement le défaut visé et a été corrigé avant ce commit.

Test : testEcsRestartRecovery — trois cas, dont une combinaison à deux relais
(R500+R1500 = 2000 W) et le cas tout-ouvert.

Build amd64 0 erreur. Simulation : 13/13.

Réf. specs/spec_ecs.md §5 ECS-411 (0.5.1).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 12:45:15 +02:00
Patrick Schurig
867820fc26 fix(etm): ECS-412 — rebuild incrémental et armement du verrou à froid
Cause racine, pas symptôme. rebuildLoadAdapters() détruisait TOUS les adaptateurs
à chaque SetLoadConfig, réarmant leurs verrous. Sur un ballon thermodynamique à
minOn de 300-600 s, un client qui réordonne ses priorités depuis l'app pouvait
faire court-cycler son compresseur. C'est de la protection matérielle.

L'arbitre mémorise désormais la config ayant servi à construire chaque adaptateur
(m_builtFrom) et ne reconstruit que si le MATÉRIEL a changé — type, câblage,
paliers, plafond, verrous (sameHardware()). Un changement de rang ou de besoins
passe par updateSoftConfig(), en place : ni m_currentStage ni m_lastSwitch ne
bougent, aucun relais n'est réécrit. Le log distingue créées / mises à jour /
inchangées / retirées.

updateSoftConfig est PURE VIRTUELLE, sans implémentation par défaut. Un défaut
vide silencieux ferait qu'un futur adaptateur construit depuis LoadConfig
ignorerait sans bruit les changements de rang ; là, le compilateur force la
décision. EvAdapter et SgReadyAdapter la déclarent sans effet, avec le motif.

Démarrage à froid : après un redémarrage de nymead, m_lastSwitch est
irrécupérable. lockWindow() traite désormais un horodatage nul comme
« commutation venant d'avoir lieu » (elapsed = 0), donc verrou ARMÉ pour sa durée
configurée. L'écriture naturelle (`valid && elapsed < minOnS`) fait l'inverse et
laisserait une boucle de redémarrage court-circuiter la protection compresseur
quand elle est la plus nécessaire. Armer n'est pas verrouiller
inconditionnellement : avec une durée nulle, `0 < 0` est faux et le verrou reste
inactif — un premier jet qui forçait le verrou a fait tomber trois tests
existants, qui avaient raison.

Test : testEcsRebuildPreservesLock — SetLoadConfig pendant une fenêtre de verrou
active, le relais reste fermé ; le délestage reprend une fois minOn écoulé.

Build amd64 0 erreur. Simulation : 12/12.

Réf. specs/spec_ecs.md §3 ECS-412 (0.5.1).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 12:42:56 +02:00
Patrick Schurig
3e3e80d445 feat(etm): RelayRouter — couche routeur watts→relais (contrat rév. 3)
Restauration de l'ex-EcsRelayAdapter (26444a5) repositionné en ROUTEUR distinct, sous
l'optimiseur watt-pur. ILoadAdapter consommant Setpoint(W) au lieu de Stage :

- Paliers DÉRIVÉS : toutes les combinaisons (sommes de sous-ensembles) des relais, dédupliquées,
  triées, 0 inclus — source de vérité unique = les relais (descriptor().powerLevels/maxPowerW).
- applyAction(Setpoint W) : mappe powerW → combinaison la plus haute ≤ powerW, applique les
  verrous minOn/minOff EN INTERNE (clamp, plus exposés au scheduler), commute via
  ThingManager::executeAction (interface "power"), off-before-on. force=true → bypass (repli L2).
- currentPowerW : mesuré si relais expose "currentPower", sinon nominal commandé (GPIO bool nu).
- LoadContext : watts uniquement (powerLevels/currentPowerW) — aucun relais ne franchit la frontière.

Classe INERTE à ce commit (non câblée à l'arbitre) : aucune charge ne l'utilise, donc aucune
charge sans repli L2. Câblage + repli L2 = étape 4 (atomiquement). Build prod 0/0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 12:03:17 +02:00