Ma rédaction se rassurait à tort : « +etm12 n'est pas perdu, .75 était réparable depuis le dépôt APT à tout moment ». C'est vrai de cette version-là, et faux comme raisonnement. .75 est le banc de BÊTA-TEST : on y pose un paquet AVANT de le publier. 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, et c'est cela, plus que le ramassage multiple, que le verrou d'archivage protège. Le journal dpkg de la box, désormais cité, donne la chronologie qui le montre : +etm12 posé sur le banc le 13 août à 19:48, publié au pool à 20:48 — une heure où il n'existait QUE sur le banc. La plupart des paquets de banc ne quittent jamais cette fenêtre, puisqu'ils sont écartés : c'est leur raison d'être. Le risque ne dépend pas de la chance qu'a eue une version particulière. Le même journal donne la cascade du 25 août : quatre « upgrade » en onze secondes, etm12→etm10→etm4→etm8→etm9. L'état final est celui du dernier configuré, pas du plus récent. Renvoi ajouté vers les deux chemins de déploiement désormais écrits (BUILD-WORKFLOW.md §8, DEPLOY.md §8) — le premier ne décrivait que le parc, laissant le chemin du banc s'improviser. Documentation seule : docs/ n'est pas embarqué dans le .deb (vérifié : le paquet ne contient que le .so, le changelog, le copyright et les .qm). 1.15.2+etm14 reste donc valide et fidèle à sa source ; pas de nouvelle version. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016MfvSskkYrrFriYcj4683D
546 lines
30 KiB
Markdown
546 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.
|
||
|
||
**❓ 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`** — §8-bis (b), question ouverte à trancher : l'exposer comme
|
||
charge non configurable (et ajouter alors `configurable: false`), ou la faire entrer en
|
||
configuration `sg-ready` et supprimer le bloc codé en dur.
|
||
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.
|