tools(rpc): sondes télémétrie / domaine / défaut, et journal de vérification

Trois sondes permanentes, et le relevé de ce qu'elles ont montré sur .75.

- telemetry_watch.dart (lecture seule) : cadence du battement de cœur et avancée
  du cycle. C'est la seule mesure qui distingue une installation stable d'un
  moteur arrêté — les deux produisent le même flux de trames.
- domain_assign.dart (écrit, --yes) : classe des charges en important le code
  d'écriture de l'app. Une sonde qui sérialiserait à sa façon ne prouverait rien
  sur l'app.
- fault_probe.dart (écrit, --yes) : provoque une charge en défaut, éprouve
  ClearLoadFault, restaure. La config d'origine part sur le disque AVANT la
  première écriture et la restauration rejoue ce fichier verbatim — filtrer la
  sonde hors d'une relecture serait une reconstruction, c'est-à-dire le chemin
  qui a failli écraser la config du banc via persist(). La config relue est
  ensuite comparée clé par clé, pas seulement « la sonde a disparu ».

Ce que le banc a appris, et qui ne se déduisait pas du code :

- ClearLoadFault lève RÉELLEMENT le verrou (journal : « défaut LEVÉ ») et le
  cycle suivant reverrouille, la cause n'ayant pas disparu. La télémétrie ne
  repasse jamais à available:true. Une app qui aurait cru l'acquittement aurait
  menti deux secondes plus tard.
- Le défaut publié est WRITE_FAILED, pas THING_MISSING : l'écriture est tentée,
  échoue, l'échelle s'épuise, et m_faulted masque le second code.
- SetLoadConfig refuse en bloc et le motif n'est PAS dans la réponse RPC — il est
  au journal. L'app ne peut que rapporter un refus, pas l'expliquer.
- ECS-412 confirmé au journal : une écriture de domaine ne reconstruit aucun
  adaptateur (« 2 inchangée(s), 0 retirée(s) »). Le §7-1bis n'est plus une
  lecture de code.

.75 est laissé classé (chauffe-eau→ecs, pac-terrain→hvac) : métadonnée que
l'arbitre ne lit pas, et le multi-domaines y devient exerçable. Retour arrière :
domain_assign.dart --clear --yes

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XPUo3RMr8SzK6qbFtfBm8H
This commit is contained in:
Patrick Schurig 2026-08-25 19:20:46 +02:00
parent dee20896c5
commit 40ea285663
5 changed files with 739 additions and 46 deletions

146
RECAP.md
View File

