12 Commits

Author SHA1 Message Date
Patrick Schurig
010a7da0f4 ci: le contrôle de qualité documentaire — écrit, éprouvé, PAS en service
Étape 4 du chantier de documentation. tools/ci-quality.sh porte les trois
vérifications qui se posent au même endroit — elles partagent la même sortie
(doc-generated/) et la même commande de réparation, les séparer ferait trois jobs
qui régénèrent trois fois la même chose :

  1. l'index des règles est VRAI (code de retour de gen-rules.py) ;
  2. la doc Doxygen est complète — 0 avertissement, DoD §5 ;
  3. l'index est REPRODUCTIBLE : deux passes, un seul résultat.

PAS EN SERVICE, et l'extension le garantit : ci/gitea-workflow.yml.disabled ne
déclenche rien tant qu'il n'est pas déplacé dans .gitea/workflows/. La raison est
datée dans le fichier — forge muette, plus de trente commits en attente ici et
plus de soixante côté app ; un job activé à l'aveugle échouerait au moment précis
où tout le monde pousse, et le premier réflexe serait de le désactiver.

CHAQUE ÉCHEC DONNE LA COMMANDE À LANCER. Un job qui annonce « l'index est
périmé » sans dire quoi faire se contourne en le désactivant : c'est le chemin de
moindre effort, et il gagne toujours.

VÉRIFIÉ AVANT D'ÊTRE DÉCLARÉ PRÊT, et ça ne passait pas du premier coup.

— Les 26 avertissements Doxygen de ce matin sont CORRIGÉS, pas contournés par un
  cliquet sur un compte de référence : c'était de la dette DoD-5 (retours et
  paramètres non documentés dans relayrouter.h, energyarbitrator.h, loadconfig.h,
  les trois clearFault(), ILoadAdapter::updateSoftConfig), plus deux que j'avais
  introduits — un \rule{} dans un titre \par, et un \param energyLogs qui ne
  correspond à aucun argument. Le seuil est donc ZÉRO, le seul qui ne rote pas.

— Et le script lui-même échouait sur un dépôt SAIN : `grep -c ... || echo 0`
  affiche 0 PUIS sort en 1 quand il ne trouve rien, si bien que le « || » empile
  un second zéro et que le test entier casse. Corrigé ici et dans gen-doc.sh, qui
  portait le même piège. Une commande de mesure dont l'échec produit une lecture
  fausse — le motif de la semaine, appliqué à l'outil.

CE QUI N'EST PAS VÉRIFIÉ, et pourquoi : « régénérer et refuser si ça diffère de
ce qui est commité » n'a rien à comparer, doc-generated/ étant dans .gitignore.
Le risque qu'un tel diff attraperait — un artefact commité qui dérive de sa
source — a été SUPPRIMÉ en ne le commitant pas, pas déplacé. Ce qui reste à
garantir est que l'index soit vrai (contrôle 1) et déterministe (contrôle 3).

État au 2026-08-29 : ./tools/ci-quality.sh → RC=0, 113 règles, 0 avertissement.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
2026-08-29 11:33:46 +02:00
Patrick Schurig
e392c1c7b8 feat(§11): la mesure d'une charge se lit sur l'appareil, et publie sa source
LM-1105/LM-1106 implantés. measuredW disparaît, sans alias.

ILoadAdapter::deviceMeasurement() — PURE VIRTUELLE, comme runtimeView() et pour
la même raison : un mécanisme ajouté qui hériterait d'un « non mesurable » par
défaut sortirait de la mesure sans bruit, et son écran afficherait « pas
mesurable » avec l'autorité d'un constat. Le compilateur pose la question.

Le type porte une CAPACITÉ, pas une valeur nulle : `available` répond à « cette
charge est-elle mesurable », question posée à la ThingClass, jamais à la valeur
du moment. Un routeur au palier 0 mesure 0 W et reste mesurable ; sans ce
booléen les deux seraient indistinguables.

attachMeasurements() résout meter → device → none. Le compteur explicite garde la
priorité, et pas d'abord parce qu'il mesure mieux : un réglage sans effet est
pire que pas de réglage. La priorité porte sur la SOURCE, pas sur la valeur —
compteur déclaré injoignable, on retombe sur l'appareil, parce que se taire
perdrait une mesure qu'on a et que le silence se lirait « non mesurable ».

Sous `none`, powerW est OMIS. La puissance nominale reste lisible dans
mechanism.*, qui dit d'où elle vient ; la faire passer pour une mesure produirait
un écart commandé↔mesuré identiquement nul à tous les cycles — un « tout
concorde » permanent, corollaire LM-104.

