# 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 : ```jsonc { "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) : ```bash # 1. faire taire le simulateur SunSpec plus de 90 s # (sur l'hôte docker du simulateur, PAS sur .75) docker compose stop ; 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 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 [params-json]`, `watch [namespaces…]`, `snapshot ` (é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.