LM-301 vérifié à l'usage : brancher le mécanisme n'a touché qu'une branche de fabrique
dans rebuildLoadAdapters(), un champ optionnel au schéma RPC, et la comparaison
sameHardware() — la charge utile fait partie du « matériel », un état ré-encodé impose
une reconstruction. Aucun code d'arbitrage n'a bougé.
La PAC de configuration vit dans m_loadAdapters, la MÊME table que les autres charges.
Elle hérite donc d'ECS-412 (reconstruction incrémentale) et d'ECS-413 (état sûr à la
désactivation, qui vaut ici ÉTAT 2 et non « contacts ouverts ») sans code dédié. Le
dispatch des actions route par loadId d'abord, l'enregistrement en dur ne servant plus
que de repli.
Garde neuve, créée par cette configurabilité même : une PAC de config peut viser les
mêmes contacts que la PAC de banc câblée dans energypluginnymea.cpp. Deux adaptateurs
sur un contact, c'est deux commandes contradictoires et une charge comptée deux fois
dans le budget (règle absolue 1). La configuration fait autorité : l'exemplaire en dur
est retiré — sans mise en état sûr, les contacts ayant un propriétaire légitime.
testSgReadyFromConfig couvre les deux refus (sans état 2, charges utiles mélangées),
le round-trip par le store, le pilotage, et la désactivation vers l'état 2.
Suite complète : 111 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>
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>
L'arbitre construit et pilote les routeurs ; le repli L2 couvre les DEUX types ENSEMBLE
(atomique : pas d'intermédiaire avec charge active sans repli).
- Map unique m_loadAdapters (QHash<QString, ILoadAdapter*>) : RelayRouter + EtmVariableLoadAdapter
cohabitent par polymorphisme. registerRelayRouter() ajouté.
- rebuildLoadAdapters() : la distinction de TYPE vit ICI — RelayRouter si relays[], sinon
EtmVariableLoadAdapter. Au-dessus tout est ILoadAdapter (Setpoint W).
- Dispatch Setpoint AGNOSTIQUE au type : routage par loadId, le polymorphisme absorbe (pas de
if(RelayRouter)). Le scheduler distingue Setpoint (etmvariableload + relay-router) vs State
(sg-ready) — nature de l'action, pas classe concrète.
- Repli L2 : applyDegradedMode boucle sur TOUS les ILoadAdapter → Setpoint(0) force=true. Pour
le RelayRouter, coupe TOUS les relais (bypass minOn). Ferme le trou T2 pour le routeur.
- testMeterSilentFallback étendu au cas RELAIS : compteur muet → 2 relais OFF force=true,
restent OFF sur N cycles. Certifie la sécurité relais.
Build prod 0/0 ; suite (config/L2/migrés) verte. Étape qui rouvre le déploiement.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Pont moteur↔app : déclaration des charges etmvariableload persistée et éditable,
construction des adaptateurs à chaud depuis la config (remplace le registre en dur).
- LoadConfig / LoadConfigNeeds (Q_GADGET typés, introspectables) — forme mot pour mot
du LoadDescriptor §4 (jonction inter-repos avec l'app).
- LoadConfigStore : persistance atomique /var/lib/nymea/energy-load-configuration.json,
validation en bloc, signal changed().
- Handler NymeaEnergy.GetLoadConfig / SetLoadConfig (+ notif LoadConfigChanged). GET typé
objectRef<LoadConfig> ; SET schéma inline o: (powerLevels/needs conditionnels §4).
- EnergyArbitrator : setLoadConfigStore + rebuildEtmVariableLoadAdapters (enabled==true
seulement, §9) ; repli L2 = setPowerSetpoint(0) force=true sur tout etmvariableload
(ferme le trou sécurité ouvert en T2).
- Tests simulation : migration ECS→etmvariableload (arrondi fixed/dynamic, recrédit,
délestage, round-trip powerSetpoint), budget partagé etmvariableload↔PAC, watchdog L2
réactivé, persistance + construction depuis config + injection RPC end-to-end.
PAC SG-Ready reste hors config (§8, gelée). Les 5 tâches du brief sont couvertes.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
RuleBasedScheduler : buildSetpointAction (contrat rév. 2 §5) — fixed = arrondi au plus
haut powerLevels ≤ budget ; dynamic = clamp(budget, 0, maxPowerW) ; recrédit currentPowerW
de DÉBUT de cycle (invariant 8) ; résidu cascadé. Waterfall unifié : etmvariableload
(Setpoint W) + sg-ready (State) triés par priorité, un seul budget.
EnergyArbitrator : registerEtmVariableLoadAdapter, inclusion dans buildContext, dispatch
Setpoint par loadId (l'EV reste au proxy amont).
Infra de test : ThingClass mock etmVariableLoad (currentPowerW read / powerSetpoint write)
+ handler executeAction + helper addEtmVariableLoad — valide l'implémentabilité de
l'interface avant le thing réel. Les scénarios simulation migrés arrivent en T4.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Bug : exportW clampé à max(0,-p) AVANT recrédit → sur-crédit en import (ECS
restait allumé sur le réseau, ne délestait jamais). Fix : surplus net SIGNÉ
(exportW - importW). Régime export inchangé.
Le délestage strict est borné par minOn/minOff (protection compresseur, pas confort) :
l'adaptateur expose minStage/maxStage (fenêtre de verrou évaluée au temps de cycle),
le scheduler clampe bestStage et décrémente au palier réel → budget correct pour les
charges suivantes (puissance verrouillée = engagée non-coupable).
Seam de temps unifié : now=ctx.timestamp partagé par toLoadContext()/applyAction() ;
lockWindow() est l'unique calcul, lockActive() en dérive (décision==exécution).
Interface ILoadAdapter étendue (now) + contrat "temps=paramètre, jamais l'horloge"
documenté pour les futurs adaptateurs. EvAdapter aligné. Build 0 erreur / 0 warning.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
QTimer 30s indépendant des signaux ; m_lastMeterUpdate picoté sur powerBalanceChanged.
Silence >90s → mode dégradé (appliqué à la TRANSITION uniquement) :
- ECS palier 0 force=true ;
- EV : clamp courant minimum SEULEMENT si déjà en charge (pas d'activation forcée ;
"jamais 0 A si branché" relève du failsafe L1, pas du repli logiciel).
update() suspend la planification + le dispatch tant que m_degradedMode (sécurité L4
en position 3 reste active) → pas de rallumage sur le cache d'un compteur mort, pas
d'oscillation. Reprise au retour du compteur.
SAFETY.md §L2 : nuance maintenu/démarré + suspension planification. AGENTS.md morceau 7 :
exiger ECS reste à 0 sur plusieurs cycles. SG-Ready/Batterie déférés 3e/3f ;
flag degradedMode exposé en 3c-6. Build 0 erreur / 0 warning.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Itère slot.actions, dispatche les kind==Stage vers m_ecsAdapters (position 7,
avant adjustEvChargers). EV (Setpoint) reste sur le proxy amont jusqu'à 3g.
Build 0 erreur / 0 warning.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>