DEUX AFFINEMENTS IMPOSÉS PAR L'IMPLANTATION, tous deux de modèle :

LM-1105-a — une mesure PARTIELLE n'est pas une mesure. Là où plusieurs Things
portent la charge, il faut qu'ils la portent TOUS : trois relais dont un seul
mesure annonceraient 500 W là où 1 500 W coulent, et l'écart enverrait chercher
une panne inexistante. À ne pas confondre avec LoadTelemetry::currentPowerW, qui
accepte « au moins un » et retombe sur le nominal — celui-là est le juge runtime
du budget et doit rendre un nombre à chaque cycle. Les fusionner rouvrirait la
correction B.

LM-1105-b — la règle présuppose que le Thing piloté est TRAVERSÉ par la
puissance. Un relais de relay-router commute la charge, donc sa puissance EST
celle de la charge ; un contact SG-Ready est un contact de SIGNAL — la PAC est
alimentée ailleurs et un contact qui mesurerait quelque chose mesurerait sa
propre bobine. sg-ready est donc `none` par CONSTRUCTION, même sur des relais qui
mesurent — c'est le cas du banc — et c'est le seul mécanisme pour lequel
meterThingId reste la seule voie.

RelayRouter : la capacité se juge sur la ThingClass de TOUS les relais déclarés,
la valeur se lit sur les relais FERMÉS. Jugée sur les relais actifs, la source
vaudrait `device` au palier 1 et `none` au palier 0 — un bloc de mesure qui
apparaît et disparaît au rythme du thermostat. VÉRIFIÉ ÉCHOUANT ainsi.

testMeasurementSourceIsPublished : les trois sources sans une ligne de config de
compteur, le palier 0 qui ne fait pas basculer la source, la PAC en `none` alors
que ses contacts mesurent, la disparition de measuredW, puis les deux règles de
priorité — le compteur explicite l'emporte (1 234 contre 2 900), un compteur
injoignable fait retomber sur l'appareil.

Ne touche PAS ce que l'arbitre lit : LM-1101 tient, LoadTelemetry::currentPowerW
est inchangé, et le recrédit anti-clignotement n'a jamais transité par
meterThingId.

amd64 0 erreur / 0 avertissement. Suites, un processus par test : simulation
30/30, charging 16/16, loadmodel 18/18, spotmarket 7/7.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
2026-08-28 14:49:28 +02:00
Patrick Schurig
639689baa9 feat(lot E §12): le plancher de modulation — rien, ou au moins X
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
2026-08-26 10:48:51 +02:00
Patrick Schurig
a0c02fbd34 feat(lot D): libellé jusqu'à l'adaptateur, relais comparés par table, paliers publiés
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
2026-08-26 10:07:22 +02:00
Patrick Schurig
74465375c4 feat(telemetry): lot B — frontière de télémétrie d'arbitrage + o:domain — 1.15.2+etm14
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
2026-08-25 13:18:10 +02:00
Patrick Schurig
cd1a0c83a5 fix: ECS-410-b — l'issue de ClearLoadFault, et l'asymétrie refus/succès (règles 7-c, 7-d)
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>
2026-08-13 20:27:53 +02:00
Patrick Schurig
2a4659ff8f fix(etm): ECS-414 — échelle d'échec généralisée à SgReady et EtmVariableLoad
Même modèle qu'ECS-410, rien de nouveau conçu : m_pending, verdict à zéro,
échelle bornée à trois barreaux, `this` en contexte de connexion.

PLANCHER PROPRE À CHAQUE ADAPTATEUR. Pour EtmVariableLoadAdapter, consigne 0 W.
Pour SgReadyAdapter, le barreau 3 est l'ÉTAT 2 — pas « tous contacts ouverts ».
Ouvrir les deux contacts d'une PAC est une COMMANDE, et selon l'encodage câblé
c'est potentiellement le BLOCAGE, donc l'inverse d'une mise en sécurité.

ATOMICITÉ DU REPLI — le point qui n'existait pas sur le routeur relais. Un état
SG-Ready est porté par deux bits ; si une écriture échoue, on est dans un motif
valide mais NON VOULU, et le retour depuis ce motif peut exiger de traverser le
blocage. applyStateRelays() prend désormais un ENSEMBLE de relais réellement
fermés au lieu d'un index d'état, de sorte que transientHarm ordonne le repli
exactement comme il ordonne l'aller. Sans cela, la récupération serait plus
dangereuse que la panne qu'elle corrige.

