Patrick Schurig 47866ddee3 docs(sondes): défaire son action n'est pas restaurer l'état
Payé le 2026-08-30 en cherchant à provoquer EV_GRID_START. Trois erreurs, aucune
n'a produit de code d'erreur :

- `connect` bascule `connected` (la liaison du Thing), pas `pluggedIn` — lecture
  seule, qu'aucune action n'écrit. La borne n'est jamais entrée dans `loads[]` ;
- le `finally` défaisait le GESTE, pas l'ÉTAT : `connected` valait déjà `true`
  avant, donc la « restauration » a laissé la borne déconnectée dix minutes ;
- `ThingErrorNoError` est un VRAI succès, et c'est ce qui rend le cas plus
  vicieux que `status: "success"` : l'action a réussi, elle ne faisait pas ce
  qu'on croyait. Aucun code ne dira « tu t'es trompé d'action » — le seul verdict
  est l'état relu.

La règle que `fault_probe.dart` tenait déjà pour la configuration est généralisée
à `ExecuteAction` : capturer avant, restaurer VERS la capture, comparer état par
état.

Et le fait mesuré, pour qui réessaiera : EV_GRID_START n'est pas provocable
depuis la frontière RPC (pas de prise véhicule exposée, pas d'`assignedCarId`).
La fenêtre, elle, s'ouvre seule — 07:46, surplus 905 W, sous le minimum de la
borne. La preuve du partage viendra donc de la suite de simulation du moteur ;
le critère ne bouge pas, et la branche reste jusqu'à sa trace.

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

250 lines
14 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.
> ⚠️ **Défaire son action n'est pas restaurer l'état.** *(Payé le 2026-08-30, en cherchant à
> provoquer `EV_GRID_START` sur la wallbox simulée.)*
>
> La sonde branchait la borne par `Integrations.ExecuteAction`, surveillait, et appelait
> `disconnect` dans un `finally`. Trois choses fausses là-dedans, et **aucune n'a produit
> d'erreur** :
>
> 1. **`connect` ne fait pas ce que son nom promet.** Il bascule `connected` — la liaison du
> Thing — et **pas `pluggedIn`**, qui est en lecture seule et qu'**aucune action n'écrit**.
> La borne n'est donc jamais entrée dans `loads[]`, et le motif cherché était inatteignable
> par ce chemin. 18 cycles pour l'apprendre.
> 2. **Le `finally` défaisait le GESTE, pas l'ÉTAT.** `connected` valait **déjà `true`** avant
> la sonde : le `disconnect` « de restauration » a laissé la borne déconnectée dix minutes.
> Un `finally` symétrique de l'action n'est juste que si l'action est un interrupteur dont
> on connaît la position de départ — c'est-à-dire jamais, sauf à l'avoir relevée.
> 3. **`ThingErrorNoError` est un VRAI succès**, et c'est ce qui rend ce cas plus vicieux que
> le `status: "success"` du bloc précédent : l'action **a** réussi, elle ne faisait pas ce
> qu'on croyait. Aucun code d'erreur ne dira jamais « tu t'es trompé d'action ». **Le seul
> verdict est l'état relu.**
>
> **Règle, et `fault_probe.dart` la tenait déjà pour la configuration** : capturer l'état
> **avant** la première écriture, restaurer **vers la capture** et non vers l'inverse du
> geste, puis **comparer état par état** — le script de retour lit le fichier de capture,
> jamais la mémoire de ce qu'on a tapé. Ce qui valait pour `SetLoadConfig` vaut identiquement
> pour un `ExecuteAction`.
>
> **Et ce que le passage a mesuré, pour qui réessaiera** : `EV_GRID_START` n'est pas
> provocable depuis la frontière RPC. Il faudrait une prise véhicule (aucune action ne
> l'expose) **et** une voiture assignée — les `ChargingInfos` du banc n'ont aucun
> `assignedCarId`, et sans elle la borne reste hors `loads[]` même branchée (`+etm24`). En
> revanche la **fenêtre** s'ouvre bien toute seule : le 2026-08-30 à 07:46, surplus à 905 W,
> entre zéro et le minimum de la borne (~1 380 W à 6 A monophasé). Ce n'est pas la fenêtre qui
> manque, c'est la borne dans l'arbitrage.
### `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. |