buildTelemetry() n'interrogeait que la table des charges de configuration et sautait l'EV
avec un commentaire devenu faux (« décidé en amont du waterfall »). Le journal montrait les
décisions des deux bornes ; GetLoadTelemetry n'en montrait aucune. Le changement de contrat
annoncé par +etm21 n'était donc pas livré — et LM-1204-b était inéprouvable, puisque son
discriminant est justement le motif publié.
Nouveau champ loads[].funding (« surplus » / « grid ») : dès lors que les bornes sont dans
loads[], deux d'entre elles peuvent y être pour des raisons différentes, l'une comptée dans
budget.allocatedW, l'autre dans budget.evReservedW. Sans lui, sommer allocatedW donne un
écart ininterprétable.
Constat non corrigé, écrit au contrat : le rang d'une borne n'est pas configurable
(priority = 100 en dur) et std::sort n'est pas stable — laquelle des deux bornes est servie
n'est ni réglable ni garanti reproductible.
Aussi : « batterie à 50 %% » — %% n'est pas une échappée pour QString::arg. Vu au banc.
Et retrait du code mort buildMinCurrentAction/buildIdleAction, seuls porteurs de EV_ECO_MIN
et EV_IDLE depuis +etm21.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XPUo3RMr8SzK6qbFtfBm8H
La transplantation. Les bornes partagent le budget, décrémentent la cascade et portent
un rang qui a enfin un sens — il était décoratif depuis le début.
Le défaut corrigé n'était pas théorique : planSurplusCharging() calculait l'allocation
une fois avant la boucle et ne la décrémentait jamais. Chaque borne recevait la totalité
du surplus. Avec 4 kW et deux bornes, ce n'était pas « 2 kW à chacune » mais « 4 kW à
chacune », et le rattrapage venait du compteur au cycle suivant, pas de l'arbitrage.
Trois motifs EV disparaissent — des suppressions, pas des remplacements. EV_ECO_MIN en
particulier : le courant minimum était la même idée que le plancher du §12, écrite une
seconde fois pour un seul mécanisme.
Le nombre de phases est décidé AVANT l'allocation et réutilisé à l'exécution, parce que
c'est lui qui fixe le plancher — 6 A valent 1,4 kW en mono et 4,1 kW en tri. C'était le
piège le plus silencieux du lot.
evReservedW est restreint au financement réseau plutôt que supprimé : ce qui reste
décidé en amont doit rester réservé, ce qui passe par la cascade ne le doit plus.
Et une correction attrapée à la première observation en vrai de BATTERY_RESERVE : il
était publié avec withheldW à 0 sur un surplus négatif, donc il disait « une règle vous
bloque » là où il n'y avait simplement rien.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XPUo3RMr8SzK6qbFtfBm8H
Un filterByInterface qui rend une liste vide est toujours ambigu entre « il n'y a pas de
batterie » et « il y en a une, mal déclarée ». Le second désactive une règle de sécurité
sans produire ni erreur ni panne — même mode de défaillance qu'un seuil de réserve mal
réglé : rien ne se signale.
Constaté sur le banc : le Thing « SunSpec Storage » déclare battery mais pas
energystorage. La réserve ne s'est donc JAMAIS appliquée — ni dans le waterfall que je
viens d'écrire, ni dans le proxy amont qui utilisait le même filtre depuis toujours, ni
dans le bilan de puissance de l'experience-plugin qui filtre pareil. GetPowerBalance rend
currentPowerStorage: 0 pendant que la batterie affiche 50 % et « Holding » : elle n'est
pas à zéro, elle est invisible.
L'arbitre émet maintenant un avertissement quand des appareils portent 'battery' sans
'energystorage'. Il ne corrige rien — la déclaration appartient au plugin — mais il rend
la cause lisible au lieu de la laisser deviner.
Brief émis vers etm-powersync-plugins-modbus : ce n'est pas une ligne de JSON, l'interface
exige currentPower et capacity que la classe ne fournit pas, et un currentPower de
batterie à zéro serait pire que son absence.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XPUo3RMr8SzK6qbFtfBm8H
Première moitié de 3g-1. La règle batterie vivait dans planSurplusCharging(), donc dans
le chemin VE — par accident d'histoire, pas par conception. Ce n'a jamais été une règle
« véhicule » : c'est une règle de BUDGET. Tant qu'elle y restait, une batterie sous sa
réserve empêchait de charger la voiture mais laissait le chauffe-eau se servir : deux
poids, deux mesures pour la même énergie.
buildContext() remplit désormais ctx.battery (champ qui existait, resté vide depuis
« déféré 3f »), et le waterfall applique les quatre comportements de l'amont :
sous la réserve le budget est ANNULÉ (et non réduit — servir « un peu » retarderait la
recharge sans démarrer personne, planchers du §12) ; batterie en charge sous le seuil,
tampon de 500 W ; en charge au-dessus, sa puissance rejoint le budget ; en décharge, sa
puissance est retranchée quel que soit le niveau, car ce qu'elle fournit n'est pas du
surplus.
⚠️ CHANGEMENT DE COMPORTEMENT assumé pour l'ECS et la PAC, qui n'ont jamais connu cette
règle. Une installation qui servait ses charges sous le seuil de réserve ne le fera plus.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XPUo3RMr8SzK6qbFtfBm8H
Le domaine d'une consigne devient {0} ∪ [minPowerW, maxPowerW], un ensemble discontinu.
LM-1203 est la règle qui fait le travail : sous le plancher, allouer 0 et TRANSMETTRE le
budget à la charge suivante.
Constat en lisant le waterfall : il satisfaisait déjà LM-1203 pour les charges à
paliers — le plus haut palier ≤ budget vaut 0 quand le budget est sous le premier, et
le résidu cascade. Le trou n'était que dans la branche continue, où
qBound(0, budget, max) acceptait n'importe quelle miette. Le correctif est donc plus
petit que la spec ne le laissait craindre, et localisé.
Le plancher est déclaré pour etmvariableload dynamic et dérivé partout ailleurs — plus
petit palier non nul chez relay-router, plus petite estimation non nulle chez sg-ready.
SetLoadConfig refuse de le déclarer là où il se dérive, et refuse aussi minPowerW >
maxPowerW : le domaine serait réduit à {0}, la charge ne démarrerait jamais, et rien
dans le journal ne dirait pourquoi.
Motif BELOW_MIN_POWER distinct de SURPLUS_INSUFFICIENT : les confondre enverrait
chercher du soleil là où il faut lire une fiche technique.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XPUo3RMr8SzK6qbFtfBm8H
Deux champs optionnels de même forme sur LoadConfig — o:meterThingId, o:sensorThingId —
et leur publication en télémétrie. Rien d'autre : l'arbitre ne s'en sert pas pour
décider, et le §12 comme le §10 restent à faire.
« Aucun effet sur les décisions » est vrai par CONSTRUCTION, pas par discipline : les
adaptateurs ne voient jamais ces Things. attachMeasurements() les résout au moment de
publier, dans les deux chemins de télémétrie — y compris le mode dégradé, où un
exploitant veut précisément voir ce que la charge fait pendant que l'arbitre ne décide
plus rien.
measuredW est publié À CÔTÉ de allocatedW et jamais fondu dedans : l'un est ce que le
waterfall a alloué, l'autre ce qu'un compteur a vu. Les confondre rendrait la somme des
allocations irréconciliable avec budget.allocatedW — et injecter une mesure en retard
sur la commande réintroduirait l'oscillation que le recrédit a supprimée.
claimedThingIds() les exclut, et l'exclusion est maintenant commentée sur place : LM-201
vaut pour ce qui commande, ceux-ci mesurent. Sans ce commentaire, la symétrie avec
relays[] invitait à les ajouter — ce qui ferait refuser un compteur divisionnaire
partagé ou une sonde de pièce commune.
spec 0.2.1 : la précondition de livraison du §10 (extension du délestage au waterfall,
même lot ou avant, jamais après) est écrite comme CONDITION et non comme conseil — la
protection de surcharge ne déleste que les bornes, et des planchers éco qui
s'additionnent un matin sans soleil sur un branchement 6 kVA feraient disjoncter
l'installation. Et LM-1008 tranche la cible de confort : saisie en degrés, frontière de
l'arbitre en énergie, conversion à la couche domaine — et aucune conversion nécessaire
aujourd'hui, un comparateur suffit, c'est l'arrêt qui compte et pas le dosage.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XPUo3RMr8SzK6qbFtfBm8H
Les trois premiers constats de BRIEF_agent_plugin.md, dans l'ordre de ce qu'ils coûtent.
§2 — Renommer une charge n'atteignait jamais l'adaptateur. updateSoftConfig() ne
portait que priority et needs, donc un changement de libellé seul était compté
« inchangée » : l'adaptateur gardait son ancien nom et le journal contredisait
GetLoadConfig jusqu'à la prochaine reconstruction. `label` entre dans la signature et
dans le prédicat soft de l'arbitre, sans entrer dans sameHardware() — mise à jour EN
PLACE, pas reconstruction. La vraie prise était le Q_UNUSED de SgReadyAdapter, hérité
d'avant le lot B-bis : sans lui, renommer la PAC — le cas même qu'on allait tester —
n'aurait rien changé, et on aurait conclu que le correctif ne marchait pas.
§3 — relays[] n'est plus comparé par index. Un réordonnancement reconstruisait la
charge : contacts ouverts, verrous réarmés à froid. Comparer comme un ENSEMBLE aurait
été faux dans l'autre sens — à somme égale la table retient la première combinaison
rencontrée, donc échanger deux relais de même puissance change le contact qui sert ce
palier. sameHardware() compare la TABLE DÉRIVÉE : deux listes qui produisent les mêmes
paliers avec les mêmes contacts sont matériellement identiques, par construction.
§4 — mechanism.stagesW publie les paliers atteignables, en télémétrie et pas dans
GetLoadConfig. La télémétrie n'a aucun chemin d'écriture, donc « lecture seule » y est
vrai par construction ; GetLoadConfig aurait cassé l'aller-retour neutre dont l'app
dépend pour écrire — elle renvoie verbatim ce qu'elle a lu, le champ serait revenu
dans SetLoadConfig, et un refus en bloc aurait emporté toutes les charges.
deriveStages() est l'implémentation unique de la combinatoire, partagée par les trois
usages. C'était le fond de la demande du §4 : une seule règle, pas de divergence.
tests/auto/loadmodel : cible neuve sans serveur nymea, 2 ms. Elle a attrapé un défaut
de la correction elle-même avant le banc — operator== comparait les listes de ThingIds
et voyait une différence entre fermer {K1,K2} et fermer {K2,K1}.
tools/repair_energylogs.py : §1, et ce n'est PAS un correctif de ce plugin. La cause
est extérieure et déjà corrigée (SunSpec float32 aux mots permutés, d08cf50 daté
05:30:14 UTC — la seconde du premier échantillon empoisonné). La séquelle, elle, était
définitive : à 7,5e33 kWh le plus petit incrément représentable vaut 1,67e18, donc le
compteur n'était pas faux mais GELÉ, et rechargé tel quel à chaque démarrage.
Appliqué sur .75 : 62 726 anomalies → 0, accumulation reprise et vérifiée en direct.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XPUo3RMr8SzK6qbFtfBm8H
Le bloc d'enregistrement codé en dur d'energypluginnymea.cpp est supprimé, avec
registerSgReadyAdapter(), la table m_sgReadyAdapters et claimedRelays(). Il n'existe plus
aucun chemin d'enregistrement en dur : m_loadAdapters est la table UNIQUE des charges
arbitrées, et toutes ses entrées viennent de LoadConfigStore.
Contrat : loads[] de GetLoadTelemetry est désormais INCLUS dans GetLoadConfig, par
CONSTRUCTION et non par discipline. La règle de détection publiée à +etm14 — « un loadId sans
équivalent dans GetLoadConfig est une charge non configurable » — est retirée d'INTERFACE.md :
elle n'avait qu'un porteur, pac-terrain, qui vient de disparaître. Aucun configurable:false
n'est ajouté, il n'aurait plus rien à signaler. L'inclusion ne vaut que dans ce sens : une
charge enabled:false est configurée sans être publiée.
SÉCURITÉ — repli L2 d'une PAC de configuration, trou ouvert par LM-300 et refermé ici. Le
repli envoyait un Setpoint(0) à toute la table et un State(2) à une table séparée réservée à
la PAC en dur ; une PAC construite depuis la CONFIG vivant dans la première recevait donc une
action que SgReadyAdapter rejette d'entrée, et rien d'autre — elle restait en état 4 sous
compteur muet quand SAFETY.md §L2 exige l'état 2. Le motif DEGRADED_L2 s'affichait
correctement : seule l'écriture manquait. Le repli lit maintenant supportedKinds (LM-301-b).
ECS-411/-411-b au SG-Ready : l'état de départ est relu sur les contacts, avec relevé contact
par contact et l'issue — y compris quand l'issue est l'état neutre. ECS-412 au minStateHold :
armement paresseux au démarrage à froid, jamais permanent.
Harnais de test : JournalCapture se chaînait sur elle-même (récursion infinie à croissance
quadratique dès la deuxième capture vivante, donc OOM) ; tm/owner non initialisés sur trois
sites de freshSetup().
Vérifié sur .75 avant publication — docs/RELEVE_LOTBbis.md. Suites : simulation 85,
charging 74, spotmarket 44, zéro échec, un processus par test.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HajXLUczEyZd22JeewRfff
Constat d'origine, rejoué et daté au banc : 90 s d'écoute, arbitre actif, ~2 kW exportés
→ ZÉRO notification NymeaEnergy.*. L'état runtime de l'arbitre (allocation, budget, verrous,
paliers, disponibilité, motifs) ne franchissait aucune frontière RPC : il vivait exclusivement
dans qCInfo(dcNymeaEnergy). Expliquer les décisions au client n'avait donc aucun transport.
Après déploiement, même geste : 13 notifications en 480 s.
Aucune phrase composée par la box. La locale nymea est PAR CONNEXION (JSONRPC.Hello o:locale),
et NymeaEnergyJsonHandler est un JsonHandler sans pluginId — donc hors du catalogue de
traduction : rien ne rattraperait une phrase française émise là. La box transporte un CODE et
des PARAMÈTRES ; la phrase est fabriquée par le client.
LoadAction::reason devient DecisionReason{code, params}. La ligne française du journal est
RENDUE depuis ce couple par renderFr(), source unique — jamais maintenue en parallèle. Les 19
codes sont EXTRAITS des sites existants, pas inventés ; les motifs d'ECS-309 et ECS-309-b sont
repris mot pour mot. Prouvé par comparaison de traces (1726 lignes de journal identiques en
simulation), par une table d'or qui échoue au premier code sans phrase attendue, et au banc sur
7 motifs distincts. Le libellé de la charge n'entre pas dans la charge utile : il est substitué
au RENDU, par l'appelant qui le connaît.
Les verrous sortent en SECONDES RESTANTES, pas en watts. Ceci n'enfreint pas ECS-306 : cette
exigence interdit qu'un index de PALIER franchisse la frontière d'ALLOCATION ; une valeur
d'AFFICHAGE sortante est un autre chemin. Le mécanisme sort en union discriminée
(relay|variable|sgReady), toutes clés internes optionnelles au schéma (LM-302).
ILoadAdapter::runtimeView() est PURE VIRTUELLE à dessein — mais la pure virtuelle force à
RÉPONDRE, pas à répondre quelque chose. testRuntimeViewIsSubstantial() est le second verrou :
les quatre adaptateurs, amenés dans un état non trivial, doivent rendre les valeurs runtime
réelles, les verrous en secondes ET leur expiration, et les codes de défaut. Deux mutants
« vue vide » introduits puis retirés : le test échoue sur chacun.
budget publié : surplusW, evReservedW, recreditedW, allocatedW, remainingW, avec l'identité
surplus + recrédit − alloué == restant. recreditedW n'est pas décoratif : sans lui l'identité
serait fausse DÈS QU'UNE CHARGE CONSOMME, et le client chercherait un écart inexplicable. Le
banc l'a montré aussitôt — surplusW −250 W avec recreditedW 3000 W — rendant lisible l'artefact
de banc documenté dans AGENTS.md (relais sans mesure + rootmeter aveugle).
Champs omis, jamais nuls : timestamp absent = aucun cycle depuis le démarrage ; budget absent =
mode dégradé, planification suspendue. Publier un zéro se lirait « exécuté, rien à allouer ».
Cadence : changement qualitatif ou variation d'allocation > 50 W, plus un battement de 60 s.
C'est le battement qui rend un arbitre FIGÉ détectable — les notifications continuent tandis que
le timestamp cesse d'avancer. Observé 3 fois au banc. Volume mesuré : ≈ 1,7 Mo/jour, un client.
GetChargingSchedules rend enfin degradedMode : il était POUSSÉ en notification seule, donc
illisible par un client connecté après la bascule (famille ECS-411). Règles absolues 11 et 12
ajoutées à AGENTS.md.
LoadConfig : champ optionnel domain (ecs·ev·heating·hvac·battery), PURE métadonnée d'intention
que l'arbitre ne lit pas. Absente ou vide = non classée. Forme exacte découverte au banc et
corrigée dans la doc ET le test : GetLoadConfig sérialise via le méta-objet, donc la propriété
sort TOUJOURS, à "" — comme sgReady:{} ou powerLevels:[]. C'est le corollaire LM-302.
Règle 7-c, deux silences fermés : les retours idempotents du RelayRouter et du SgReadyAdapter
étaient MUETS — « consigne reçue, palier déjà bon » ne se distinguait pas de « l'action n'est
jamais parvenue à l'adaptateur », et c'est le cas ordinaire qu'on vient vérifier.
⚠️ Porté au contrat : loads[] n'est PAS en correspondance 1:1 avec GetLoadConfig. « pac-terrain »
est la PAC SG-Ready CODÉE EN DUR d'energypluginnymea.cpp (K1/K2 = Relay4/Relay5, {3:1500,
4:3000}) — arbitrée et publiée, mais inconfigurable : ni rang, ni domain, ni enabled:false.
INTERFACE.md porte la règle de détection pour l'app. Son sort reste À TRANCHER (RELEVE_LOTB §8-bis b).
Déploiement : la recette de TEST_TERRAIN.md §b portait un glob « dpkg -i /tmp/…_*_arm64.deb »,
de la même famille que celui qui a rétrogradé .75 de +etm12 à +etm9. Remplacée par
etm-powersync-deploy/deploy.sh (archivage avant dépôt, installation par nom, refus de
rétrogradation, contrôle de version après coup).
Vérifié sur .75 (1.15.2+etm13 puis +etm14) : critères 1, 2, 4, 5, 6, 7, 8 du brief. Critère 3
(épisode dégradé) vérifié en simulation seulement — le déclencher demande de faire taire un
simulateur SunSpec partagé, hors périmètre. Relevé complet, critère par critère, avec ce qui n'a
PAS été vérifié sur la machine et pourquoi : docs/RELEVE_LOTB.md.
Suites : simulation 38/38, charging 46/46, spotmarket 32/32, y compris en build DEBUG où les
Q_ASSERT_X du validateur JSON-RPC sont actifs (le schéma déclaré correspond donc à la charge
utile émise, retours ET notifications). Cross-arm64 : 0 avertissement. Doxygen : 10, inchangé.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016MfvSskkYrrFriYcj4683D
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>
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>