test(config): les deux notes sont MONTÉES, pas seulement calculées

Vérification demandée après le relevé V2C du banc : « ta note de rangs ex æquo doit
s'afficher — vérifie qu'elle le fait ». Elle s'affiche. Mais rien ne le prouvait, et
c'est exactement le défaut qui a coûté isNotGrouped : la contiguïté était calculée
depuis le premier jour, testée en fonction, exposée par le provider — et affichée nulle
part. Un test de fonction ne peut pas voir ça.

Quatre tests de widgets sur la configuration réelle du 2026-08-28
(loadconfig_hems75_v2c.json), montés dans le harnais existant :

  · la note de rangs ex æquo est à l'écran ET nomme les charges (chauffe-eau, V2C
    Trydan) — « des rangs sont à égalité » sans dire lesquelles laisse chercher dans
    cinq cartes ;
  · la note d'ordre entrelacé est à l'écran ET annonce ce que l'enregistrement fera
    (REGROUPERA) — l'annoncer après serait un constat, pas un avertissement ;
  · contrôle négatif : sur un ordre groupé et classé, aucune des deux n'apparaît. Sans
    lui, deux notes toujours affichées deviennent du décor ;
  · THING_MISSING se lit « l'appareil n'existe plus dans nymea » + « réinstallez-le, ou
    retirez cette charge », et n'offre RIEN à lever — ni le bouton, ni l'aide qui
    l'accompagne.

⚠️ La télémétrie du quatrième test est COMPOSÉE, pas capturée : le banc publiait
WRITE_FAILED sur cette charge au dernier relevé (l'adaptateur épuise son échelle
d'écriture avant que THING_MISSING ne sorte). Le test éprouve le rendu du code sur le
loadId réel de la Terra AC supprimée, pas la présence du code sur la box.

Et deux traces de la campagne V2C, qui ne se câblent pas :

· tools/rpc/evcharger_watch.dart entre au dépôt, documenté. C'est la sonde qui a
  produit les deux constats bloquants — une borne hors arbitrage n'apparaît pas dans
  telemetry_watch, et c'est pendant ces transitions qu'il faut la regarder.
· ev_charging_card.dart affirmait que « les deux bornes du banc publient sessionEnergy ».
  Faux depuis l'arrivée de la V2C : elle publie chargeEnergy, remis à zéro à chaque
  interruption de charge. La note renvoie désormais à LoadMechanism et redit C1 de
  LM-1009 — « pas mesurable », jamais zéro.

TODO : entrée d'attente pour sessionEnergy et le renommage currentL1/L2/L3.

146 tests passent, flutter analyze sans remarque nouvelle.

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 12:18:00 +02:00
parent 553a182e9b
commit 935cec269f
5 changed files with 271 additions and 3 deletions

11
TODO.md
View File

@ -35,6 +35,17 @@
prêtes, les motifs n'ont **jamais été vus sur machine**. Signaler la première occurrence
réelle. Ne rien bâtir autour d'ici là.
- [ ] ⏳ **Deux grandeurs de la V2C attendent le plugin — ne rien câbler d'ici là.**
Mesuré sur `.75` le 2026-08-28, sonde `tools/rpc/evcharger_watch.dart`.
- `chargeEnergy` est **exact pendant la charge** et **remis à zéro à chaque
interruption** — donc à chaque nuage sur du pilotage par surplus. Le plugin V2C
publiera un `sessionEnergy` construit ; attendre celui-là, et **n'accumuler rien côté
app**. D'ici là, la condition C1 de LM-1009 s'applique en vrai sur cette borne :
« pas mesurable » et « rien livré » sont deux états différents, et zéro dit le second.
- `powerL1/L2/L3` sont des **ampères mal nommés** ; le renommage `currentL1/L2/L3`
(unité Ampere) est en cours côté plugin. Ne pas les afficher avant son déploiement, et
**n'écrire aucune conversion côté app** — elle survivrait au correctif.
- [ ] **`EVChargingCard` n'est montée par AUCUN écran** (constaté le 2026-08-27 — le
tableau de bord utilise `features/dashboard/widgets/`). Soit elle rejoint un écran, soit
elle part ; en l'état c'est du code mort qui a l'air vivant.

View File

