Patrick Schurig 3f5f6e0d9f docs(correction): mon « écho refusé » était un écho PÉRIMÉ — deux cas, un symptôme
L'agent plugin a mesuré l'inverse sur la même box : écho verbatim, "auto" compris,
EnergyErrorNoError. L'exception existe déjà côté moteur — reconcileRankOrigin() accepte
"auto" quand le persisté le porte déjà, écrite exprès pour ne pas rompre la neutralité
Get → Set.

Ce que j'avais mesuré est autre chose : entre ma lecture et mon écriture, la marque avait
été consommée par une manipulation concurrente. Mon écho portait donc un "auto" que le
persisté ne portait plus — un écho périmé, correctement refusé. Deux cas distincts, un seul
symptôme, et c'est précisément pourquoi une écriture qui ne relit pas juste avant ne prouve
rien. Le contrôle de la suppression, lui, relit avant de conclure ; ma sonde jetable, non.

§10 corrigé : la ligne du tableau dit maintenant ce qu'elle mesure vraiment, et ce qui
reste à mesurer (un écho inchangé sur une marque FRAÎCHE). sansRankOriginAuto() est
documentée comme un contournement EN SURSIS — elle part au résultat de cette mesure, et la
marque survivra alors aux gestes qui ne la concernent pas. Rien n'est retiré d'ici là.

Ce qui reste vrai de mon relevé : "user" s'écrit, ne déplace pas le rang, survit aux gestes
voisins. Et l'effacement de "auto" que j'ai observé est le fait de l'APP — de son
nettoyage — pas d'un refus de la box.

── Deux pièges de sonde, consignés dans tools/rpc/README.md ─────────────────

1. Un refus arrive en `status: "success"`, verdict dans params.energyError. Une sonde qui ne
   teste que `status` annonce « accepté » sur une écriture rejetée.
2. Les identifiants ne s'écrivent pas pareil d'un appel à l'autre — le §9, documenté la
   VEILLE, et la sonde y est quand même tombée : elle rendait des verdicts sur une charge
   qu'elle ne regardait pas.

La leçon n'est pas « écrire des sondes plus soigneusement » : c'est que la sonde ne partage
pas les garde-fous de l'app. Quand la question porte sur ce que l'app fera, importer son
modèle vaut mieux que relire le JSON ; quand elle porte sur la box, croiser la réponse RPC
avec journalctl avant de conclure.

Et la raison lisible d'un refus n'existe QUE dans ce journal — côté RPC il ne reste qu'un
code. Silence de refus ouvert depuis le premier jour, rencontré une fois de plus, et raison
d'être des miroirs clients comme validateSet.

Rien de touché côté entrelacement : ça attend le moteur.

226 tests inchangés, analyze inchangé à 27.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQbZKrWqsMFP1Lh2jjjd9f
2026-08-29 10:44:14 +02:00

216 lines
11 KiB
Markdown

