Patrick Schurig 0765672e62 feat(config): lot B-bis — la PAC entre en configuration — 1.15.2+etm15
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
2026-08-25 18:35:04 +02:00

30 KiB
Raw Blame History

RELEVÉ — lot B : frontière de télémétrie + o:domain

Version du paquet vérifié : 1.15.2+etm13, republiée en 1.15.2+etm14 (binaire fonctionnellement identique ; seuls changelog, contrat et tests diffèrent — cf. §8-bis) · Machine : .75 (hems, arm64, nymead 1.15.2+202606191336~trixie1) · Date : 2026-08-25.

Ce qui compte dans ce document, c'est la colonne « où ». Un extrait issu d'un test unitaire ne vaut pas vérification machine, et il est annoncé comme tel. Une seule exigence n'a PAS été vérifiée sur .75 — le critère 3 — et la raison est écrite en toutes lettres au §3, avec la procédure exacte pour la clore.


0. Le constat d'origine, rejoué AVANT le déploiement

Sur .75, paquet 1.15.2+etm9 alors installé, abonnement NymeaEnergy par WebSocket (ws://localhost:4444, auth désactivée au banc) :

2026-08-25T11:18:24+01:00
# abonnement: {"enabled": true, "namespaces": ["NymeaEnergy"]}
# ecoute de 90 s terminee
# 0 notification(s) au total
2026-08-25T11:19:54+01:00

L'arbitre était actif pendant cette même fenêtre — journal nymead, 11:19:00 :

[Arbitre] "chauffe-eau" → "Surplus PV 10907 W — chauffe-eau consigne 3500 W (palier)" | …
[Arbitre] "pac-terrain" → "Surplus abondant 10407 W — PAC banc forcée (état 4, ~3000 W)" | …

Constat reproduit : arbitre qui décide, frontière RPC muette. C'est le point de départ du lot, et il est désormais daté et mesuré plutôt que rapporté.

Déploiement : 1.15.2+etm13 installé à 11:21:35, nymead redémarré à 11:21:45. Reprise ECS-411 au démarrage, lue dans le journal :

[RelayRouter] chauffe-eau — reprise au démarrage : palier 3500 W [correspondance exacte] ;
  relais lus : {2ebe6be(1000W)=fermé, {8538782(500W)=fermé, {b033b21(2000W)=fermé ; somme lue 3500 W

1. Critère 1 — le test du constat, rejoué APRÈS

Vérifié sur .75. Même geste, même durée, paquet etm13 :

Fenêtre Durée Notifications NymeaEnergy.*
11:18:24 → 11:19:54 (etm9) 90 s 0
11:22:07 → 11:23:37 (etm13) 90 s 2
11:25:31 → 11:28:32 (etm13) 180 s 4
11:31:44 → 11:39:45 (etm13) 480 s 13

Cohérence charge utile ↔ journal, même fenêtre. Exemple le plus instructif — cycle du 11:33:00, où deux verrous mordent en même temps.

Notification reçue :

{ "timestamp": "2026-08-25T10:33:00Z", "degradedMode": false,
  "budget": { "surplusW": -250.5, "evReservedW": 0, "recreditedW": 3000,
              "allocatedW": 3000, "remainingW": -250.5 },
  "loads": [
    { "loadId": "chauffe-eau", "allocatedW": 1500, "available": true,
      "lock": { "kind": "minOn", "remainingS": 1 },
      "mechanism": { "kind": "relay", "stageW": 1500, "maxStageW": 3500 },
      "decision": { "code": "LOCK_MIN_ON", "params": { "appliedW": 1500, "budgetW": 1250 } } },
    { "loadId": "pac-terrain", "allocatedW": 1500, "available": true,
      "lock": { "kind": "minStateHold", "remainingS": 181 },
      "mechanism": { "kind": "sgReady", "state": 3, "estimatedPowerW": 1500 },
      "decision": { "code": "LOCK_MIN_STATE_HOLD", "params": { "state": 3 } } } ] }

Journal nymead du même instant :

11:33:00  [Arbitre] "chauffe-eau" → "Verrou minOn — chauffe-eau maintenue à 1500 W
                                     (puissance engagée, budget 1250 W) ; le résidu en tient compte"
11:33:00  [Arbitre] "pac-terrain" → "Verrou minStateHold — PAC banc maintenue état 3 (court-cycling PAC)"

appliedW: 1500 / budgetW: 1250 ↔ « maintenue à 1500 W … budget 1250 W » : le code et ses paramètres, et la phrase rendue depuis eux, disent la même chose au mot près. C'est la preuve que le rendu n'est pas une seconde écriture.

Identité du budget vérifiée sur la machine : −250,5 + 3000 − 3000 = −250,5. ✔

Et ce chiffre rend visible l'artefact de banc. Le compteur SunSpec est aveugle aux résistances câblées sur les GPIO, et les relais n'exposent pas de currentPower : le recrédit crédite donc le nominal commandé. On lit ici, en clair, surplusW = −250 W (le site soutire) avec recreditedW = 3000 W — le double comptage documenté dans AGENTS.md § « Correction B », désormais lisible dans la charge utile au lieu d'être à redéduire. Sans le champ recreditedW, un client verrait « surplus −250 W, alloué 3000 W » et conclurait à un bug.

Codes observés sur la machine (6 des 19) : SURPLUS_SETPOINT, SURPLUS_INSUFFICIENT, LOCK_MIN_ON, SG_FORCED, SG_RECOMMENDED, SG_NORMAL, LOCK_MIN_STATE_HOLD.

Volume mesuré sur .75 (demandé au brief) : 13 notifications / 480 s, 9 411 octets de charge utile cumulée, soit ≈ 1,2 ko/min et ≈ 1,7 Mo/jour pour UN client abonné, avec 2 charges. Négligeable pour la cible RPi Zero 2 W.


2. Critère 2 — snapshot ≡ notification

Vérifié sur .75 (paquets etm13 puis etm14). GetLoadTelemetry appelé immédiatement après une notification :

# ecoute de 5 s terminee (arretee a la 1re notification, pour interroger JUSTE APRES)
# snapshot == derniere notification ? OUI

Comparaison faite sur le JSON complet, clés triées. Le Get ne recalcule pas : il rend l'objet publié. Un recalcul donnerait des remainingS rafraîchis sous un timestamp périmé — un état qui n'a jamais existé.

« Juste après » est une condition du critère, pas une formule. Une première mesure a répondu NON : l'outil écoutait 100 s puis interrogeait, et un cycle d'arbitrage s'était intercalé — un cycle sous le seuil, donc sans notification. Le Get rendait alors, légitimement, un instantané plus récent (11:16:00Z contre 11:15:00Z).

Ce n'était pas un défaut du moteur mais de la mesure, et il enseigne une propriété qu'il vaut mieux écrire que redécouvrir : le cache suit CHAQUE cycle, y compris ceux qui ne notifient pas. L'égalité « snapshot ≡ notification » vaut donc à l'instant d'une notification, pas indéfiniment — c'est même souhaitable : un client qui interroge apprend l'état le plus récent, pas le dernier qu'on a jugé digne d'un réveil. L'outil /tmp/nymea_rpc.py s'arrête désormais à la première notification avant d'interroger.

Également couvert en test (testLoadTelemetryRpc) sous les deux angles : QCOMPARE du Get avec la notification, et l'assertion inverse — après une variation de 10 W qui ne notifie pas, le Get a bien avancé d'un cycle.


3. Critère 3 — reprise à froid du mode dégradé ⚠️ NON vérifié sur .75

Ce qui est vérifié sur .75 : les deux portes existent et concordent.

# GetLoadTelemetry.degradedMode      = false
# GetChargingSchedules.degradedMode  = False        ← champ ABSENT avant ce lot

Le défaut corrigé — degradedMode poussé en notification seule, illisible par un client connecté après la bascule — est donc fermé et constaté sur la machine.

Ce qui n'est PAS vérifié sur .75 : l'épisode dégradé lui-même. Le déclencher demande de faire taire le compteur plus de 90 s. Le rootmeter du banc est un Fronius SunSpec réel sur 192.168.1.211:502 — le simulateur etm-powersync-hems-sim, une infrastructure partagée, hors de ce dépôt. L'arrêter n'entrait ni dans le périmètre du lot ni dans ce qui m'a été demandé, et le laisser arrêté par accident coûterait plus cher que la vérification ne rapporte.

Vérifié en SIMULATION uniquement (testLoadTelemetryRpc), dans les deux sens :

  • bascule → notification degradedMode: true, sans clé budget (planification suspendue : publier des zéros se lirait « arbitrage exécuté, rien à allouer ») ; chaque charge porte decision.code == "DEGRADED_L2" et allocatedW == 0 ;
  • les deux portes relues après la bascule : GetLoadTelemetry.degradedMode == true et GetChargingSchedules.degradedMode == true ;
  • sortie → notification immédiate degradedMode: false par les deux portes, sans attendre le cycle suivant.

Pour clore le critère au banc, trois gestes (≈ 4 min) :

# 1. faire taire le simulateur SunSpec plus de 90 s
#    (sur l'hôte docker du simulateur, PAS sur .75)
docker compose stop <service-fronius> ; sleep 100
# 2. lire les DEUX portes depuis .75
ssh etm@192.168.1.75 'python3 /tmp/nymea_rpc.py snapshot 30 NymeaEnergy'
# 3. relancer le simulateur, vérifier la sortie du mode dégradé
docker compose start <service-fronius>
ssh etm@192.168.1.75 'python3 /tmp/nymea_rpc.py snapshot 30 NymeaEnergy'

Attendu : degradedMode: true, pas de clé budget, decision.code == "DEGRADED_L2" sur les deux charges, et GetChargingSchedules.degradedMode à true ; puis retour à false.


4. Critère 4 — arbitre figé détectable

Vérifié sur .75. Le battement de cœur republie l'instantané sans que le timestamp avance. Extrait de la fenêtre de 480 s (t = seconde d'écoute) :

t timestamp publié origine
75,1 s 10:33:00Z cycle
135,1 s 10:33:00Z battement — horodatage INCHANGÉ
135,1 s 10:34:00Z cycle suivant
195,1 s 10:34:00Z battement — horodatage INCHANGÉ
315,1 s 10:37:00Z cycle
375,3 s 10:37:00Z battement — horodatage INCHANGÉ

Un client reçoit donc toujours un état, et c'est le timestamp qui lui dit si l'arbitre décide encore. Sans battement, « installation stable » et « moteur arrêté » sont le même silence — c'est la règle 7-c appliquée à la frontière RPC.

Le battement ne double pas une notification fraîche : chaque publication relance son décompte, d'où la cadence observée d'environ une notification par 45 s (cycle 60 s + battement 60 s décalé), et non deux par minute.

Le seuil, vérifié en test (testLoadTelemetryRpc) : une variation de 10 W ne notifie pas, alors que le Get a bien avancé — l'état interne est à jour sans réveiller les abonnés. Sur la machine, le même effet se lit au cycle 11:28:00 : le surplus passe de 6321 W à 4473 W sans qu'aucune allocation ne bouge (allocatedW reste 6500 W) → aucune notification.


5. Critère 5 — zéro langue naturelle dans la charge utile

Vérifié sur .75 (inspection des 13 charges utiles capturées) et mécaniquement en test.

Le test testLoadTelemetryRpc parcourt récursivement la charge utile et exige de toute valeur de type chaîne : ASCII strict et sans espace. Une phrase française en porterait forcément (accents et/ou espaces).

Inventaire des littéraux réellement émis (extrait du code, pas de la doc) :

allocatedW available budget chargingEnabled currentA decision degradedMode estimatedPowerW
evReservedW evcharger faultCode kind loadId loads lock maxPowerW maxStageW mechanism minOff
minOn minStateHold percent phaseCount pluggedIn recreditedW relay remainingS remainingW
setpointW sgReady stageW state surplusW timestamp variable
THING_MISSING  UNUSABLE_ENCODING  WRITE_FAILED

plus les 19 codes de DecisionCode (EV_SURPLUS … SAFE_STATE_SG_READY). Tous ASCII, tous des codes.

Le libellé de la charge n'entre pas dans la charge utile : « chauffe-eau », « PAC banc » n'apparaissent nulle part dans le JSON — seulement les loadId. Le libellé est substitué au rendu, côté journal, par l'appelant qui le connaît.


6. Critère 6 — journal INCHANGÉ

Prouvé trois fois, par comparaison et non par relecture.

(a) Sur .75 — mêmes situations avant/après le déploiement, formes identiques :

Situation etm9 (avant) etm13 (après)
consigne servie [Arbitre] "chauffe-eau" → "Surplus PV 10907 W — chauffe-eau consigne 3500 W (palier)" [Arbitre] "chauffe-eau" → "Surplus PV 8445 W — chauffe-eau consigne 3500 W (palier)"
PAC forcée "Surplus abondant 10407 W — PAC banc forcée (état 4, ~3000 W)" "Surplus abondant 7945 W — PAC banc forcée (état 4, ~3000 W)"
commutation relais [RelayRouter] "chauffe-eau" → consigne 3000 W → palier 3000 W | "…" [RelayRouter] "chauffe-eau" → consigne 2500 W → palier 2500 W | "…"

Seuls les nombres diffèrent (le profil PV n'est pas le même à deux minutes d'écart). Cinq motifs supplémentaires ont été observés inchangés au fil de la descente du profil : Surplus insuffisant (247 W) — chauffe-eau à 0 W, … recommandée (état 3, ~1500 W), … en normal (état 2, non pilotée), Verrou minOn — … (puissance engagée, budget 1250 W) ; le résidu en tient compte, Verrou minStateHold — … (court-cycling PAC).

(b) En simulation, diff binaire — les 1 726 lignes de motif produites par la suite complète sont identiques avant et après la refonte (seuls les ThingIds, aléatoires par exécution, sont normalisés) :

diff baseline-motifs.norm after-motifs.norm  →  aucun écart

(c) Table d'or — testDecisionReasonRendering vérifie les 19 codes du catalogue contre la phrase attendue, mot pour mot, et échoue si un code est ajouté sans ligne de rendu. Un test « motif non vide » n'aurait rien vu ici : c'est exactement la leçon d'ECS-309.

Un ajout assumé au journal : le retour idempotent du RelayRouter et celui du SgReadyAdapter étaient muets (règle 7-c). Ils produisent désormais une ligne … INCHANGÉ (aucune commutation), au même niveau que la branche qui commute. Ce n'est pas une modification d'un motif existant : c'est un silence comblé. Coût mesuré : une ligne par charge et par cycle, soit ≈ 2 900 lignes/jour pour les 2 charges du banc.


7. Critère 7 — o:domain

Vérifié sur .75, sur la charge réelle du banc, puis configuration restaurée à l'identique :

# config d'origine                 → domain = ""      (charge NON classée)
# hors énumération ("chauffe-eau") → EnergyErrorInvalidParameter
# domain="ecs"                     → EnergyErrorNoError
# relu                             → domain = 'ecs'
# aller-retour Get→Set             → EnergyErrorNoError
# relu après aller-retour          → domain = 'ecs'
# restauration                     → EnergyErrorNoError | domain = ''
# config identique à l'origine ?   → OUI

Une découverte de forme, faite au banc et corrigée dans la doc et le test. GetLoadConfig sérialise via le méta-objet, donc une charge non classée revient avec "domain": "", et non avec la clé absente — exactement comme "sgReady": {} ou "powerLevels": [] sur une charge qui ne les porte pas. C'est le corollaire LM-302, et c'est ce qui rend l'aller-retour possible. La forme persistée (fichier JSON), elle, omet la clé. La documentation le dit maintenant, et l'assertion du test est exacte plutôt que permissive : une tolérance masquerait un changement de forme, or c'est la forme que l'app consomme.

Aucun effet sur l'arbitrage, vérifié en test (charge servie à l'identique avec et sans domaine) et sur la machine : les écritures de configuration ci-dessus n'ont produit aucune commutation de relais — ECS-412 conserve l'adaptateur, le domaine n'étant pas du « matériel ».


8. Critère 8 — suites de tests, paquet, déploiement

Élément Où Résultat
Suite simulation sandbox amd64 37/37 (34 avant le lot + 3 ajoutés)
Suite charging sandbox amd64 46/46
Suite spotmarket sandbox amd64 32/32
Suites en build debug (Q_ASSERT_X du validateur JSON-RPC actifs) sandbox amd64 37/37 — le schéma déclaré correspond donc à la charge utile émise, retours et notifications
Build amd64 sandbox 0 erreur, 0 avertissement neuf
Cross-build arm64 LXD build-cross-arm64 0 erreur, 0 avertissement de compilation
Paquet — powersync-energy-plugin-nymea_1.15.2+etm13_arm64.deb, Architecture: arm64, Depends: libnymea-energy (>= 1.15.2+202606191336~trixie1) — aligné sur la box, Provides/Conflicts/Replaces: nymea-energy-plugin-nymea (Voie B)
Déploiement .75 installé 11:21:35, nymead redémarré 11:21:45, dpkg -l → ii … 1.15.2+etm13
Doxygen (./tools/gen-doc.sh) sandbox 10 avertissements — inchangé, aucun n'appartient à ce lot

Rollback disponible sans rebuild : /var/lib/etm-deploy/ contient toujours …_1.15.2+etm9_arm64.deb (la version qui tournait avant), et le compte etm a la règle NOPASSWD qui permet dpkg -i + systemctl restart nymead.


8-bis. Trois points repris après première revue

(a) deploy.sh — la faille de déploiement, fermée sur la machine

Enquête. Le script incriminé n'existait dans aucun dépôt : il vivait dans /tmp sur la box (/tmp/etm-deploy-etm12.sh, /tmp/etm-deploy-and-capture.sh, retrouvés dans ~/.bash_history de .75), donc hors de tout suivi de version — exactement la cause racine déjà documentée pour publish-to-repo.sh dans DEPLOY.md §7.4-bis. Ce n'est pas un « deploy.sh à corriger », c'est un script à faire entrer au dépôt. Il est entré : etm-powersync-deploy/deploy.sh.

Le docs/TEST_TERRAIN.md de CE dépôt portait aussi la recette fautive — scp … /tmp/ puis sudo dpkg -i /tmp/…_*_arm64.deb — avec le même glob. Remplacée.

Les trois causes, et il fallait les trois : un glob sur un répertoire qui accumule · dpkg -i autorise la rétrogradation par défaut (dpkg --force-help : [*] downgrade, vérifié sur .75) · aucune relecture de la version après coup.

Les quatre verrous, vérifiés sur .75 :

Verrou Preuve
Archivage AVANT dépôt (archive/, sous-répertoire hors de portée de *.deb) le répertoire de dépôt contient désormais exactement 1 paquet ; les 4 périmés (etm4/8/9/10) sont dans archive/
Installation par NOM → dpkg -i /var/lib/etm-deploy/powersync-energy-plugin-nymea_….deb
Contrôle AVANT tentative de repose d'etm9 sur etm13 → RÉTROGRADATION REFUSÉE, code de sortie 1
Contrôle APRÈS + état du service ✓ version installée confirmée puis ✓ nymead actif

Le verrou qui compte le plus est l'archivage, et ce n'est pas celui qu'on attend. La règle sudoers de la box est exactement /usr/bin/dpkg -i /var/lib/etm-deploy/*.deb : elle porte le glob, et la resserrer demanderait root sur chaque box (le nom de fichier change à chaque version, on ne peut pas le figer). L'archivage la rend inoffensive : avec un seul .deb dans le répertoire, le glob ne peut désigner qu'un fichier — y compris pour un geste fait à la main, hors script. C'est le seul des quatre verrous qui protège ce que le script ne contrôle pas.

dpkg --refuse-downgrade a été écarté volontairement : toute option supplémentaire fait échouer la correspondance sudoers, et le déploiement ne passerait plus sans mot de passe. Le contrôle vit donc dans le script, où il est en prime visible et journalisé.

Ce que .75 est, et pourquoi ça change l'enjeu. C'est le banc de bêta-test : on y pose un paquet avant de le publier, pour décider s'il mérite d'aller au canal. Conséquence — sur le banc, un paquet pas encore publié n'existe qu'à deux endroits, le répertoire de dépôt de la box et le pool local de build. Ni au dépôt APT, ni chez un client, ni dans aucun git : un .deb ne se versionne pas. L'écraser est irréversible. C'est cela, plus que le ramassage multiple, que le verrou d'archivage protège.

Ma première rédaction se rassurait à tort, en constatant que +etm12 était au canal donc que « .75 était réparable à tout moment ». Le journal dpkg de la box donne la chronologie exacte, et elle dit l'inverse de ce que j'en avais tiré :

2026-08-13 19:48:59  upgrade  …:arm64  1.15.2+etm11 → 1.15.2+etm12    ← posé sur le BANC
2026-08-13 20:48:58                                                    ← publié au pool, 1 h plus tard
2026-08-25 10:50:22  upgrade  …:arm64  1.15.2+etm12 → 1.15.2+etm10  ┐
2026-08-25 10:50:28  upgrade  …:arm64  1.15.2+etm10 → 1.15.2+etm4   │ quatre « upgrade »
2026-08-25 10:50:32  upgrade  …:arm64  1.15.2+etm4  → 1.15.2+etm8   │ en ONZE secondes
2026-08-25 10:50:33  upgrade  …:arm64  1.15.2+etm8  → 1.15.2+etm9   ┘

Deux enseignements. Le premier : l'état final est celui du dernier configuré, pas du plus récent — dpkg ne trie pas, il déballe dans l'ordre où il rencontre les fichiers. Le second, et c'est celui qui compte : entre 19:48 et 20:48 le 13 août, +etm12 n'existait que sur le banc. Le risque ne dépend pas de la chance qu'a eue une version particulière — la plupart des paquets de banc ne quittent jamais cette fenêtre, puisqu'ils sont écartés, c'est leur raison d'être.

Pour mémoire, l'état du dépôt : +etm12 est bien au canal powersync-nightly avec son binaire en pool (reprepro list, horodatage 13 août 20:48) ; stable et testing ne contiennent aucun powersync-energy-plugin-nymea.

Les deux chemins de déploiement sont désormais écrits — banc par deploy.sh, parc par apt — dans BUILD-WORKFLOW.md §8 (qui ne décrivait que le second, laissant le premier s'improviser à la main) et DEPLOY.md §8.

(b) pac-terrain — d'où elle vient, et ce que ça change pour l'app

Vérifié sur .75, verdict sans ambiguïté : c'est bien l'exemplaire CODÉ EN DUR.

Preuve Constat
Relais commandés beaf92e1-… → Relay4, bf236e64-… → Relay5 — exactement K1/K2 d'energypluginnymea.cpp:66-76
Estimations {3: 1500 W, 4: 3000 W} — les valeurs codées en dur, retrouvées telles quelles dans mechanism.estimatedPowerW
minStateHoldS 300 s, cohérent avec les lock.remainingS observés (181 s, 300 s)
GetLoadConfig une seule charge : chauffe-eau — aucune entrée sg-ready
Code registerSgReadyAdapter() n'a qu'un appelant en production : energypluginnymea.cpp:75

Conséquence directe sur ce lot, portée au contrat. GetLoadTelemetry publie deux charges, GetLoadConfig une. loads[] n'est donc pas en correspondance 1:1 avec la configuration : pac-terrain est pleinement arbitrée — elle consomme du budget, porte des verrous, apparaît dans la télémétrie — et reste inconfigurable : ni renommage, ni rang, ni domain, ni enabled: false.

C'est écrit dans INTERFACE.md, section GetLoadTelemetry, avec la règle de détection pour le client : un loadId de loads[] sans équivalent dans GetLoadConfig est une charge non configurable ; afficher le loadId, la traiter comme non classée, ne proposer aucun écran de réglage. Objectif : que l'agent app prévoie ce cas au lieu de le découvrir.

Aucun champ n'a été ajouté à la charge utile pour le signaler. Un configurable: false serait utile — mais il préjuge de la réponse à la question ci-dessous, et deviendrait du poids mort si la PAC entre en configuration. La règle de détection est dérivable sans lui.

RÉPONDU depuis (2026-08-25) : voie 2, réalisée par le lot B-bis 1.15.2+etm15. Ce qui suit reste tel qu'écrit au lot B — c'est l'état des lieux qui a servi à trancher, pas une question encore ouverte. Suite : docs/RELEVE_LOTBbis.md.

❓ QUESTION OUVERTE — à trancher, pas par moi. Deux issues, exclusives :

  1. L'exposer comme charge normale — assumer qu'une charge arbitrée peut être hors configuration, et ajouter alors configurable: false à la charge utile pour que l'app n'ait pas à le déduire par différence d'ensembles.
  2. La faire disparaître au profit d'une vraie config sg-ready — techniquement possible depuis LM-300 : le mécanisme est configurable, rebuildLoadAdapters() sait déjà retirer l'exemplaire en dur quand une charge de configuration revendique ses contacts (energyarbitrator.cpp, bloc revendiquesParConfig). Il ne resterait qu'à écrire la config du banc et à supprimer le bloc energypluginnymea.cpp:66-76. Effet de bord à mesurer : la reconstruction réarme les verrous (ECS-412), donc minStateHold repart à 900 s sur une PAC réelle.

AGENTS.md la classait déjà comme dette (« reste codé en dur : le SgReadyAdapter du banc »), avec une précondition levée depuis : le Q_ASSERT(m_stateRelays.contains(2)) a disparu au profit du refus explicite m_usable. La voie 2 n'est donc plus bloquée.

(c) runtimeView() — substantielle, prouvé par mutation

La pure virtuelle force à RÉPONDRE, pas à répondre quelque chose. Un return LoadRuntimeView{}; compilerait, passerait le dispatch, et sortirait de la télémétrie sans bruit — le client verrait une charge sans mécanisme ni verrou, indistinguable d'une charge libre. Le compilateur ne voit pas ça.

Second verrou posé : testRuntimeViewIsSubstantial(), sur les quatre adaptateurs. Chacun est amené dans un état non trivial avant lecture, et les valeurs attendues sont choisies pour qu'aucun défaut plausible ne passe par accident :

Adaptateur Vue exigée
RelayRouter relay · stageW = 2500 (palier réellement appliqué) · maxStageW = 2500 · lock{minOn, 255 s} à t+45 s sur minOnS = 300 · et le verrou expire à t+301 s
EtmVariableLoadAdapter variable · setpointW = 3000 · maxPowerW = 4000 · percent = 75,0 (dérivé — ni 0, ni 100, ni la consigne) · aucun verrou, asserté et non toléré · THING_MISSING sur un thing absent
SgReadyAdapter sgReady · state = 3 (le défaut est 2) · estimatedPowerW = 1500 · lock{minStateHold, 780 s} · expire à t+901 s · UNUSABLE_ENCODING sur un encodage sans état 2
EvAdapter evcharger + ses 4 clés — hors charge utile en beta, mais écrit dès maintenant : en 3g il n'y aura rien à écrire

Plus un verrou générique sur la liste des quatre : mechanismKind non vide, mechanism non vide, et au moins deux clés — une charge utile réduite au seul discriminant serait une vue vide déguisée.

Preuve que le test mord (un test qui passe sans pouvoir attraper le défaut ne vaut rien). Deux mutants introduits puis retirés :

Mutant Résultat
EtmVariableLoadAdapter::runtimeView() → return LoadRuntimeView(); FAIL
SgReadyAdapter::runtimeView() → return LoadRuntimeView(); FAIL

Arbre vérifié propre après retrait (grep -rn MUTANT energyplugin/ → rien ; git diff sans trace).

Où la substance est constatée sur la MACHINE : la vue sgReady de pac-terrain et la vue relay de chauffe-eau sont lues telles quelles dans les charges utiles capturées au §1 — {kind: sgReady, state: 3, estimatedPowerW: 1500} avec lock{minStateHold, 181 s}, et {kind: relay, stageW: 1500, maxStageW: 3500} avec lock{minOn, 1 s}. EtmVariableLoadAdapter n'a pas d'instance sur .75 — aucun matériel réel n'implémente encore l'interface etmvariableload (AGENTS.md, réserve connue) : sa vue n'est vérifiée qu'en test, et c'est dit ici plutôt que sous-entendu.


9. Ce qui reste ouvert

  1. Critère 3 sur la machine — §3 ci-dessus, procédure incluse. 1-bis. Sort de pac-terrain — TRANCHÉ le 2026-08-25 : voie 2. La PAC entre en configuration, le bloc codé en dur est supprimé, aucun configurable: false n'est ajouté (il n'aurait plus de porteur) et la règle de détection d'INTERFACE.md est retirée. Réalisé par le lot B-bis (1.15.2+etm15) — relevé de vérification : docs/RELEVE_LOTBbis.md.
  2. Silences de REFUS (règle 7-c/7-d), signalés et non corrigés : quatre refus rendent la main sans une ligne alors que l'application correspondante est en qCInfo — charge en défaut dans RelayRouter, EtmVariableLoadAdapter et SgReadyAdapter, et surtout SgReadyAdapter::m_usable == false (encodage sans état 2 → plus aucune commande depuis la construction, sans aucune annonce à sa naissance). Le défaut est désormais publié en continu dans faultCode, mais l'asymétrie de journal reste. À traiter avec ECS-414.
  3. docs/OPTIMIZER_PROTOCOL.md ne documente pas PlanBudget ni ILoadAdapter::runtimeView() — comme il ne documentait déjà pas LoadAction::force, telemetry.minState/maxState, degradedMode ni la forme relay-router. INTERFACE.md, lui, est à jour de ce lot.
  4. L'EV est hors de loads[] en beta : décidé par l'amont avant le waterfall (décision B), sa puissance est réservée dans budget.evReservedW. Il y entrera avec 3g — EvAdapter::runtimeView() est déjà écrit pour ce jour-là.

10. Publication — où l'agent app trouve le paquet

Version publiée 1.15.2+etm14 (arm64)
Canal powersync-nightly — c'est là que vivait +etm12 ; stable et testing ne contiennent aucun powersync-energy-plugin-nymea
Dépôt local etm-powersync-dev:/mnt/builddisk/apt-repo (reprepro list powersync-nightly powersync-energy-plugin-nymea)
Public 192.168.1.241:/var/www/html/repos.etm-powersync.fr/ — index et pool vérifiés après rsync
deb [signed-by=/etc/apt/keyrings/powersync.gpg]     https://repos.etm-powersync.fr powersync-nightly main

Publication en AJOUT SEUL. reprepro a été invoqué avec --keepunreferencedfiles : sans cette option il aurait supprimé du pool le binaire d'+etm12, qui venait de perdre sa dernière référence — c'est-à-dire qu'il aurait causé la perte que l'incident du §8-bis (a) décrivait à tort comme déjà survenue. +etm11 et +etm12 sont toujours en pool, local et public.

repos.etm-powersync.fr n'est pas résolu depuis etm-powersync-dev : la vérification a été faite sur le serveur (192.168.1.241), pas par HTTPS. Le fichier et l'index y sont ; le chemin HTTPS public n'a pas été exercé depuis cette machine.


11. Outils laissés sur .75

  • /tmp/nymea_rpc.py — client JSON-RPC minimal (WebSocket en clair, port 4444) : call <Namespace.Method> [params-json], watch <s> [namespaces…], snapshot <s> (écoute puis GetLoadTelemetry + GetChargingSchedules, et compare le snapshot à la dernière notification).
  • /tmp/domain_check.py — aller-retour domain avec restauration de la configuration.

/tmp est volatile : ces scripts disparaissent au redémarrage. Leur source est dans le journal de la session, pas dans le dépôt.