@ -57,60 +57,123 @@ remplace **tout l'ensemble** → la charge `chauffe-eau` de `.75` aurait été
---
## ⏸️ ÉTAT À LA REPRISE — attendre l'agent plugin
## Reprise du 2026-08-25 (soir) — l'agent plugin a terminé, la moitié télémétrie est faite
**Le lot plugin télémétrie A ÉTÉ déployé sur `.75`** en fin de session (`4ade3e4`) :
**20 méthodes / 14 notifications**, `GetLoadTelemetry`, `LoadTelemetryChanged` et
`o:domain` présents. Les critères **4 à 8** du brief lot B sont donc techniquement
atteignables.
Le blocage est levé : `1.15.2+etm15` (lot plugin **B-bis**) est sur `.75`, `pac-terrain`
est devenue une `LoadConfig` ordinaire, et `GetLoadConfig` / `GetLoadTelemetry`
**concordent** (2 charges des deux côtés). La règle de détection « charge arbitrée hors
configuration » de `+etm14` est **sans objet** — ne pas la réintroduire, et **ne pas
attendre de `configurable: false`** : ce champ n'existe pas.
> **⛔ NE PAS ENCHAÎNER POUR AUTANT.** Décision Patrick, 2026-08-25 : attendre que
> l'agent plugin ait **terminé son travail sur `pac-terrain`** avant de construire la
> moitié télémétrie côté app.
>
> Symptôme observé (reproductible, **travail en cours — pas un bug à remonter**) :
> `GetLoadTelemetry` renvoie **2** charges (`chauffe-eau`, `pac-terrain`) là où
> `GetLoadConfig` n'en renvoie qu'**1** (`chauffe-eau`). Coder l'écran contre cet état
> intermédiaire, c'est coder contre une cible mouvante.
### Livré côté app
### Fixtures capturées avant la bascule (déjà committées)
- `test/fixtures/loadtelemetry_hems75.json` — dump réel de `GetLoadTelemetry`
- `test/fixtures/loadconfig_hems75_v2.json` — `GetLoadConfig` **avec** la clé `domain`
- `test/fixtures/schema_hems75_v2.json` — schémas SET + télémétrie introspectés
- `test/fixtures/loadconfig_hems75.json` — dump d'AVANT, conservé comme référence
| Fichier | Rôle |
|---|---|
| `models/load_telemetry.dart` | Vue en lecture de `GetLoadTelemetry`. `timestamp`/`budget`/`mechanism`/`lock`/`faultCode` **nullables** : omis ≠ zéro. Seuil de fraîcheur `kTelemetryStaleAfter = 180 s`. |
| `services/telemetry_text.dart` | Rendu français des **codes**. 19 motifs, 3 défauts, 3 verrous, 4 mécanismes — et un repli obligatoire. |
| `providers/load_telemetry_provider.dart` | Instantané + `LoadTelemetryChanged`, fraîcheur à **trois signaux**, machine à états de `ClearLoadFault`. |
| `providers/load_config_provider.dart` | `assignDomain()` — affectation en **brouillon**, jamais d'écriture implicite. |
| `screens/energy/load_order_screen.dart` | En-tête budget/fraîcheur/dégradé, bloc télémétrie par charge, encart défaut, menu de domaine. |
| `services/nymea_service.dart` | `getLoadTelemetry()`, `clearLoadFault()`, aiguillage de `LoadTelemetryChanged`. |
| `tools/rpc/{telemetry_watch,domain_assign,fault_probe}.dart` | Sondes permanentes — voir leur `README.md`. |
Forme confirmée de la charge utile (conforme au brief : des **codes**, jamais des phrases) :
**57 tests** (30 avant), `flutter analyze` **25 issues = ligne de base inchangée**.
```json
{ "loadId": "chauffe-eau", "allocatedW": 0, "available": true,
"decision": { "code": "SURPLUS_INSUFFICIENT", "params": { "budgetW": 229 } },
"lock": { "kind": "minOff", "remainingS": 60 },
"mechanism": { "kind": "relay", "stageW": 0, "maxStageW": 3500 } }
### Trois choix de conception à ne pas défaire
1. **La fraîcheur ne repose pas sur le seul `timestamp`.** Trois signaux concourent :
l'âge du `timestamp` (le seul qui vaille à l'instant du premier instantané — un `Get`
rend le dernier cycle *publié*, qui peut dater d'une heure), le temps depuis la
dernière trame (immunisé contre l'écart d'horloge box↔téléphone), et le temps depuis
que le `timestamp` a **cessé d'avancer** — le cas exact que le battement de cœur
existe pour rendre détectable. Le seuil est à **180 s** : trois cycles. Un seuil ≤ 60 s
crierait « données anciennes » sur une installation vivante.
2. **Les secondes d'un verrou ne sont pas décomptées côté app.** Le plugin exclut
volontairement ce champ de sa détection de changement ; un décompte local serait une
extrapolation présentée comme une mesure. L'écran écrit « (au dernier cycle) ».
3. **Un code connu privé d'un paramètre attendu bascule en repli**, comme un code inconnu.
« Consigne de W servie par le surplus » aurait l'air d'une donnée ; le code brut a
l'air de ce qu'il est.
### Ce que le banc a appris — et qui ne se déduisait pas du code
**`ClearLoadFault` lève vraiment, et le cycle suivant reverrouille.** Passage du
2026-08-25 18:14 → 18:17 (`tools/rpc/fault_probe.dart`, journal de `.75`) :
```
18:14:00 écriture → thing non trouvé → échelle épuisée → charge EN DÉFAUT
18:15:58 [Arbitre] ClearLoadFault → "défaut LEVÉ ; la charge revient à l'arbitrage"
RPC : EnergyErrorNoError
18:16:00 cycle suivant → thing toujours non trouvé → EN DÉFAUT à nouveau
```
### Acquis à ne pas reperdre
- **L'arbitre n'est PAS figé** : le `timestamp` avance d'une minute par cycle
(`12:37` → `12:38` → `12:39`, vérifié). Le seuil de fraîcheur du §7-6 doit donc être
**supérieur à 60 s** — deux ou trois cycles, pas moins.
- `.75` a désormais une 2e charge (`pac-terrain`, sg-ready) côté télémétrie : le
multi-charges y deviendra exerçable en vrai, contrairement à ce que disait le tableau
ci-dessous.
La télémétrie ne repasse **jamais** à `available: true`. Une app qui aurait annoncé
« défaut levé » sur le retour du RPC aurait menti deux secondes plus tard. C'est la
justification, mesurée, de l'état `pending` maintenu 130 s puis conclu `stillFaulty`.
### Sondes JSON-RPC — désormais permanentes
`tools/rpc/` (voir son `README.md`). `loadconfig_roundtrip.dart` est le **seul qui écrit**
et exige `--yes`.
**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
(`etmvariableloadadapter.cpp:148-150`, l'ordre des `if`). Ne pas coder l'UI en supposant
qu'un Thing absent produit `THING_MISSING`.
**`SetLoadConfig` refuse en bloc, et le motif n'est PAS dans la réponse RPC.** La box
renvoie `EnergyErrorInvalidParameter`, point. Le motif — « `etmvariableload fixed :
powerLevels requis (non vide)` », avec le libellé de la charge — est **au journal**. C'est
la « silence de refus » que le relevé du plugin garde ouverte (§9-4) : l'app ne peut pas
expliquer un refus à l'installateur, elle ne peut que le rapporter.
### Journal de vérification — critères 4 à 8
| Critère | Verdict |
|---|---|
| **§7-4 domaine** | ✅ **sur `.75`** — écrit par le code d'écriture de l'app (`domain_assign.dart` importe `patched()`/`flattenToPayload()`), relu identique, persisté dans `/var/lib/nymea/energy-load-configuration.json` (`domain='ecs'` / `'hvac'`). ⚠️ **le redémarrage de `nymead` n'a PAS été exercé** : `sudo` demande un mot de passe sur `.75`. |
| **§7-5 télémétrie vivante** | ✅ **sur `.75`** — trames non sollicitées, allocation 500 → 3000 → 3500 W, `SG_NORMAL` → `LOCK_MIN_STATE_HOLD`, verrou 241 → 180 → 120 s. |
| **§7-6 fraîcheur** | ✅ **protocole mesuré sur `.75`** (battement ~59 s, cycle qui avance d'une minute) ; ⚠️ la **bascule d'affichage** est éprouvée en test unitaire, pas à l'écran — l'app n'a jamais tourné sur appareil (voir §7-9). |
| **§7-7 défaut** | ✅ **sur `.75`** — défaut provoqué, `available: false`, `faultCode` rendu en français, `ClearLoadFault` exercé et son inefficacité **constatée sur la télémétrie**. ⚠️ pas de fixture brute : le dump n'a pas été conservé (`--dump` le capturera au prochain passage). |
| **§7-8 code inconnu** | ✅ test unitaire — code absent de l'ARB **et** code connu privé d'un paramètre. Non exerçable sur `.75` : la box n'émet que des codes du catalogue. |
| **§7-1bis ECS-412** | ✅ **levé** grâce au ssh — journal de l'écriture de domaine : `0 créée(s), 0 mise(s) à jour en place, 2 inchangée(s), 0 retirée(s)`. Aucune reconstruction d'adaptateurs. |
### État du banc à la fin de la session
`.75` est **laissé classé** : `chauffe-eau` → `ecs`, `pac-terrain` → `hvac`. Le `domain`
est une pure métadonnée que l'arbitre ne lit pas, et cet état rend enfin le multi-domaines
exerçable sur la machine. Pour revenir en arrière :
```bash
dart run tools/rpc/domain_assign.dart 192.168.1.75 --clear --yes
```
La charge fictive de la sonde de défaut a été **retirée**, et la configuration relue est
identique clé par clé à celle lue avant la sonde.
## Vérifié / non vérifié (lot B app)
| Critère | Statut |
|---|---|
| §7-1 aller-retour neutre | ✅ **sur `.75` en vrai** — `SetLoadConfig` verbatim → relecture identique |
| §7-1bis non-rebuild ECS-412 | ⚠️ lecture de code seulement (`sameHardware()`), journal non consulté |
| §7-2 aplatissement réversible | ✅ **test unitaire seul** — `.75` n'a qu'une charge |
| §7-1bis non-rebuild ECS-412 | ✅ **sur `.75`** — journal lu : 2 charges inchangées, 0 reconstruite |
| §7-2 aplatissement réversible | ✅ **test unitaire seul** — `.75` n'a que deux charges, désormais dans deux domaines |
| §7-3 config non contiguë | ✅ **test unitaire seul** — fixture synthétique |
| §7-4 à §7-8 | ❌ bloqués (lot plugin) |
| §7-4 domaine | ✅ **sur `.75`** ; ⚠️ redémarrage `nymead` non exercé (sudo verrouillé) |
| §7-5 télémétrie vivante | ✅ **sur `.75`** |
| §7-6 fraîcheur | ✅ protocole sur `.75` ; ⚠️ bascule d'affichage en test unitaire seul |
| §7-7 défaut | ✅ **sur `.75`** (sonde `fault_probe.dart`) |
| §7-8 code inconnu | ✅ test unitaire (non exerçable sur une box conforme) |
| §7-9 locale | ✅ protocole vérifié sur `.75` ; ⚠️ **chemin app jamais exécuté sur appareil** (adb refusé) |
| §7-10 analyze / tests / gating | ✅ 25 issues = ligne de base, 30/30 tests |
| §7-10 analyze / tests / gating | ✅ 25 issues = ligne de base, 57/57 tests |
## Ce qui reste à faire sur ce lot
1. **Redémarrer `nymead` sur `.75`** et relire la configuration — dernier morceau du §7-4.
`sudo systemctl restart nymead` demande un mot de passe que l'agent n'a pas.
2. **Faire tourner l'app sur appareil** (`adb` refusé jusqu'ici). Tant que ça n'est pas
fait, §7-6 et §7-9 restent des vérifications de protocole, pas d'écran.
3. **Capturer une fixture brute de défaut** au prochain passage de `fault_probe.dart`
(`--dump`).
4. **Écran de réglage par charge** — l'agent plugin le demande explicitement :
`pac-terrain` est désormais renommable, classable, rangeable, désactivable. Le brief du
lot B limitait l'écriture à `priority` + `o:domain` ; renommer et désactiver sont
**hors de ce lot**, et attendent un arbitrage.
## Question ouverte à trancher
@ -119,13 +182,6 @@ est bâti sur le modèle **inverse** (il construit des `LoadDescriptor` depuis d
J'ai créé `/energy/loads` à côté et laissé `/energy/setup` intact. **Fusionner les deux est
une refonte, pas un câblage** — décision Patrick attendue.
## Nouveauté à exploiter à la reprise
`CLAUDE.md` documente désormais l'accès banc : `ssh etm@192.168.1.75` (clé installée),
journal via `journalctl -u nymead`. **Cela lève la limite qui rendait §7-1bis invérifiable**
— au prochain aller-retour neutre, lire le journal pour confirmer qu'aucune reconstruction
d'adaptateurs n'a lieu.
---
# RECAP — etm-powersync-app · reprise de session

View File

@ -22,6 +22,9 @@ dart tools/rpc/<script>.dart [host] [args…] # host par défaut : 192.168.1
| `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 |
| `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** |
### `probe.dart`
@ -70,9 +73,73 @@ Pour confirmer qu'aucune reconstruction d'adaptateurs n'a lieu (ECS-412, `m_buil
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.
### `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. Une seule charge (`chauffe-eau`, relay-router). |
| `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. |

View File

@ -0,0 +1,198 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:etm_powersync_app/models/load_config_entry.dart';
import 'package:etm_powersync_app/services/load_priority.dart';
// §7-4 — affectation d'un domaine, par le CHEMIN D'ÉCRITURE DE L'APP.
//
// ⚠️ CE SCRIPT ÉCRIT SUR LA BOX. `SetLoadConfig` remplace l'ENSEMBLE des charges.
// Le drapeau --yes est obligatoire.
//
// Il n'invente aucune sérialisation : il importe `LoadConfigEntry.patched()`,
// `groupByDomain()` et `flattenToPayload()` — exactement ce que
// `LoadConfigProvider.assignDomain()` puis `save()` produisent dans l'app. Une sonde qui
// fabriquerait sa propre charge utile ne prouverait rien sur l'app.
//
// Le `domain` est une PURE MÉTADONNÉE : l'arbitre ne la lit pas, aucun comportement
// moteur n'en dépend. L'écriture reste réelle et déclenche une réévaluation des
// adaptateurs — d'où le --yes, et d'où l'intérêt de lire le journal en parallèle :
//
// ssh etm@192.168.1.75 'journalctl -u nymead -f'
//
// Usage :
// dart run tools/rpc/domain_assign.dart <host> --set chauffe-eau=ecs --set pac-terrain=hvac --yes
// dart run tools/rpc/domain_assign.dart <host> --clear --yes # tout déclasser
Future<void> main(List<String> args) async {
final assignments = <String, String>{};
for (var i = 0; i < args.length; i++) {
if (args[i] == '--set' && i + 1 < args.length) {
final parts = args[i + 1].split('=');
if (parts.length != 2) {
stderr.writeln('--set attend loadId=domaine');
exitCode = 2;
return;
}
assignments[parts[0]] = parts[1];
}
}
final clearAll = args.contains('--clear');
if (assignments.isEmpty && !clearAll) {
stderr.writeln('Rien à faire : passer --set loadId=domaine, ou --clear.');
exitCode = 2;
return;
}
for (final d in assignments.values) {
if (!kAssignableDomains.contains(d)) {
// L'énumération est fermée côté plugin et `SetLoadConfig` rejette EN BLOC :
// envoyer un domaine inventé ferait échouer l'enregistrement de TOUTES les
// charges, pas seulement de celle qu'on classe.
stderr.writeln('Domaine « $d » hors énumération : $kAssignableDomains');
exitCode = 2;
return;
}
}
if (!args.contains('--yes')) {
stderr.writeln('Ce script ÉCRIT sur la box (NymeaEnergy.SetLoadConfig).');
stderr.writeln('Relancer avec --yes pour confirmer.');
exitCode = 2;
return;
}
final host =
args.isNotEmpty && !args.first.startsWith('--') ? args.first : '192.168.1.75';
final ws = await WebSocket.connect('ws://$host:4444')
.timeout(const Duration(seconds: 8));
var id = 0;
final pending = <int, Completer<Map<String, dynamic>>>{};
final notifs = <String>[];
ws.listen((raw) {
final m = jsonDecode(raw as String) as Map<String, dynamic>;
final rid = m['id'];
if (rid is int && pending.containsKey(rid)) {
pending.remove(rid)!.complete(m);
} else if (m['notification'] != null) {
notifs.add('${DateTime.now().toIso8601String()} ${m['notification']}');
}
});
Future<Map<String, dynamic>> call(String me, [Map<String, dynamic> p = const {}]) {
final i = ++id;
final c = Completer<Map<String, dynamic>>();
pending[i] = c;
ws.add(jsonEncode({'id': i, 'method': me, 'params': p}));
return c.future.timeout(const Duration(seconds: 20));
}
await call('JSONRPC.Hello', {'locale': 'fr_FR'});
await call('JSONRPC.SetNotificationStatus', {'enabled': true});
final before = await call('NymeaEnergy.GetLoadConfig');
final rawBefore = (before['params']['loadConfigs'] as List)
.cast<Map<String, dynamic>>()
.map((m) => Map<String, dynamic>.from(m))
.toList();
print('--- AVANT ---');
for (final m in rawBefore) {
print(' ${m['id']} priority=${m['priority']} domain="${m['domain'] ?? ''}"');
}
// ── Le chemin de l'app, sans l'app ────────────────────────────────────────
var groups = groupByDomain(rawBefore.map(LoadConfigEntry.new).toList()).groups;
for (final e in {
if (clearAll)
for (final m in rawBefore) m['id'] as String: kUnclassifiedDomain,
...assignments,
}.entries) {
groups = _assign(groups, e.key, e.value);
}
final payload = flattenToPayload(groups);
print('\n--- CHARGE UTILE ENVOYÉE (produite par le code de l\'app) ---');
for (final m in payload) {
print(' ${m['id']} priority=${m['priority']} domain="${m['domain'] ?? ''}"');
}
final conflict = validateSet([for (final g in groups) ...g.loads]);
if (conflict != null) {
print('\nMiroir de validateSet() : conflit ${conflict.kind} — rien n\'est envoyé.');
await ws.close();
exitCode = 1;
return;
}
final set = await call('NymeaEnergy.SetLoadConfig', {'loadConfigs': payload});
print('\n--- réponse SetLoadConfig ---');
print(jsonEncode(set));
await Future<void>.delayed(const Duration(seconds: 3));
final after = await call('NymeaEnergy.GetLoadConfig');
final rawAfter = (after['params']['loadConfigs'] as List)
.cast<Map<String, dynamic>>()
.map((m) => Map<String, dynamic>.from(m))
.toList();
print('\n--- APRÈS (relu sur la box) ---');
for (final m in rawAfter) {
print(' ${m['id']} priority=${m['priority']} domain="${m['domain'] ?? ''}"');
}
print('\n--- notifications reçues ---');
print(notifs.isEmpty ? '(aucune)' : notifs.join('\n'));
// ── Verdict : seuls domain et priority ont bougé ──────────────────────────
print('\n--- VERDICT ---');
var intact = true;
for (final origin in rawBefore) {
final now = rawAfter.firstWhere((m) => m['id'] == origin['id'],
orElse: () => <String, dynamic>{});
if (now.isEmpty) {
print('PERDUE : ${origin['id']}');
intact = false;
continue;
}
for (final k in origin.keys) {
if (k == 'domain' || k == 'priority') continue;
if (jsonEncode(now[k]) != jsonEncode(origin[k])) {
print('ALTÉRÉE : ${origin['id']}.$k ${jsonEncode(origin[k])} '
'→ ${jsonEncode(now[k])}');
intact = false;
}
}
}
print(intact
? 'Charges utiles intactes — seuls domain/priority ont changé.'
: 'Divergence : voir ci-dessus.');
await ws.close();
}
/// Miroir de `LoadConfigProvider.assignDomain()` — la charge quitte son groupe et
/// rejoint la fin du groupe cible.
List<DomainGroup> _assign(List<DomainGroup> groups, String loadId, String domain) {
final current = [for (final g in groups) g.copyWith(loads: [...g.loads])];
LoadConfigEntry? moved;
for (final g in current) {
final i = g.loads.indexWhere((e) => e.id == loadId);
if (i >= 0) {
moved = g.loads.removeAt(i);
break;
}
}
if (moved == null) {
stderr.writeln('Charge « $loadId » absente de la configuration — ignorée.');
return groups;
}
final patched = LoadConfigEntry(moved.patched(domain: domain));
final gi = current.indexWhere((g) => g.domain == domain);
if (gi >= 0) {
current[gi] = current[gi].copyWith(loads: [...current[gi].loads, patched]);
} else {
current.add(DomainGroup(domain: domain, loads: [patched]));
}
current.removeWhere((g) => g.loads.isEmpty);
return current;
}

272
tools/rpc/fault_probe.dart Normal file
View File

@ -0,0 +1,272 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
// §7-7 — provoquer une charge `available: false` sur le banc, et regarder ce que
// `ClearLoadFault` en fait vraiment.
//
// ⚠️ CE SCRIPT ÉCRIT SUR LA BOX, ET IL AJOUTE UNE CHARGE FICTIVE.
// Le drapeau --yes est obligatoire.
//
// ## Pourquoi une charge fictive
//
// Les deux défauts réellement atteignables ne se provoquent pas de la même façon :
//
// * `WRITE_FAILED` — l'échelle d'écriture est épuisée : il faut du matériel qui refuse
// les commandes. **Pas provoquable depuis le réseau.** C'est pourtant le SEUL défaut
// que `clearFault()` lève (`m_faulted`).
// * `THING_MISSING` — l'adaptateur `etmvariableload` ne trouve pas le Thing qui porte
// son propre `id` (`etmvariableloadadapter.cpp:149`). Provoquable, puisqu'il suffit
// de nommer un ThingId absent — et c'est le seul des trois codes qui s'obtienne sans
// toucher au matériel ni casser une liaison réelle.
//
// La charge fictive est **en défaut**, donc exclue de l'arbitrage : elle ne consomme
// aucun budget, et son ThingId n'existant pas, elle ne peut commander aucun contact. Le
// pire cas est une charge inerte dans la configuration.
//
// Ce que la sonde établit :
// (a) une charge en défaut sort de l'arbitrage avec `available: false` et un `faultCode`
// que l'app doit rendre en français ;
// (b) `ClearLoadFault` sur un `THING_MISSING` **ne lève rien** — l'adaptateur renvoie
// `false`, l'arbitre journalise « sans objet », et le RPC répond quand même
// `EnergyErrorNoError`. C'est exactement pourquoi l'app n'annonce jamais une levée
// sur le retour du RPC.
//
// ## La restauration
//
// La configuration lue au départ est écrite **dans un fichier local avant la première
// écriture**, et c'est cette map qui est renvoyée verbatim. Jamais une relecture filtrée :
// retirer la sonde d'une config relue serait une **reconstruction**, c'est-à-dire le
// chemin exact qui a failli écraser la configuration du banc (`EnergySetupProvider
// .persist()`). Et un plantage à mi-parcours ne doit pas laisser le banc avec la charge
// fictive et aucun moyen de savoir ce qu'il y avait avant : le fichier survit au script.
//
// Après restauration, la sonde ne se contente pas de vérifier que la charge fictive a
// disparu : elle compare `GetLoadConfig` à ce qu'elle avait lu au départ, clé par clé.
// C'est le test d'aller-retour neutre du critère 1, appliqué ici.
//
// Usage :
// dart run tools/rpc/fault_probe.dart <host> --yes
// dart run tools/rpc/fault_probe.dart <host> --restore-only --yes
// (--backup <chemin> pour changer le fichier de sauvegarde)
// (--dump <préfixe> pour écrire les charges utiles BRUTES de GetLoadTelemetry —
// c'est ainsi qu'on obtient une fixture de défaut sans avoir à reprovoquer le défaut
// une seconde fois : une charge utile recopiée d'un affichage n'est pas un dump.)
const String kProbeLoadId = '{00000000-dead-4bee-8000-000000000042}';
/// Sans ambiguïté possible : si cette charge survit à un incident, personne ne doit se
/// demander d'où elle sort dans trois semaines.
const String kProbeLabel = 'SONDE-TEST-A-SUPPRIMER';
Future<void> main(List<String> args) async {
if (!args.contains('--yes')) {
stderr.writeln('Ce script ÉCRIT sur la box et ajoute une charge fictive.');
stderr.writeln('Relancer avec --yes pour confirmer.');
exitCode = 2;
return;
}
final restoreOnly = args.contains('--restore-only');
final host =
args.isNotEmpty && !args.first.startsWith('--') ? args.first : '192.168.1.75';
final di = args.indexOf('--dump');
final dumpPrefix = (di >= 0 && di + 1 < args.length) ? args[di + 1] : null;
final bi = args.indexOf('--backup');
final backupPath = (bi >= 0 && bi + 1 < args.length)
? args[bi + 1]
: 'fault_probe_backup_$host.json';
final backupFile = File(backupPath);
final ws = await WebSocket.connect('ws://$host:4444')
.timeout(const Duration(seconds: 8));
var id = 0;
final pending = <int, Completer<Map<String, dynamic>>>{};
ws.listen((raw) {
final m = jsonDecode(raw as String) as Map<String, dynamic>;
final rid = m['id'];
if (rid is int && pending.containsKey(rid)) pending.remove(rid)!.complete(m);
});
Future<Map<String, dynamic>> call(String me, [Map<String, dynamic> p = const {}]) {
final i = ++id;
final c = Completer<Map<String, dynamic>>();
pending[i] = c;
ws.add(jsonEncode({'id': i, 'method': me, 'params': p}));
return c.future.timeout(const Duration(seconds: 20));
}
Future<List<Map<String, dynamic>>> readConfig() async {
final r = await call('NymeaEnergy.GetLoadConfig');
return (r['params']['loadConfigs'] as List)
.cast<Map<String, dynamic>>()
.map((m) => Map<String, dynamic>.from(m))
.toList();
}
var dumps = 0;
Future<void> showTelemetry(String title) async {
final r = await call('NymeaEnergy.GetLoadTelemetry');
final p = r['params'] as Map<String, dynamic>;
if (dumpPrefix != null) {
final path = '${dumpPrefix}_${++dumps}.json';
File(path).writeAsStringSync(
const JsonEncoder.withIndent(' ').convert({'_title': title, 'telemetry': p}));
print('(charge utile brute écrite : $path)');
}
print('\n--- $title (cycle ${p['timestamp'] ?? 'AUCUN'}) ---');
for (final l in ((p['loads'] as List?) ?? const []).whereType<Map>()) {
print(' ${l['loadId']} available=${l['available']} '
'faultCode=${l['faultCode'] ?? '(aucun)'} '
'alloué=${l['allocatedW']}W → ${(l['decision'] as Map?)?['code']} '
'${jsonEncode((l['decision'] as Map?)?['params'] ?? {})}');
}
}
/// Aller-retour neutre : la config relue est-elle celle de départ, clé par clé ?
bool sameConfig(List<Map<String, dynamic>> a, List<Map<String, dynamic>> b) {
String canon(List<Map<String, dynamic>> l) {
final rows = [
for (final m in l)
jsonEncode({for (final k in m.keys.toList()..sort()) k: m[k]}),
]..sort();
return rows.join('\n');
}
return canon(a) == canon(b);
}
await call('JSONRPC.Hello', {'locale': 'fr_FR'});
await call('JSONRPC.SetNotificationStatus', {'enabled': true});
// ── Restauration seule : on rejoue le FICHIER, pas une relecture filtrée ───
if (restoreOnly) {
if (!backupFile.existsSync()) {
stderr.writeln('Aucune sauvegarde à « $backupPath » : rien à restaurer.');
stderr.writeln('Restaurer à la main plutôt que de deviner la config d\'origine.');
await ws.close();
exitCode = 1;
return;
}
final saved = (jsonDecode(backupFile.readAsStringSync())
as Map<String, dynamic>)['loadConfigs'] as List;
final original =
saved.cast<Map>().map((m) => Map<String, dynamic>.from(m)).toList();
print('Restauration verbatim depuis « $backupPath » '
'(${original.length} charge(s), lue le '
'${(jsonDecode(backupFile.readAsStringSync()) as Map)['readAt']}).');
final r = await call('NymeaEnergy.SetLoadConfig', {'loadConfigs': original});
print('réponse : ${jsonEncode(r['params'])}');
await Future<void>.delayed(const Duration(seconds: 3));
final back = await readConfig();
print(sameConfig(original, back)
? 'IDENTIQUE à la sauvegarde — restauration neutre OK.'
: 'DIVERGENCE après restauration :\n${jsonEncode(back)}');
await ws.close();
return;
}
final original = await readConfig();
print('--- CONFIGURATION LUE (référence de restauration) ---');
for (final m in original) {
print(' ${m['id']} ${m['adapter']} priority=${m['priority']} '
'domain="${m['domain'] ?? ''}"');
}
if (original.any((m) => m['id'] == kProbeLoadId)) {
print('\nUne charge de sonde traîne déjà sur cette box.');
print('Relancer avec --restore-only --yes (sauvegarde : $backupPath).');
await ws.close();
exitCode = 1;
return;
}
// La sauvegarde part sur le disque AVANT la première écriture. Un plantage entre les
// deux laisserait sinon le banc modifié sans référence de retour.
backupFile.writeAsStringSync(const JsonEncoder.withIndent(' ').convert({
'_note': 'Configuration de $host lue AVANT fault_probe.dart. À rejouer verbatim '
'par --restore-only. Ne pas reconstruire : renvoyer ces maps telles quelles.',
'host': host,
'readAt': DateTime.now().toIso8601String(),
'loadConfigs': original,
}));
print('\nSauvegarde écrite : $backupPath');
// La charge de sonde reprend la forme complète d'une LoadConfig — le plugin sérialise
// par méta-objet et le validateur de nymea rejette toute clé hors schéma.
final probe = <String, dynamic>{
'adapter': 'etmvariableload',
'domain': '',
'enabled': true,
// Pour `etmvariableload`, l'id de la charge EST le ThingId piloté. Celui-ci n'existe
// pas : c'est ce qui produit THING_MISSING.
'id': kProbeLoadId,
'label': kProbeLabel,
'maxPowerW': 1000,
'minOffS': 0,
'minOnS': 0,
// `dynamic` + `maxPowerW > 0` + `powerLevels` vide : la seule forme d'etmvariableload
// qui passe `LoadConfig::validate()` sans table de paliers (loadconfig.cpp:286-291).
// En `fixed`, la box exige des powerLevels commençant à 0 — refus en bloc sinon.
'mode': 'dynamic',
'needs': {'dailyDeadline': '', 'minEnergyWhPerDay': 0},
'powerLevels': <dynamic>[],
'priority': original.length + 1,
'relays': <dynamic>[],
'sgReady': <String, dynamic>{},
};
print('\n--- AJOUT de la charge de sonde ---');
final added =
await call('NymeaEnergy.SetLoadConfig', {'loadConfigs': [...original, probe]});
print(jsonEncode(added));
if ((added['params']?['energyError'] as String?) != 'EnergyErrorNoError') {
// Refus EN BLOC : la configuration n'a pas bougé, la sauvegarde n'a plus d'objet.
// Le motif du refus n'est PAS dans la réponse RPC — il est au journal de la box
// (`journalctl -u nymead`), et lui nomme la charge et la règle enfreinte.
print('Refus de la box — la configuration est inchangée, rien à restaurer.');
print('Motif : ssh etm@$host \'journalctl -u nymead --since "2 min ago"\' '
'| grep "SetLoadConfig rejetée"');
backupFile.deleteSync();
await ws.close();
exitCode = 1;
return;
}
try {
// Un cycle d'arbitrage dure une minute : la télémétrie ne reflète le changement
// qu'au cycle suivant. Conclure avant, c'est reprocher à la box de n'avoir pas
// encore tourné.
print('\n… attente de deux cycles d\'arbitrage (130 s)');
await Future<void>.delayed(const Duration(seconds: 130));
await showTelemetry('TÉLÉMÉTRIE avec la charge en défaut');
print('\n--- ClearLoadFault sur la charge en défaut ---');
final cleared = await call('NymeaEnergy.ClearLoadFault', {'loadId': kProbeLoadId});
print('réponse RPC : ${jsonEncode(cleared['params'])}');
print('→ ce que dit le RPC ne vaut PAS levée. Verdict ci-dessous, sur la télémétrie.');
await Future<void>.delayed(const Duration(seconds: 70));
await showTelemetry('TÉLÉMÉTRIE après ClearLoadFault');
} finally {
print('\n--- RESTAURATION verbatim de la configuration d\'origine ---');
final restored =
await call('NymeaEnergy.SetLoadConfig', {'loadConfigs': original});
print(jsonEncode(restored['params']));
await Future<void>.delayed(const Duration(seconds: 3));
final back = await readConfig();
print('\n--- VERDICT DE RESTAURATION ---');
if (sameConfig(original, back)) {
print('IDENTIQUE à la configuration lue au départ, clé par clé — '
'aller-retour neutre OK.');
backupFile.deleteSync();
print('Sauvegarde « $backupPath » retirée : elle n\'a plus d\'objet.');
} else {
print('DIVERGENCE — la sauvegarde « $backupPath » est CONSERVÉE.');
print('Relire, puis rejouer : dart run tools/rpc/fault_probe.dart $host '
'--restore-only --yes');
print('config relue :\n${const JsonEncoder.withIndent(' ').convert(back)}');
}
await ws.close();
}
}