@ -472,9 +472,16 @@ class _ModeButton extends StatelessWidget {
///
/// **aucun SOC de véhicule n'existe dans ce contrat.** Vérifié sur `.75` le 2026-08-27,
/// sur les 58 classes de la box : aucune classe portant l'interface `evcharger` ne
/// déclare d'état de charge, aucune classe ne porte l'interface `car`, et les deux bornes
/// du banc publient `pluggedIn`, `charging`, `maxChargingCurrent`, `phaseCount`,
/// `sessionEnergy` — jamais un pourcentage.
/// déclare d'état de charge, aucune classe ne porte l'interface `car`, et les bornes du
/// banc publient `pluggedIn`, `charging`, `maxChargingCurrent` — jamais un pourcentage.
///
/// **Et l'énergie livrée n'est pas là non plus, sur la borne du banc.** La V2C Trydan
/// arrivée le 2026-08-28 ne publie ni `sessionEnergy` ni `phaseCount` : elle publie
/// `chargeEnergy`, exact pendant la charge et **remis à zéro à chaque interruption** —
/// donc à chaque nuage sur du pilotage par surplus. Le détail et la mesure sont dans
/// `LoadMechanism` (`models/load_telemetry.dart`). Tant que le plugin ne publie pas le
/// `sessionEnergy` construit qu'il prépare, l'avancement de LM-1009 se dit **« pas
/// mesurable »** — et surtout pas zéro, qui se lirait « rien livré ».
///
/// Le seul `batteryLevel` de l'installation est celui de la **batterie de la maison**
/// (Fronius Storage, 50 %), rendu visible par le correctif `energystorage` du plugin

View File

@ -227,6 +227,101 @@ void main() {
FieldSupport.unknown);
});
});
// ───────────────────────────────────────────────────────────────────────────
// Les deux notes de la configuration réelle du 2026-08-28 — MONTÉES, pas
// seulement calculées.
//
// `tiedByPriority` et `GroupedOrder.isContiguous` ont leurs tests de fonction dans
// `evcharger_config_test.dart`, et ils passaient déjà le 2026-08-27 : la contiguïté
// était calculée, exposée par le provider, et **affichée nulle part**. Un test de
// fonction ne pouvait pas le voir. Ceux-ci montent l'écran et cherchent le texte.
group('terrain 2026-08-28 — les notes s\'affichent VRAIMENT', () {
List<Map<String, dynamic>> v2cConfig() =>
(_fixture('loadconfig_hems75_v2c.json')['loadConfigs'] as List)
.cast<Map<String, dynamic>>();
testWidgets('la note de rangs ex æquo est à l\'écran, et elle nomme les charges',
(tester) async {
final config = LoadConfigProvider()..adoptForTest(v2cConfig());
final telemetry = LoadTelemetryProvider()
..adoptForTest({'degradedMode': false, 'loads': const []});
await tester.pumpWidget(_harness(config: config, telemetry: telemetry));
await tester.pump();
final note = find.textContaining('Rangs à égalité');
expect(note, findsOneWidget);
// Nommer les charges est le point : « des rangs sont à égalité » sans dire
// lesquelles laisse l'installateur chercher dans cinq cartes.
final texte = tester.widget<Text>(note).data!;
expect(texte, contains('chauffe-eau'));
expect(texte, contains('V2C Trydan'));
});
testWidgets('la note d\'ordre entrelacé est à l\'écran, et annonce l\'effet de l\'enregistrement',
(tester) async {
final config = LoadConfigProvider()..adoptForTest(v2cConfig());
final telemetry = LoadTelemetryProvider()
..adoptForTest({'degradedMode': false, 'loads': const []});
await tester.pumpWidget(_harness(config: config, telemetry: telemetry));
await tester.pump();
final note = find.textContaining('n\'est pas groupé par domaine');
expect(note, findsOneWidget);
// Ce que l'enregistrement fera doit être dit AVANT, pas constaté après.
expect(tester.widget<Text>(note).data!, contains('REGROUPERA'));
});
testWidgets('un ordre déjà groupé et classé ne montre AUCUNE des deux notes',
(tester) async {
// Le contrôle négatif : les deux notes doivent disparaître d'elles-mêmes au
// premier vrai réglage, sinon elles deviennent du décor qu'on n'ouvre plus.
final config = LoadConfigProvider()..adoptForTest(_benchConfig());
final telemetry = LoadTelemetryProvider()
..adoptForTest({'degradedMode': false, 'loads': const []});
await tester.pumpWidget(_harness(config: config, telemetry: telemetry));
await tester.pump();
expect(find.textContaining('Rangs à égalité'), findsNothing);
expect(find.textContaining('n\'est pas groupé par domaine'), findsNothing);
});
testWidgets('THING_MISSING se lit « appareil absent », et n\'offre RIEN à lever',
(tester) async {
// ⚠️ Télémétrie COMPOSÉE ici, pas capturée : le banc publiait `WRITE_FAILED` sur
// cette charge au dernier relevé (l'adaptateur épuise son échelle d'écriture avant
// que `THING_MISSING` ne sorte). Ce test éprouve donc le RENDU du code, sur le
// `loadId` réel de la Terra AC supprimée — pas la présence du code sur la box.
const terra = '{88160e45-7df2-48cc-9639-f0d15ae6e382}';
final config = LoadConfigProvider()..adoptForTest(v2cConfig());
final telemetry = LoadTelemetryProvider()..adoptForTest({
'degradedMode': false,
'loads': [
{
'loadId': terra,
'allocatedW': 0,
'available': false,
'faultCode': 'THING_MISSING',
'decision': {'code': 'NO_BUDGET', 'params': const {}},
},
],
});
await tester.pumpWidget(_harness(config: config, telemetry: telemetry));
await tester.pump();
// Le bouton ne peut pas tenir sa promesse — il n'existe pas.
expect(find.text('Lever le défaut'), findsNothing);
expect(find.textContaining('La levée est une action délibérée'), findsNothing);
// À la place : les deux gestes qui, eux, résolvent le cas.
expect(find.textContaining('Réinstallez-le dans nymea'), findsOneWidget);
// Et le libellé ne doit pas se lire « votre configuration est cassée ».
expect(find.textContaining('n\'existe plus dans nymea'), findsOneWidget);
});
});
}
extension on RpcSchema {

View File

@ -23,6 +23,7 @@ dart tools/rpc/<script>.dart [host] [args…] # host par défaut : 192.168.1
| `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 |
| `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** |
@ -85,6 +86,24 @@ distingue une installation stable d'un moteur arrêté — les deux produisent l
battements de cœur. Relevé du 2026-08-25 sur `.75` : battement à ~59 s, cycle qui avance
d'une minute, et une trame de changement significatif dans la foulée.
### `evcharger_watch.dart`
```bash
dart tools/rpc/evcharger_watch.dart 192.168.1.75 600 V2C # 600 s, bornes dont le nom contient « V2C »
```
`telemetry_watch.dart` ne montre une borne que si elle est **arbitrée** ; sans véhicule
branché elle n'entre pas dans `loads[]` (depuis `+etm24`), et c'est justement pendant ces
transitions qu'il faut voir ce que le Thing publie. Cette sonde s'abonne aux états du Thing
et n'imprime que ce qui change.
Relevé du 2026-08-28 sur la V2C Trydan de `.75` — les deux constats qui bloquent LM-1009 :
`chargeEnergy` est **exact pendant la charge** (0,3456 kWh, croissance qui recoupe
`currentPower` à mieux d'un pour cent) mais **remis à zéro à l'arrêt de la charge**, 119 s
avant que le câble ne bouge ; et `powerL1/L2/L3` sont des **ampères mal nommés**. Aucun des
deux ne doit être affiché tant que le plugin n'a pas publié `sessionEnergy` et le renommage
`currentL1/L2/L3`.
### `domain_assign.dart` — ⚠️ écrit
```bash

View File

@ -0,0 +1,136 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
// Observation d'une BORNE de recharge, état par état — LECTURE SEULE.
//
// ## Pourquoi cette sonde existe
//
// `telemetry_watch.dart` regarde l'arbitrage ; il ne montre une borne que si elle est
// arbitrée. Or une borne passe l'essentiel de son temps DEHORS : sans véhicule branché,
// elle n'entre pas dans `loads[]` (comportement depuis `+etm24`). C'est justement pendant
// ces transitions — branchement, démarrage de charge, montée en puissance — qu'on a besoin
// de voir ce que le Thing publie.
//
// Elle sert notamment à trancher une question que le contrat ne documente pas : quelles
// bornes publient `sessionEnergy` (l'énergie de la session, remise à zéro au branchement)
// et lesquelles ne publient qu'un cumul. La décision LM-1009 en dépend — l'avancement
// d'une recharge s'affiche en énergie livrée, et sur une borne qui n'en publie pas, il
// faut dire « pas mesurable » plutôt qu'afficher zéro.
//
// N'ÉCRIT RIEN : ni action, ni configuration. Elle s'abonne et regarde.
//
// Usage : dart tools/rpc/evcharger_watch.dart [host] [durée_s] [filtre_nom]
// dart tools/rpc/evcharger_watch.dart 192.168.1.75 600 V2C
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]) : 300;
final filtre = args.length > 2 ? args[2].toLowerCase() : '';
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();
String stamp() =>
'+${DateTime.now().difference(started).inSeconds.toString().padLeft(4)}s';
// thingId → nom ; stateTypeId → nom d'état ; et la dernière valeur vue, pour
// n'imprimer que ce qui CHANGE — une borne qui republie la même valeur toutes les
// secondes noierait la transition qu'on guette.
final noms = <String, String>{};
final etats = <String, String>{};
final unites = <String, String>{};
final dernier = <String, 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);
return;
}
if (m['notification'] != 'Integrations.StateChanged') return;
final p = m['params'] as Map<String, dynamic>;
final thingId = p['thingId'] as String?;
final nom = noms[thingId];
if (nom == null) return; // pas une borne suivie
final etat = etats[p['stateTypeId'] as String? ?? ''] ?? '?';
final valeur = '${p['value']}';
final cle = '$thingId/$etat';
if (dernier[cle] == valeur) return; // inchangé : on se tait
dernier[cle] = valeur;
final u = unites[p['stateTypeId'] as String? ?? ''] ?? '';
stdout.writeln('${stamp()} $nom · $etat = $valeur $u');
});
Future<Map<String, dynamic>> call(String method,
[Map<String, dynamic> params = const {}]) {
final i = ++id;
final c = Completer<Map<String, dynamic>>();
pending[i] = c;
ws.add(jsonEncode({'id': i, 'method': method, 'params': params}));
return c.future.timeout(const Duration(seconds: 20));
}
await call('JSONRPC.Hello', {'locale': 'fr_FR'});
await call('JSONRPC.SetNotificationStatus', {
'namespaces': ['Integrations']
});
final classes = (await call('Integrations.GetThingClasses'))['params']
['thingClasses'] as List;
final bornes = <String>{};
for (final c in classes.cast<Map<String, dynamic>>()) {
final ifs = (c['interfaces'] as List?)?.cast<String>() ?? const [];
if (!ifs.contains('evcharger')) continue;
bornes.add(c['id'] as String);
for (final s in (c['stateTypes'] as List? ?? []).cast<Map<String, dynamic>>()) {
etats[s['id'] as String] = s['name'] as String;
final u = s['unit'] as String? ?? '';
unites[s['id'] as String] =
(u.isEmpty || u == 'UnitNone') ? '' : u.replaceFirst('Unit', '');
}
}
final things =
(await call('Integrations.GetThings'))['params']['things'] as List;
stdout.writeln('── bornes déclarées sur $host ${'─' * 42}');
for (final t in things.cast<Map<String, dynamic>>()) {
if (!bornes.contains(t['thingClassId'])) continue;
final nom = t['name'] as String;
if (filtre.isNotEmpty && !nom.toLowerCase().contains(filtre)) continue;
noms[t['id'] as String] = nom;
final vals = <String, String>{};
for (final s in (t['states'] as List? ?? []).cast<Map<String, dynamic>>()) {
final n = etats[s['stateTypeId'] as String] ?? '?';
vals[n] = '${s['value']}';
dernier['${t['id']}/$n'] = '${s['value']}';
}
stdout.writeln('\n■ $nom (${(t['id'] as String).substring(0, 8)})');
// L'énergie d'abord : c'est ce que la décision LM-1009 attend, et sa PRÉSENCE est
// l'information — une borne sans `sessionEnergy` rend l'avancement non mesurable.
for (final k in ['sessionEnergy', 'chargeEnergy', 'totalEnergyConsumed']) {
stdout.writeln(vals.containsKey(k)
? ' $k = ${vals[k]}'
: ' $k — ABSENT de cette borne');
}
for (final k in ['pluggedIn', 'charging', 'power', 'phaseCount',
'maxChargingCurrent', 'currentPower', 'intensity']) {
if (vals.containsKey(k)) stdout.writeln(' $k = ${vals[k]}');
}
}
if (noms.isEmpty) {
stdout.writeln(' aucune borne ne correspond au filtre « $filtre »');
await ws.close();
return;
}
stdout.writeln('\n── changements pendant ${seconds}s ${'─' * 40}');
stdout.writeln('(seules les valeurs qui CHANGENT sont imprimées)\n');
await Future<void>.delayed(Duration(seconds: seconds));
stdout.writeln('\n── fin ${'─' * 54}');
await ws.close();
}