tools(rpc): une sonde qui dit ce que l'app AFFICHERAIT de measurement

Les règles d'affichage de `measurement` sont figées par test sur des trames fabriquées.
Cette sonde les éprouve sur les trames réelles de la box — en important le modèle de l'app
(LoadTelemetryEntry.fromJson), pas en relisant le JSON à sa façon : une lecture parallèle
ne prouverait rien sur ce que l'écran fera.

Elle montre ce 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 encore publié ? false
  chauffe-eau   source=meter → Mesuré 1500 W · mesure indépendante : concordance = preuve
  pac-terrain   source=meter → Mesuré 800 W  · idem
  V2C Trydan    absente de loads[] — pas `none`

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQbZKrWqsMFP1Lh2jjjd9f
This commit is contained in:
Patrick Schurig 2026-08-28 15:32:52 +02:00
parent e362fb7430
commit 771345f1bc
2 changed files with 79 additions and 0 deletions

View File

@ -24,6 +24,7 @@ dart tools/rpc/<script>.dart [host] [args…] # host par défaut : 192.168.1
| `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** |
@ -111,6 +112,22 @@ avant que le câble ne bouge ; et `powerL1/L2/L3` sont des **ampères mal nommé
deux ne doit être affiché tant que le plugin n'a pas publié `sessionEnergy` et le renommage
`currentL1/L2/L3`.
### `measurement_check.dart`
```bash
dart run tools/rpc/measurement_check.dart 192.168.1.75
```
⚠️ `dart run` : la sonde **importe le modèle de l'app** (`LoadTelemetryEntry.fromJson`)
plutôt que de relire le JSON à sa façon — une lecture parallèle ne prouverait rien sur ce
que l'écran fera. Elle rend, par charge, la source publiée et la phrase que l'app en tire.
Ce qu'elle sert à voir, et qu'un écran regardé de loin ne distingue pas : `none` (bloc
masqué, « pas mesurable ») d'une charge simplement **absente de `loads[]`** (rien à
conclure), et la portée d'un écart selon `meter` ou `device`. Relevé du 2026-08-28 sur
`.75` en `1.15.2+etm26` : `measuredW` n'est plus publié, `chauffe-eau` et `pac-terrain`
sont en `meter` (1 500 W et 800 W), la V2C est absente — pas `none`.
### `domain_assign.dart` — ⚠️ écrit
```bash

View File

@ -0,0 +1,62 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:etm_powersync_app/models/load_telemetry.dart';
// Lit `NymeaEnergy.GetLoadTelemetry` et dit, charge par charge, **ce que l'app afficherait**
// — en passant par le vrai modèle de l'app, pas par une lecture parallèle du JSON.
//
// ## Pourquoi cette sonde plutôt qu'un coup d'œil à l'écran
//
// Les règles d'affichage de `measurement` sont figées par test sur des trames fabriquées.
// Ceci vérifie la même chose sur les trames RÉELLES de la box, et c'est la seule façon de
// voir que le contrat déployé correspond à ce que l'app attend. Un écran regardé de loin ne
// distingue pas « pas mesurable » d'un bloc simplement absent.
//
// LECTURE SEULE. Usage : dart run tools/rpc/measurement_check.dart [host]
Future<void> main(List<String> args) async {
final host = args.isNotEmpty ? args[0] : '192.168.1.75';
final ws = await WebSocket.connect('ws://$host:4444')
.timeout(const Duration(seconds: 8));
final done = Completer<Map<String, dynamic>>();
ws.listen((d) {
final m = jsonDecode(d as String) as Map<String, dynamic>;
if (m['id'] == 2) done.complete(m);
});
ws.add(jsonEncode({'id': 1, 'method': 'JSONRPC.Hello', 'params': {}}));
ws.add(jsonEncode(
{'id': 2, 'method': 'NymeaEnergy.GetLoadTelemetry', 'params': {}}));
final r = await done.future.timeout(const Duration(seconds: 10));
await ws.close();
final p = r['params'] as Map<String, dynamic>;
final loads = (p['loads'] as List? ?? const []);
final brut = jsonEncode(p);
print('measuredW encore publié ? ${brut.contains('"measuredW"')} '
'(doit être false depuis 1.15.2+etm26)');
print('${loads.length} charge(s) dans loads[] — et `measurement` est présent pour '
'chacune, pour elles seules.\n');
for (final l in loads.whereType<Map>()) {
final e = LoadTelemetryEntry.fromJson(Map<String, dynamic>.from(l));
final m = e.measurement;
final ligne = switch (m.source) {
MeasurementSource.none =>
'bloc de mesure MASQUÉ — « pas mesurable » est un état, pas un vide',
MeasurementSource.meter =>
'Mesuré ${m.powerW!.round()} W · mesure indépendante : une concordance est une preuve',
MeasurementSource.device =>
'Mesuré ${m.powerW!.round()} W · publiée par l\'appareil : une concordance ne prouve rien',
MeasurementSource.inconnue =>
'Mesuré ${m.powerW?.round()} W · source inconnue de cette app, aucune portée affichée',
};
print(' ${e.loadId.padRight(38)} alloué ${e.allocatedW.round()} W');
print(' ${' '.padRight(38)} source=${m.source.name} → $ligne');
}
print('\nToute charge de GetLoadConfig ABSENTE de cette liste n\'est pas `none` :');
print('elle est hors de loads[], et l\'app n\'en conclut rien.');
}