# RELEVÉ — lot B-bis : la PAC entre en configuration Version du paquet vérifié : **`1.15.2+etm15`** · 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. Ici, les quatre > critères du lot ont été vérifiés **sur `.75`** ; ce qui ne l'a pas été est listé au §6, et la > raison y est écrite en toutes lettres. --- ## 0. Le constat d'origine, relevé AVANT le déploiement `.75` tournait encore en **`1.15.2+etm14`**. Les deux frontières, interrogées à la suite : | | `GetLoadConfig` | `GetLoadTelemetry` | |---|---|---| | `chauffe-eau` (relay-router) | ✔ | ✔ | | `pac-terrain` (sg-ready) | **absent** | ✔ (état 2, verrou `minStateHold` 240 s) | **L'écart que le lot referme est donc daté et mesuré, pas rapporté.** `pac-terrain` était pleinement arbitrée — elle portait un verrou, elle publiait un mécanisme, elle apparaissait dans `loads[]` — et restait introuvable dans la configuration. C'est l'unique porteur de la règle de détection publiée à `+etm14`, celle que ce lot retire. --- ## 1. L'ORDRE de migration, exercé dans le bon sens `docs/TEST_TERRAIN.md` §1.d impose de **poser la config sg-ready d'abord**, sur le `+etm14` en cours d'exécution, **puis** d'installer le `+etm15`. La raison n'est pas cosmétique : la suppression du bloc codé en dur est un événement de **compilation**, pas d'exécution — `applySafeState()` (ECS-413) ne peut pas s'appliquer à un objet que le nouveau binaire ne construit jamais. Installer d'abord laisserait une fenêtre où plus personne ne commande K1/K2. **Vérifié sur `.75`.** `SetLoadConfig` des deux charges à 17:14:33, sur `+etm14` : ``` [EnergyArbitrator] sg-ready construit depuis config: "PAC banc" ( 4 états ) [EnergyArbitrator] SgReadyAdapter en dur "pac-terrain" retiré : ses contacts sont désormais commandés par une charge sg-ready de configuration. [EnergyArbitrator] 2 charge(s) pilotée(s) active(s) (config) — 1 créée(s), 1 inchangée(s) ``` C'est exactement la passation prévue par LM-300 : l'exemplaire en dur **cède** les contacts à l'adaptateur de configuration. **K1/K2 n'ont jamais été sans propriétaire.** Le code qui produit cet avertissement disparaît avec `+etm15` — il n'a plus d'objet une fois qu'il n'existe plus d'exemplaire en dur, et c'est précisément pourquoi il fallait l'exercer *avant*. Déploiement ensuite par `etm-powersync-deploy/deploy.sh` (jamais un `dpkg -i` à la main, cf. `AGENTS.md`) : `+etm15` installé, version confirmée, `nymead` redémarré à 17:16:23. --- ## 2. Critère — la reprise d'état au démarrage journalise ce qu'elle a LU (ECS-411 / -411-b) **Vérifié sur `.75`**, journal du nouveau processus (`nymead[57877]`, `+etm15`) : ``` [RelayRouter] chauffe-eau — reprise au démarrage : palier 500 W [correspondance exacte] ; relais lus : {2ebe6be(1000W)=ouvert, {8538782(500W)=fermé, {b033b21(2000W)=ouvert ; somme lue 500 W [SgReadyAdapter] PAC banc — reprise au démarrage : état 2 (~0 W estimés) [correspondance exacte] ; contacts lus : {beaf92e=ouvert, {bf236e6=ouvert ``` **L'exigence portait sur le cas nul, et c'est lui qui est relevé ici.** L'état repris est 2 — l'état neutre, celui que l'ancien code supposait *sans lire*. La ligne existe quand même, et elle publie le relevé **contact par contact** : « contacts lus, tous ouverts » se distingue donc de « reprise non exécutée ». C'est tout l'objet d'ECS-411-b, et c'est le cas qu'un banc au repos produit naturellement. Rejoué une seconde fois à 17:26:21, à la reconstruction de l'adaptateur (§4) : même forme, même relevé. La reprise n'est pas un événement de démarrage de service, c'est un événement de **construction d'adaptateur**. > Les deux autres issues — motif non déclaré, contact injoignable → avertissement `INCERTAIN` — > sont couvertes par `testSgReadyRestartRecovery`, **en test unitaire, pas sur la machine** : > les provoquer au banc demanderait de débrancher un relais câblé. --- ## 3. Critère — `GetLoadConfig` et `GetLoadTelemetry` concordent **Vérifié sur `.75`**, cycle du 17:23 : ``` GetLoadConfig : ['chauffe-eau', 'pac-terrain'] GetLoadTelemetry : ['chauffe-eau', 'pac-terrain'] loads[] inclus dans GetLoadConfig : True orphelins (arbitrés hors config) : AUCUN ``` | `loadId` | mécanisme publié | alloué | décision | |---|---|---|---| | `chauffe-eau` | `relay`, `stageW` 3500 / `maxStageW` 3500 | 3500 W | `SURPLUS_SETPOINT` | | `pac-terrain` | `sgReady`, `state` 4, `estimatedPowerW` 3000 | 3000 W | `SG_FORCED` | **L'inclusion ne vaut que dans ce sens, et l'autre direction a été exercée aussi** (§4) : une charge configurée peut être absente de `loads[]`, et cela se lit **« désactivée »**, jamais « perdue ». --- ## 4. Critère — `enabled: false` passe par l'état sûr, sur matériel réel C'est le troisième point d'attention du lot : `enabled: false` **ne met pas** une charge en sécurité par lui-même ; c'est ECS-413 — la charge qui **sort** d'une configuration vivante — qui la fait passer par `applySafeState()` avant destruction. **Vérifié sur `.75`**, PAC en état 4 (donc les deux contacts fermés) au moment du geste : | | `GetLoadConfig` | `loads[]` | contacts K1/K2 (`Relay4`/`Relay5`) | |---|---|---|---| | avant | `chauffe-eau`, `pac-terrain` | les deux | **fermé, fermé** | | après `enabled: false` | `chauffe-eau`, `pac-terrain` | `chauffe-eau` seule | **ouvert, ouvert** | ``` [SgReadyAdapter] "PAC banc" → état 2 ( 0 W estimés) | "Charge désactivée — mise en état sûr (état 2) avant retrait (ECS-413)" [EnergyArbitrator] 1 charge(s) pilotée(s) active(s) (config) — 1 inchangée(s), 1 retirée(s). ``` Deux choses se lisent ici, et la seconde est facile à manquer : 1. **Les contacts se sont réellement ouverts.** L'écriture a eu lieu, pas seulement le motif. 2. **`loads[]` n'a pas changé dans la seconde qui a suivi le `SetLoadConfig`** — la PAC y figurait encore. Ce n'est pas un défaut : `GetLoadTelemetry` rend ce qui a été **publié** au dernier cycle, il ne recalcule pas (`AGENTS.md`). Elle en est sortie au cycle suivant. Un client qui attend une disparition immédiate se trompe de contrat. Configuration restaurée (`enabled: true`) à 17:26:21 — le banc est laissé dans son état nominal. --- ## 5. Critère — la PAC s'arbitre comme avant le lot, et l'armement à froid EXPIRE C'est le deuxième point d'attention : la reconstruction réarme `minStateHold`. Sans conséquence au banc (300 s), réelle sur une PAC (900 s). Ce qu'il fallait prouver n'est pas que le verrou s'arme — c'est qu'il **n'est pas permanent** (le défaut ECS-412 corrigé côté routeur, où une charge démarrant contacts ouverts ne pouvait plus jamais s'enclencher). **Vérifié sur `.75`**, redémarrage à 17:16:23, `minStateHoldS: 300` : | Cycle | Surplus | Décision `pac-terrain` | |---|---|---| | 17:17 → 17:21 | 5008 → 12185 W | `Verrou minStateHold — PAC banc maintenue état 2` | | **17:22** | 8718 W | **`Surplus abondant — PAC banc forcée (état 4, ~3000 W)`** | | 17:23 | 11394 W | état 4 `INCHANGÉ` | Le verrou a tenu ~5 min 40 puis a **cédé de lui-même**, alors même que le surplus dépassait largement le seuil de forçage dès 17:17. L'armement est donc bien **paresseux et transitoire** : `applyAction()` estampille au premier `now` reçu et le compte à rebours court. **Le waterfall est intact** : `chauffe-eau` (rang 1) se sert la première (3500 W), la PAC (rang 2) voit le reliquat. C'est le comportement d'avant le lot, obtenu désormais par une charge **entièrement configurée**. --- ## 6. Ce qui n'a PAS été vérifié sur la machine 1. **Le repli L2 d'une PAC de configuration** — le trou de sécurité que ce lot referme (une PAC de config recevait un `Setpoint(0)` que `SgReadyAdapter` rejette, et restait en état 4 sous compteur muet). Couvert par `testLoadTelemetryRpc`, qui vérifie l'**état ET les contacts**, pas le motif. **Non rejoué au banc** : provoquer un compteur muet demande de figer le simulateur >90 s, ce qui suspend toute la planification et n'était pas nécessaire au vu de la couverture unitaire. À joindre à la prochaine campagne §5 de `TEST_TERRAIN.md`. 2. **Les issues dégradées de la reprise** (motif non déclaré, contact injoignable) — §2. 3. **La polarité des contacts K1/K2 face à une vraie PAC** — inchangé depuis le lot B : aucune PAC n'est sous tension. Voir ci-dessous, c'est le point le plus important de ce relevé. --- ## 7. ⚠️ Les puissances SG-Ready posées au banc ne sont PAS des données d'installation `estimatedPowerW` **{état 3 : 1500 W, état 4 : 3000 W}** et `minStateHoldS: 300` sont **reconduits du bloc codé en dur supprimé**, où ils décrivaient une PAC **inventée** pour faire tourner l'arbitrage. Au 2026-08-25, **aucune PAC n'est sous tension sur K1/K2**. Les reconduire rend §4 de `TEST_TERRAIN.md` calculable — **pas véridique**. Ce n'est pas cosmétique : `estimatedPowerW` sert de base au **recrédit budget** et au **choix d'état**, si bien qu'une estimation fausse ne décale pas seulement la PAC, elle décale l'allocation de **tout le waterfall en aval**. Le champ porte son nom — une estimation, jamais un engagement — mais une estimation reste censée viser la bonne machine. **À remplacer par les valeurs de la machine réellement câblée** (plaque signalétique, puis mesure sous les deux régimes) avant toute conclusion transposable à une installation. Marqué comme tel dans `docs/TEST_TERRAIN.md` §1.d. --- ## 8. Suites de tests, et la contrainte d'exécution qui les encadre **La suite de simulation ne doit PAS être lancée en un seul processus.** Chaque test instancie un serveur nymea complet et rien n'est rendu entre deux ; en un processus, le binaire atteint 7-8 Gio de RSS et l'OOM killer emporte la session graphique (constaté deux fois le 2026-08-25). Protocole retenu : **un processus par test**, chacun encadré par `systemd-run --user --scope -p MemoryMax=2G -p MemorySwapMax=0`. | Suite | Processus | Assertions | Échecs | Pic RSS | |---|---|---|---|---| | `simulation` | 24 | 85 | 0 | 61-66 Mo (413 Mo pour `run`, data-driven) | | `charging` | 15 | 74 | 0 | 62-63 Mo | | `spotmarket` | 7 | 44 | 0 | 63-64 Mo | Build : **0 erreur / 0 avertissement**, local (`-Wall -Werror -Wextra`) et cross-arm64 (seules subsistent les notices `dh` propres au cross-build). Deux défauts trouvés en montant ce harnais, corrigés ici : - **`JournalCapture` se chaînait sur elle-même.** `qInstallMessageHandler()` rend le handler courant ; à la **deuxième** capture vivante, ce handler est `collecte()` elle-même. Chaque message se rappelait donc indéfiniment en concaténant dans `m_texte` — récursion infinie à croissance quadratique. C'est ce que le plafond de 2 Gio a tué, et c'est un bien meilleur candidat qu'une accumulation graduelle pour expliquer un OOM **déterministe** (`total-vm` identique à 200 ko près sur les deux occurrences). Une seule capture vivante ne déclenchait rien : le seul test qui en ouvre deux est celui ajouté par ce lot. - **`tm` et `owner` non initialisés** (`simulation.cpp`, trois sites) : `freshSetup()` peut sortir sur un `QVERIFY` en échec sans les renseigner, et ils étaient déréférencés juste après. Initialisés par l'appelant, vérifiés après l'appel — un échec de test lisible au lieu d'un comportement indéfini. **Instabilité d'infrastructure, signalée et non corrigée.** Le harnais nymea rouvre un port **fixe** (4847, plugin mock système) à chaque cycle `cleanupTestCase()`/`initTestCase()` ; les tests qui réinstancient un serveur en cours de route échouent donc par intermittence sur `Failed to open HTTP port. Port in use?`. Observé une fois sur `testEcsRelayTopologies`, qui passe 7 fois sur 7 en isolation. Le harnais rejoue un test en échec **une** fois et l'étiquette `INSTABLE` plutôt que de masquer. Même famille que la cause de fond ci-dessous : une ressource non rendue à la destruction d'une instance de serveur. --- ## 9. Ce qui reste ouvert 1. **La mémoire non rendue entre deux tests** — pourquoi la destruction d'une instance de serveur ne rend ni la mémoire ni le port 4847 (`cleanup()`, `cleanupTestCase()`, durée de vie des objets de `freshSetup()`). **Hors de ce lot, à traiter séparément.** Le correctif de `JournalCapture` retire ce qui faisait tomber la VM, mais ne dit rien de l'accumulation elle-même — et cette question n'a **pas** été rejouée en un seul processus, la consigne l'interdisant. 2. **Les puissances SG-Ready du banc** — §7. C'est le point à clore en premier. 3. **Le repli L2 d'une PAC de configuration, au banc** — §6.1. 4. Les points 2 à 4 de `docs/RELEVE_LOTB.md` §9 restent tels quels : silences de refus (7-c/7-d), critère 3, et les autres. --- ## 10. Publication — où l'agent app trouve le paquet | | | |---|---| | Version publiée | **`1.15.2+etm15`** (arm64) | | Canal | **`powersync-nightly`** — inchangé depuis le lot B ; `stable` et `testing` ne contiennent toujours 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/` — pool et index vérifiés après rsync | | Banc `.75` | `1.15.2+etm15` installé, `nymead` actif | ``` deb [signed-by=/etc/apt/keyrings/powersync.gpg] https://repos.etm-powersync.fr powersync-nightly main ``` **Publication en AJOUT SEUL**, comme au lot B : `reprepro --keepunreferencedfiles`. Sans cette option, `+etm14` aurait perdu sa dernière référence et son binaire aurait été retiré du pool. `+etm12` et `+etm14` y sont toujours, local et public. La synchronisation est en **ajout pur** — le dry-run préalable ne montrait aucune ligne de suppression, et la passe `--delete` a été retirée du script le 2026-08-13. **Le paquet posé sur le banc et le paquet publié sont le MÊME.** Le premier build ne portait pas les dernières entrées de changelog ; l'arbre a donc été figé, reconstruit, et le `.so` des deux paquets comparé — `sha256` identique. Le banc a été redéployé avec le paquet reconstruit, celui qui est au pool. Rien n'a été publié qui n'ait tourné sur `.75`. > `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 — même limite qu'au lot B. ### Ce que l'agent app doit retenir 1. **La règle de détection de `+etm14` est sans objet** : plus aucun `loadId` de `loads[]` n'est absent de `GetLoadConfig`. Une implémentation existante peut rester en place, elle ne se déclenchera plus. **Aucun `configurable: false` n'existe** — ne pas l'attendre. 2. **L'inclusion est à sens unique.** Une charge de `GetLoadConfig` absente de `loads[]` est **désactivée** (`enabled: false`), pas perdue. 3. **`GetLoadTelemetry` rend le dernier cycle PUBLIÉ, il ne recalcule pas.** Après un `SetLoadConfig`, `loads[]` ne reflète le changement qu'au cycle suivant (§4). 4. **`pac-terrain` est désormais configurable** : renommable, classable (`domain`), rangeable, désactivable — un écran de réglage lui est dû comme à toute autre charge.