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
551 lines
30 KiB
Markdown
551 lines
30 KiB
Markdown
# 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 <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.
|