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:
parent
dee20896c5
commit
40ea285663
146
RECAP.md
146
RECAP.md
@ -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
|
||||
|
||||
@ -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. |
|
||||
|
||||
198
tools/rpc/domain_assign.dart
Normal file
198
tools/rpc/domain_assign.dart
Normal 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
272
tools/rpc/fault_probe.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
100
tools/rpc/telemetry_watch.dart
Normal file
100
tools/rpc/telemetry_watch.dart
Normal 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();
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user