View File

@ -0,0 +1,100 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
// Observation de la télémétrie d'arbitrage — LECTURE SEULE.
//
// Répond à deux questions que la lecture de code ne tranche pas :
//
// §7-5 « télémétrie vivante » — une trame arrive-t-elle sans qu'on la demande, et
// dans quel délai après un changement réel ?
// §7-6 « fraîcheur » — à quelle cadence le battement de cœur bat-il, et le
// `timestamp` avance-t-il vraiment d'un cycle à l'autre ?
//
// Le second point est le plus important : c'est le battement de cœur qui rend un arbitre
// FIGÉ détectable. Les trames continuent d'arriver tandis que leur `timestamp` cesse
// d'avancer. Sans lui, « installation stable » et « moteur arrêté » sont le même silence.
//
// Usage : dart tools/rpc/telemetry_watch.dart [host] [durée_s]
Future<void> main(List<String> args) async {
final host = args.isNotEmpty ? args[0] : '192.168.1.75';
final seconds = args.length > 1 ? int.parse(args[1]) : 180;
final ws = await WebSocket.connect('ws://$host:4444')
.timeout(const Duration(seconds: 8));
var id = 0;
final pending = <int, Completer<Map<String, dynamic>>>{};
final started = DateTime.now();
DateTime? lastFrameAt;
String? lastCycle;
var frames = 0;
String stamp() =>
'+${DateTime.now().difference(started).inSeconds.toString().padLeft(3)}s';
void report(String origin, Map<String, dynamic> p) {
frames++;
final now = DateTime.now();
final cycle = p['timestamp'] as String? ?? 'AUCUN';
final gap = lastFrameAt == null
? '—'
: '${now.difference(lastFrameAt!).inSeconds}s depuis la précédente';
final moved = lastCycle == null
? ''
: (cycle == lastCycle ? ' ⚠ CYCLE IDENTIQUE' : ' cycle avancé');
lastFrameAt = now;
lastCycle = cycle;
final loads = (p['loads'] as List?) ?? const [];
final budget = p['budget'] as Map?;
print('${stamp()} $origin cycle=$cycle ($gap)$moved');
print(' degradedMode=${p['degradedMode']} '
'budget=${budget == null ? 'ABSENT (repli L2)' : 'surplus ${budget['surplusW']} '
'/ alloué ${budget['allocatedW']} / reliquat ${budget['remainingW']}'}');
for (final l in loads.whereType<Map>()) {
final lock = l['lock'] as Map?;
final mech = l['mechanism'] as Map?;
final dec = l['decision'] as Map?;
print(' · ${l['loadId']} alloué=${l['allocatedW']}W '
'available=${l['available']}'
'${l['faultCode'] != null ? ' DÉFAUT=${l['faultCode']}' : ''}'
'${lock != null ? ' verrou=${lock['kind']}/${lock['remainingS']}s' : ''}'
'${mech != null ? ' ${mech['kind']}' : ''}'
' → ${dec?['code']} ${jsonEncode(dec?['params'] ?? {})}');
}
}
ws.listen((raw) {
final m = jsonDecode(raw as String) as Map<String, dynamic>;
final rid = m['id'];
if (rid is int && pending.containsKey(rid)) {
pending.remove(rid)!.complete(m);
} else if (m['notification'] == 'NymeaEnergy.LoadTelemetryChanged') {
report('◀ LoadTelemetryChanged', m['params'] as Map<String, dynamic>);
} else if (m['notification'] != null) {
print('${stamp()} ◀ ${m['notification']}');
}
});
Future<Map<String, dynamic>> call(String me, [Map<String, dynamic> p = const {}]) {
final i = ++id;
final c = Completer<Map<String, dynamic>>();
pending[i] = c;
ws.add(jsonEncode({'id': i, 'method': me, 'params': p}));
return c.future.timeout(const Duration(seconds: 15));
}
await call('JSONRPC.Hello', {'locale': 'fr_FR'});
await call('JSONRPC.SetNotificationStatus', {'enabled': true});
final snap = await call('NymeaEnergy.GetLoadTelemetry');
report('■ GetLoadTelemetry (instantané)', snap['params'] as Map<String, dynamic>);
print('\n… écoute pendant ${seconds}s\n');
await Future<void>.delayed(Duration(seconds: seconds));
print('\n--- RELEVÉ ---');
print('trames reçues (instantané compris) : $frames en ${seconds}s');
print('dernier cycle vu : $lastCycle');
await ws.close();
}