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
30 KiB
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. LeGetrendait alors, légitimement, un instantané plus récent (11:16:00Zcontre11: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.pys'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 portedecision.code == "DEGRADED_L2"etallocatedW == 0; - les deux portes relues après la bascule :
GetLoadTelemetry.degradedMode == trueetGetChargingSchedules.degradedMode == true; - sortie → notification immédiate
degradedMode: falsepar 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 compteetma la règleNOPASSWDqui permetdpkg -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.debdans 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-downgradea é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 :
- 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. - 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, blocrevendiquesParConfig). Il ne resterait qu'à écrire la config du banc et à supprimer le blocenergypluginnymea.cpp:66-76. Effet de bord à mesurer : la reconstruction réarme les verrous (ECS-412), doncminStateHoldrepart à 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
- Critère 3 sur la machine — §3 ci-dessus, procédure incluse.
1-bis.
Sort de— TRANCHÉ le 2026-08-25 : voie 2. La PAC entre en configuration, le bloc codé en dur est supprimé, aucunpac-terrainconfigurable: falsen'est ajouté (il n'aurait plus de porteur) et la règle de détection d'INTERFACE.mdest retirée. Réalisé par le lot B-bis (1.15.2+etm15) — relevé de vérification :docs/RELEVE_LOTBbis.md. - 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 dansRelayRouter,EtmVariableLoadAdapteretSgReadyAdapter, et surtoutSgReadyAdapter::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 dansfaultCode, mais l'asymétrie de journal reste. À traiter avec ECS-414. docs/OPTIMIZER_PROTOCOL.mdne documente pasPlanBudgetniILoadAdapter::runtimeView()— comme il ne documentait déjà pasLoadAction::force,telemetry.minState/maxState,degradedModeni la formerelay-router.INTERFACE.md, lui, est à jour de ce lot.- 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 dansbudget.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.frn'est pas résolu depuisetm-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 puisGetLoadTelemetry+GetChargingSchedules, et compare le snapshot à la dernière notification)./tmp/domain_check.py— aller-retourdomainavec 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.