ClearLoadFault est le SEUL levier de reprise à distance : ECS-410 pose un verrou collant que
seuls ce RPC ou une reconstruction lèvent. L'arbitre journalisait la demande de l'opérateur,
jamais son issue — si bien qu'appelé depuis l'étranger, on ne distinguait pas « le défaut a
été levé » de « il n'y en avait pas » de « l'appel n'a pas atteint l'adaptateur ». L'opérateur
relance trois fois et conclut que le système est cassé.
L'issue est écrite dans l'ARBITRE, pas dans chaque adaptateur : une ligne là couvre les quatre
d'un coup et ne peut pas être oubliée par un adaptateur futur ; répétée dans chacun, elle
manquerait le cinquième. Les trois cas sont distincts à la lecture.
ILoadAdapter::clearFault() rend un bool au lieu de void. Le type de retour force chaque
implémentation, présente et future, à répondre à la question « un défaut a-t-il réellement été
levé ». EvAdapter, qui n'a pas de mécanisme de défaut, le dit désormais explicitement plutôt
que par un corps vide.
Règle 7-d — un refus doit être au moins aussi visible que l'application correspondante. Le
refus par verrou minStateHold de SgReadyAdapter était en qCDebug quand l'application, dix
lignes plus bas, était en qCInfo. L'asymétrie est le défaut, pas le niveau : un resserrement
de la journalisation — et on vient d'en faire un pour tenir deux semaines — aurait fait
disparaître le refus AVANT le succès, ne laissant au journal que les décisions abouties. Or un
refus est plus informatif qu'une application réussie : il dit qu'une décision a été prise et
n'a pas été exécutée. Vérification faite sur les autres adaptateurs : c'était le seul cas. Les
qCDebug restants sont symétriques, et le marqueur L2 par cycle reste en debug à dessein, son
entrée étant en qCWarning et sa sortie en qCInfo.
Le retour idempotent du routeur reste DIFFÉRÉ, et ira en qCDebug : c'est le cas le plus
fréquent — plusieurs milliers de lignes par jour sur un budget ramené à 6,5 Mo — et son
ambiguïté est partiellement couverte, le scheduler journalisant sa décision et l'armement à
froid s'exécutant avant ce retour. Faible gain, coût réel : l'inverse des deux cas corrigés.
Le test porte sur le TEXTE des trois issues et vérifie qu'elles sont deux à deux distinctes.
Suite complète : 112 tests, 0 échec.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
La correspondance exacte ne journalisait qu'au palier non nul (`if (i > 0)`). Au palier 0 —
le cas de l'armement minOff d'ECS-412, celui qu'on vient précisément observer — le journal
était muet, et « relais lus, tous ouverts » ne se distinguait pas de « reprise non
exécutée ». Constaté au banc le 2026-08-13 : ECS-411 fonctionnait, et son contrôle sur le
chemin réel était impossible depuis les traces.
Le relevé porte désormais l'état de CHAQUE relais — identifiant, puissance déclarée, et
fermé / ouvert / injoignable→supposé fermé — avec le palier retenu, la branche empruntée et
la somme lue. Sortie unique pour les trois branches. Un relais injoignable fait passer la
ligne en avertissement : le palier déduit est plausible, la lecture ne l'est pas, et
« palier repris : 0 » couvrait ces deux situations sans les distinguer.
Un défaut de fond a été trouvé PAR ce relevé, qui affichait « (0W) » pour le relais 1500 W.
La nominale par relais était redéduite des encodages solo, or la déduplication par puissance
peut en évincer un : avec 500/1000/1500, le palier 1500 est encodé {500,1000} (masque 3,
rencontré avant le masque 4) et le relais 1500 n'apparaît seul nulle part. Sa nominale valait
0, donc un démarrage sur ce seul relais donnait sumW = 0, aucune correspondance exacte, et un
palier 0 annoncé pendant que 1500 W circulaient — l'annonce en sous-estimation qu'ECS-411
existe pour interdire. Elle est prise à la déclaration (m_relayNominalW). Rendre la lecture
visible est ce qui a rendu le défaut trouvable : c'est le sens de la règle, pas un effet
secondaire.
Règle 7-c dans AGENTS.md : un silence ne doit jamais être ambigu. Journaliser l'issue, pas
seulement l'issue remarquable ; journaliser ce qui a été lu, pas seulement ce qui en est
déduit. Trois autres endroits signalés dans spec_ecs.md, non corrigés — le retour idempotent
du routeur, clearFault() sur charge saine, et le refus minStateHold en qCDebug quand
l'application est en qCInfo.
RELEVE_ECS306 §3.0, en tête du protocole : un état nymea n'émet changed() que si la valeur
bouge, donc une grandeur forcée constante déclenche le watchdog L2 en 90 s et fait mesurer le
watchdog au lieu de l'arbitrage. Constaté au banc en gelant le simulateur.
Les tests portent sur le TEXTE publié, via un collecteur de messages : un test « journal non
vide » n'aurait rien vu. Trois cas, tous vérifiés échouant sans les correctifs. Suite
complète : 112 tests, 0 échec.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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>
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>
Défaut constaté AU BANC le 2026-08-09, pas en test : une charge sonde de rang 3
est restée figée à 0 W sous 4 kW de surplus disponible.
Mécanisme. lockWindow() traitait un m_lastSwitch nul comme sentinelle
« elapsed = 0 » à CHAQUE cycle. Une charge démarrant au palier 0 avec
minOffS > 0 voyait donc offHeld vrai en permanence, maxStage forcé à 0 : elle ne
pouvait jamais s'enclencher, donc jamais commuter, donc jamais valider
m_lastSwitch. Blocage circulaire. L'intention d'ECS-412 — armer plutôt que
purger — était juste ; mon écriture rendait l'armement DÉFINITIF au lieu de
transitoire.
Portée réelle : toute charge relay-router démarrant relais ouverts avec
minOffS > 0 était gelée. Le chauffe-eau du banc (minOffS = 60) n'y échappait que
parce que ses relais étaient déjà fermés et qu'ECS-411 lui faisait reprendre un
palier non nul — au premier démarrage sur installation froide, il aurait été
bloqué lui aussi.
Correction : armement PARESSEUX. applyAction(), chemin non-const qui reçoit le
temps de cycle, estampille m_lastSwitch au premier now s'il est invalide. Le
verrou expire alors après sa durée configurée. Le repli « elapsed = 0 » ne
couvre plus que les cycles précédant la première action. L'invariant « temps =
paramètre, jamais l'horloge » est préservé : aucune horloge n'est lue, et le
contrat d'ILoadAdapter n'est pas modifié — passer now au constructeur aurait
changé l'interface pour un cas particulier.
Symétrie vérifiée : relais fermés au départ → ECS-411 donne un palier non nul,
c'est minOn qui s'arme ; relais ouverts → palier 0, c'est minOff. Les deux sont
désormais transitoires de la même façon.
Test — le vrai livrable : testEcsColdStartLockExpires. Palier 0 au départ,
minOffS = 120, budget de 5000 W. La charge reste éteinte à t0 et à t0+119, puis
s'enclenche à t0+121. Couvre aussi l'expiration de la fenêtre exposée au
scheduler (lockMaxPowerW : 0 pendant le verrou, 1000 après). Aucun test ne
combinait « palier 0 au départ » et « minOffS > 0 » — c'était le trou exact.
spec_ecs.md : ECS-412 précise que l'armement est transitoire, d'une durée égale
au verrou configuré, posé paresseusement, avec le tableau de symétrie et le
renvoi au test.
Build amd64 0 erreur. Simulation : 15/15.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Régression contre une décision documentée : AGENTS.md, section « Verrous
minOn/minOff », énonce que le scheduler clampe et décrémente le budget au palier
réel. Le canal qui le permettait — minStage/maxStage dans LoadContextTelemetry —
a été retiré en rév. 2/3. L'écrêtage a continué d'avoir lieu dans l'adaptateur,
mais son résultat ne revenait plus à l'arbitre : pendant toute la fenêtre minOn,
les charges de priorité suivante recevaient un résidu SURESTIMÉ et
l'installation soutirait au réseau.
Règle visée : la 4 — « bornes par adaptateur écrêtent TOUTE sortie de
stratégie ». Pas la 1 : il n'y a qu'un décideur, le défaut est un décalage de
comptabilité entre décision et exécution.
Le canal est restauré EN WATTS, pas en index de palier : lockMinPowerW /
lockMaxPowerW dans LoadContextTelemetry, remplis par RelayRouter::toLoadContext()
depuis lockWindow(), convertis via la table de paliers. La frontière rév. 3 tient
— aucun identifiant de relais ni index de combinaison ne remonte. Un
lockMaxPowerW négatif signifie « aucun plafond », et non « plafond nul ».
buildSetpointAction() applique ces bornes AVANT de décrémenter le budget, donc le
résidu passé aux charges suivantes tient compte de la puissance engagée — quitte
à devenir négatif, ce qui est la réponse correcte. Le decisionReason distingue ce
cas : « Verrou minOn — X maintenue à N W (puissance engagée, budget M W) ; le
résidu en tient compte ».
Corriger dans le cycle, pas au cycle suivant : remonter le palier appliqué après
coup n'aurait rattrapé l'erreur qu'au tour d'après.
Test : testEcsBudgetUnderLock — deux charges classées, la première verrouillée à
2000 W sous un budget de 500 W ; la seconde reste à 0 parce que le résidu tient
compte des 2000 W engagés.
Build amd64 0 erreur. Simulation : 14/14.
Réf. specs/spec_ecs.md §3 ECS-306 (0.5.1).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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>
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>
Restauration de l'ex-EcsRelayAdapter (b7bfd58) 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>