# Sondes JSON-RPC — `tools/rpc/`
Scripts `dart:io` autonomes pour interroger une box nymea **sans lancer l'app**.
Pourquoi ils existent : sur ce poste, Flutter Linux natif est bloqué (snap sans linker
`ld.lld`) et le web ne peut pas joindre le LAN en `ws://` (CORS). Ces sondes sont donc le
moyen le plus court de vérifier un comportement réel de la box — et elles font plus autorité
qu'une lecture de code ou qu'un `Introspect` recopié de mémoire.
Aucune dépendance hors SDK : `dart:io` + `dart:convert`. Rien à installer.
```bash
dart tools/rpc/<script>.dart [host] [args…] # host par défaut : 192.168.1.75
```
## Les scripts
| Script | Ce qu'il fait | Écrit ? |
|---|---|---|
| `probe.dart` | Un appel RPC quelconque, réponse en JSON indenté. Le couteau suisse. | non |
| `notifications.dart` | S'abonne à **tous** les namespaces, compte et échantillonne ce qui arrive. | non |
| `locale.dart` | Comportement de `o:locale` : connexions simultanées, re-`Hello`, locale absurde. | non |
| `locale_diff.dart` | Aplatit **toutes** les chaînes affichables et les diffe entre locales. | non |
| `loadconfig_roundtrip.dart` | Aller-retour neutre `Get` → `Set` verbatim → `Get`. | ⚠️ **OUI** |
| `telemetry_watch.dart` | Instantané puis écoute de `LoadTelemetryChanged` : cadence, avancée du cycle, allocations. | non |
| `evcharger_watch.dart` | Suit les **états d'une borne** (branchement, charge, énergie) — y compris quand elle est hors arbitrage. | non |
| `measurement_check.dart` | Dit, charge par charge, **ce que l'app afficherait** de `measurement` — via le vrai modèle. | non |
| `domain_assign.dart` | Classe des charges par `o:domain`, **par le code d'écriture de l'app**. | ⚠️ **OUI** |
| `fault_probe.dart` | Provoque une charge en défaut, éprouve `ClearLoadFault`, restaure. | ⚠️ **OUI** |
> ⚠️ **Une sonde écrite vite retombe dans les pièges que l'app évite.** Deux payés dans la
> même session, le 2026-08-29, dans des scripts jetables de vingt lignes :
>
> 1. **Un refus arrive en `status: "success"`.** Le verdict est dans `params.energyError` —
> `EnergyErrorInvalidParameter` pour un rejet, `EnergyErrorNoError` pour un succès. Une
> sonde qui ne teste que `status` annonce « accepté » sur une écriture que la box vient
> de rejeter, et c'est le journal de la box qui rétablit les faits. `NymeaService`
> teste bien les deux ; la sonde, non.
> 2. **Les identifiants ne s'écrivent pas pareil d'un appel à l'autre** — `{uuid}` en
> configuration, `uuid` nu côté Thing. Le §9 du contrat de données le documente **depuis
> la veille**, et la sonde est quand même tombée dedans : la comparaison n'a jamais
> trouvé sa cible, et le script a rendu des verdicts sur une charge qu'il ne regardait
> pas.
>
> La leçon n'est pas « écrire des sondes plus soigneusement ». C'est que **la sonde ne
> partage pas les garde-fous de l'app** : elle relit le JSON à sa façon, donc elle refait
> les erreurs que le modèle a déjà résolues. Quand la question porte sur ce que l'app fera,
> importer le modèle de l'app (`dart run`, cf. `measurement_check.dart`) vaut mieux que le
> relire — et quand la question porte sur la box, croiser la réponse RPC avec
> `journalctl -u nymead` avant de conclure.
>
> ⚠️ **Et la raison lisible d'un refus n'existe QUE dans ce journal.** Côté RPC il ne reste
> qu'un code : la box dit *« la charge "Simulated wallbox 2" prétend un rankOrigin "auto"
> que le moteur n'a pas posé »*, l'app reçoit `EnergyErrorInvalidParameter`. C'est le
> silence de refus ouvert depuis le premier jour, rencontré une fois de plus — et la raison
> pour laquelle chaque miroir client (`validateSet`) vaut le coût de sa maintenance.
> ⚠️ **Avant de rapprocher deux réponses.** Deux appels de la même box n'emploient pas
> forcément la même convention — clés de champs, graphie des identifiants, unités — et
> **rien ne le signale** : le croisement raté rend un neutre plausible, jamais une erreur.
> Le motif, ses occurrences connues et la façon de le chercher sont dans
> `docs/UI_data_contract.md` §9. C'est ici, la sonde en main, qu'il se voit le plus vite :
> comparer les charges utiles **brutes** des deux appels, jamais les modèles Dart.
### `probe.dart`
```bash
dart tools/rpc/probe.dart 192.168.1.75 NymeaEnergy.GetLoadConfig
dart tools/rpc/probe.dart 192.168.1.75 JSONRPC.Introspect > introspect.json
dart tools/rpc/probe.dart 192.168.1.75 Integrations.GetThings
dart tools/rpc/probe.dart 192.168.1.75 NymeaEnergy.GetChargingInfos '{"evChargerId":"…"}'
```
Le `Hello` part toujours en premier et son contenu va sur **stderr**, la réponse sur
**stdout** — d'où le `> fichier.json` propre ci-dessus.
### `notifications.dart`
```bash
dart tools/rpc/notifications.dart 192.168.1.75 90 # écoute 90 s
```
Utile pour répondre à « est-ce que cette information franchit la frontière RPC ? ». Un
namespace qui ne produit rien pendant que le système travaille est une réponse, pas un
silence à interpréter.
### `locale.dart` / `locale_diff.dart`
`locale.dart` montre que nymea gère la locale **par connexion** (deux sockets, deux
langues). `locale_diff.dart` répond à la question qui compte vraiment : **est-ce que ça
change quelque chose ?** Relevé du 2026-08-25 sur `.75` — sur 784 chaînes affichables,
`de_DE` en change **1**, `fr_FR`/`it_IT`/`es_ES`/`nl_NL` **zéro**.
### `loadconfig_roundtrip.dart` — ⚠️ écrit
```bash
dart tools/rpc/loadconfig_roundtrip.dart 192.168.1.75 --yes
```
`SetLoadConfig` **remplace l'ensemble** des charges. Le contenu renvoyé est identique à ce
qui vient d'être lu, donc l'opération est neutre — mais elle reste une écriture sur une
installation vivante, et elle déclenche une réévaluation des adaptateurs. Le drapeau
`--yes` est obligatoire pour qu'elle ne parte jamais par réflexe de flèche haut.
Pour confirmer qu'aucune reconstruction d'adaptateurs n'a lieu (ECS-412, `m_builtFrom` +
`sameHardware()`), lire le journal de la box en parallèle :
```bash
ssh etm@192.168.1.75 'journalctl -u nymead -f'
```
### `telemetry_watch.dart`
```bash
dart tools/rpc/telemetry_watch.dart 192.168.1.75 240 # écoute 240 s
```
Répond à deux questions que la lecture de code ne tranche pas : **une trame arrive-t-elle
sans qu'on la demande**, et **le `timestamp` avance-t-il** ? La seconde est la seule qui
distingue une installation stable d'un moteur arrêté — les deux produisent le même flux de
battements de cœur. Relevé du 2026-08-25 sur `.75` : battement à ~59 s, cycle qui avance
d'une minute, et une trame de changement significatif dans la foulée.
### `evcharger_watch.dart`
```bash
dart tools/rpc/evcharger_watch.dart 192.168.1.75 600 V2C # 600 s, bornes dont le nom contient « V2C »
```
`telemetry_watch.dart` ne montre une borne que si elle est **arbitrée** ; sans véhicule
branché elle n'entre pas dans `loads[]` (depuis `+etm24`), et c'est justement pendant ces
transitions qu'il faut voir ce que le Thing publie. Cette sonde s'abonne aux états du Thing
et n'imprime que ce qui change.
Relevé du 2026-08-28 sur la V2C Trydan de `.75` — les deux constats qui bloquent LM-1009 :
`chargeEnergy` est **exact pendant la charge** (0,3456 kWh, croissance qui recoupe
`currentPower` à mieux d'un pour cent) mais **remis à zéro à l'arrêt de la charge**, 119 s
avant que le câble ne bouge ; et `powerL1/L2/L3` sont des **ampères mal nommés**. Aucun des
deux ne doit être affiché tant que le plugin n'a pas publié `sessionEnergy` et le renommage
`currentL1/L2/L3`.
### `measurement_check.dart`
```bash
dart run tools/rpc/measurement_check.dart 192.168.1.75
```
⚠️ `dart run` : la sonde **importe le modèle de l'app** (`LoadTelemetryEntry.fromJson`)
plutôt que de relire le JSON à sa façon — une lecture parallèle ne prouverait rien sur ce
que l'écran fera. Elle rend, par charge, la source publiée et la phrase que l'app en tire.
Ce qu'elle sert à voir, et qu'un écran regardé de loin ne distingue pas : `none` (bloc
masqué, « pas mesurable ») d'une charge simplement **absente de `loads[]`** (rien à
conclure), et la portée d'un écart selon `meter` ou `device`. Relevé du 2026-08-28 sur
`.75` en `1.15.2+etm26` : `measuredW` n'est plus publié, `chauffe-eau` et `pac-terrain`
sont en `meter` (1 500 W et 800 W), la V2C est absente — pas `none`.
### `domain_assign.dart` — ⚠️ écrit
```bash
dart run tools/rpc/domain_assign.dart 192.168.1.75 --set chauffe-eau=ecs --set pac-terrain=hvac --yes
dart run tools/rpc/domain_assign.dart 192.168.1.75 --clear --yes # tout déclasser
```
⚠️ `dart run` et non `dart` : ce script **importe le code de l'app**
(`LoadConfigEntry.patched()`, `groupByDomain()`, `flattenToPayload()`) plutôt que de
fabriquer sa propre charge utile. Une sonde qui sérialiserait à sa façon ne prouverait rien
sur l'app. Le `domain` est une pure métadonnée — l'arbitre ne la lit pas — mais l'écriture
est réelle.
### `fault_probe.dart` — ⚠️ écrit, et ajoute une charge fictive
```bash
dart run tools/rpc/fault_probe.dart 192.168.1.75 --yes
dart run tools/rpc/fault_probe.dart 192.168.1.75 --yes --dump fixtures_defaut
dart run tools/rpc/fault_probe.dart 192.168.1.75 --restore-only --yes
```
Ajoute une charge `etmvariableload` dont le ThingId n'existe pas, attend deux cycles,
observe la télémétrie, appelle `ClearLoadFault`, puis **restaure verbatim** la
configuration lue au départ.
Trois garde-fous, tous délibérés :
1. **La configuration d'origine part sur le disque AVANT la première écriture**
(`fault_probe_backup_<host>.json`, git-ignoré). Un plantage à mi-parcours ne doit pas
laisser le banc avec la charge fictive et aucune référence de retour.
2. **La restauration rejoue ce fichier verbatim**, jamais une relecture filtrée. Retirer
la sonde d'une config relue serait une *reconstruction* — le chemin exact qui a failli
écraser la configuration du banc via `EnergySetupProvider.persist()`.
3. **Après restauration, la config relue est comparée clé par clé** à celle du départ, pas
seulement « la sonde a disparu ». C'est le critère 1 appliqué ici.
Le label de la charge fictive est `SONDE-TEST-A-SUPPRIMER` : si elle survit à un incident,
personne n'aura à se demander d'où elle sort.
**Ce que le passage du 2026-08-25 a montré**, et qui ne se déduisait pas du code :
- le défaut publié est `WRITE_FAILED`, **pas** `THING_MISSING` — l'écriture est tentée,
échoue faute de Thing, l'échelle s'épuise, et `m_faulted` masque le second code ;
- `ClearLoadFault` **lève réellement** le verrou (le journal le dit : « défaut LEVÉ ; la
charge revient à l'arbitrage »), le RPC répond `EnergyErrorNoError` — et **le cycle
suivant reverrouille**, la cause n'ayant pas disparu. La télémétrie ne repasse jamais à
`available: true`.
C'est la démonstration la plus nette de la règle du lot : **l'acquittement d'un RPC ne vaut
pas application**. Une app qui aurait affiché « défaut levé » sur le retour du RPC aurait
menti deux secondes plus tard.
## Bancs
| Cible | Particularité |
|---|---|
| `192.168.1.75` | **ouverte** (`authenticationRequired: false`) — aucune auth à gérer dans les sondes. Deux charges depuis le plugin `1.15.2+etm15` : `chauffe-eau` (relay-router, `ecs`) et `pac-terrain` (sg-ready, `hvac`). |
| `192.168.1.120` | **auth requise** — ces sondes ne gèrent PAS `Authenticate`. À étendre avant usage. |