ECS-Meter et PAC-Meter sont des smartMeterConsumer de generic-energy appairés à la
main : leur currentPower est SAISI, pas mesuré. 1 500 W et 800 W constants jusqu'au
2026-09-05, puis 0 depuis le passage de la box aux vrais compteurs Eastron, faute
de quelqu'un pour l'écrire.
Donc tout le §11 — compteur dédié par charge, sessionWh depuis le compteur,
measurement.source "meter" — a été éprouvé contre des valeurs tapées.
CE QUI EST PROUVÉ : la plomberie. meterThingId résolu, état lu, valeur remontée
dans measurement.powerW, sessionWh alimenté, source = meter. La chaîne existe et
transporte.
CE QUI NE L'EST PAS : la mesure. Qu'un compteur réel rattaché à une charge réelle
produise une valeur qui SUIT la commande — donc que l'écart commandé↔mesuré
signifie quelque chose.
La distinction n'est pas académique : c'est celle qui rend les traces de divergence
inexploitables sur ces deux charges. Un compteur constant produit un écart qui ne
parle que de sa propre constance.
Et ça explique ce que voit l'app : une consommation affichée sur des compteurs qui
ne mesurent rien. Elle est fidèle, la box aussi — la chaîne est juste sans source.
Troisième nomination du sixième manque du mock, par un troisième chemin : les
traces de divergence, puis la pose d'obligation, maintenant le passage au réel.
Un `sudo journalctl` par ssh ÉCHOUE — pas de TTY pour le mot de passe — et
l'échec part sur stderr : stdout est vide, le grep en aval ne trouve rien, la
commande rend 0. Un relevé conduit ainsi ne dit rien et en a l'air.
C'est arrivé sur mes propres relevés des 28 et 29 : deux recherches « la ligne X
apparaît-elle ? » n'avaient RIEN LU. Et l'explication que j'avais donnée à
l'absence — « cette ligne est en qCDebug, le niveau n'est pas actif » — était
fausse : les lignes `D | NymeaEnergy:` sortent bel et bien sur .75. Une cause
plausible avancée pour un silence qu'on n'a pas mesuré, c'est le corollaire
LM-104 appliqué à l'instrument de mesure.
L'accès qui marche est écrit : `journalctl -u nymead`, SANS sudo, l'utilisateur
etm étant dans le groupe adm. Vérifié sur .75.
Et la règle qui va avec : une absence ne s'affirme que dans un flux dont on a
prouvé qu'il coule — citer une ligne qu'on a LUE dans la même fenêtre, puis dire
ce qui n'y est pas. RELEVE_LOTD.md est donné en modèle : il cite la ligne
« 0 créée(s), 0 mise(s) à jour, 2 INCHANGÉE(S) » qu'il a lue, PUIS conclut à
l'absence d'ECS-413 et de commutation. L'absence y porte parce que la présence
l'accompagne.
AUDIT DES RELEVÉS. Repris sur ce critère : aucun ne repose sur un journal vide.
RELEVE_LOTBbis §267 parle d'un dry-run reprepro, pas du journal. Le seul défaut
était dans la PROCÉDURE — TEST_TERRAIN.md prescrivait `sudo journalctl` dans son
helper logs(), celui qu'un opérateur lance pendant un essai, alors que sa ligne
51 était déjà juste. Le document se contredisait ; corrigé.
Consigné aussi : +etm27 posé sur .75 et ce qui y a été vérifié — avec les deux
vérifications qui n'y sont PAS observables et qu'il ne faut pas fabriquer. La
config du banc porte les rangs 1-2-3-4, réparée à la main hier soir ; la
migration se rencontrera sur une box cliente non migrée.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
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