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
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.
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 :
- Un refus arrive en
status: "success". Le verdict est dansparams.energyError—EnergyErrorInvalidParameterpour un rejet,EnergyErrorNoErrorpour un succès. Une sonde qui ne teste questatusannonce « accepté » sur une écriture que la box vient de rejeter, et c'est le journal de la box qui rétablit les faits.NymeaServiceteste bien les deux ; la sonde, non.- Les identifiants ne s'écrivent pas pareil d'un appel à l'autre —
{uuid}en configuration,uuidnu 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 avecjournalctl -u nymeadavant 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_STARTsur la wallbox simulée.)La sonde branchait la borne par
Integrations.ExecuteAction, surveillait, et appelaitdisconnectdans unfinally. Trois choses fausses là-dedans, et aucune n'a produit d'erreur :
connectne fait pas ce que son nom promet. Il basculeconnected— la liaison du Thing — et paspluggedIn, qui est en lecture seule et qu'aucune action n'écrit. La borne n'est donc jamais entrée dansloads[], et le motif cherché était inatteignable par ce chemin. 18 cycles pour l'apprendre.- Le
finallydéfaisait le GESTE, pas l'ÉTAT.connectedvalait déjàtrueavant la sonde : ledisconnect« de restauration » a laissé la borne déconnectée dix minutes. Unfinallysymé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.ThingErrorNoErrorest un VRAI succès, et c'est ce qui rend ce cas plus vicieux que lestatus: "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.dartla 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 pourSetLoadConfigvaut identiquement pour unExecuteAction.Et ce que le passage a mesuré, pour qui réessaiera :
EV_GRID_STARTn'est pas provocable depuis la frontière RPC. Il faudrait une prise véhicule (aucune action ne l'expose) et une voiture assignée — lesChargingInfosdu banc n'ont aucunassignedCarId, et sans elle la borne reste horsloads[]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
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
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
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 :
ssh etm@192.168.1.75 'journalctl -u nymead -f'
telemetry_watch.dart
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
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
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
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
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 :
- 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. - 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(). - 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, pasTHING_MISSING— l'écriture est tentée, échoue faute de Thing, l'échelle s'épuise, etm_faultedmasque le second code ; ClearLoadFaultlève réellement le verrou (le journal le dit : « défaut LEVÉ ; la charge revient à l'arbitrage »), le RPC répondEnergyErrorNoError— 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. |