Le repli écrit les contacts directement, sans repasser par applyAction : le verrou
minStateHoldS n'est donc jamais consulté — équivalent d'un force = true, comme le
repli L2. À 900 s sur une PAC, attendre un quart d'heure pour sortir d'un état non
voulu ne serait pas défendable.

DEUX INCOHÉRENCES TROUVÉES PAR LE TEST, et corrigées :

  1. readActualOn() traitait un contact injoignable comme OUVERT, alors qu'ECS-411
     le suppose FERMÉ. Conséquence : un repli ne demandant aucune écriture
     « réussissait » à vide alors qu'on ne savait rien du matériel.

  2. Corollaire inverse : une fois supposé fermé, un contact injoignable n'était
     plus jamais écrit quand la cible le voulait fermé — l'échec était masqué et
     la transition paraissait réussie sans qu'aucune écriture n'ait été tentée.
     Un contact non vérifiable est désormais TOUJOURS commandé.

Test testSgReadyPartialFailure. Ce qu'il verrouille n'est pas le défaut mais le
PLANCHER : le repli ramène K1 à l'ouverture, soit l'état 2 sur cet encodage. Un
plancher « contacts ouverts par principe » aurait pu, sur un autre câblage, valoir
l'état 1 — le blocage. Vérifie aussi que minStateHoldS = 900 s ne retarde pas le
repli, que la charge figée a plancher == plafond, et que la levée relit l'état.

Une correction de mon propre scénario au passage : j'avais écrit un cas
« récupération au barreau 2 » qui n'est pas constructible avec un contact
définitivement injoignable — rien ne peut alors être confirmé, et le défaut est
l'issue honnête.

Build amd64 0 erreur. Simulation : 18/18.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 12:07:39 +02:00
Patrick Schurig
dd28749b6b 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
92d0bef4ac 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
c9b8e63f89 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
e70a60c180 docs(etm): contrats Doxygen — types de config/contexte et adaptateurs stables
Couverture des fichiers non visés par la restructuration à venir : l'extraction
du noyau de calcul (étape 2) et celle de m_relayMapping (étape 3) ne les
touchent pas. relayrouter.h et energyarbitrator.h sont volontairement DIFFÉRÉS —
documenter ce qui va changer produirait du bruit d'historique.

Mesure sur la config du Doxyfile : 113 → 31 avertissements.
  loadconfig.h 41→0 · surpluscontext.h 30→0 · sgreadyadapter.h 4→1 (le \return
  restant relève du lot suivant) · etmvariableloadadapter.h 2→0 · evadapter.h 1→0

Ce sont des contrats, pas des étiquettes : enabled dit qu'une charge déclarée
mais exclue n'est JAMAIS pilotée ; priority qu'il s'agit d'un rang ascendant et
non d'un poids ; timestamp qu'il est la source unique du temps, dont dérivent
verrous et fenêtres ; setPowerLevels qu'il trie et déduplique ; fromMap qu'il
retourne une config NON validée ; internalRootMeter() qu'il peut être nul.

Ajouts « // [ETM] » hors etm/ (smartchargingmanager.h) : ils échappent au
périmètre du Doxyfile, la frontière étant un répertoire. Un inventaire explicite
est posé au marqueur [ETM] BEGIN, distinguant les trois cas — degradedMode() et
les trois accesseurs internal* sont des ajouts ETM et sont documentés ; les huit
changements de visibilité seule gardent la documentation de l'amont. La
définition de fait d'AGENTS.md renvoie à cet inventaire et l'étend explicitement
aux ajouts hors etm/.

Build amd64 0 erreur. Simulation : 7/7.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 11:47:05 +02:00
Patrick Schurig
b7bfd58139 feat(etm): adaptateur etmvariableload (interface charge pilotée, Setpoint W)
Côté energymanager : EtmVariableLoadAdapter consomme l'interface etmvariableload
(states currentPowerW read / powerSetpoint write) pour les charges à puissance
pilotable (EV / ECS résistif / routeur PV). La combinatoire matérielle, l'anti-rebond
et les phases vivent dans le thing (contrat §1/§3) — l'adaptateur n'écrit qu'un
setpoint W et relit currentPowerW.

- LoadDescriptor : champs additifs powerLevels / maxPowerW (déclaration §3/§5).
- Contrat partagé versionné : docs/INTERFACE_etmvariableload.md (rév. 2).
- La déclaration de l'interface nymee + le driver thing relèvent d'une session device.

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