diff --git a/TODO.md b/TODO.md index 860cb8a..179655a 100644 --- a/TODO.md +++ b/TODO.md @@ -52,6 +52,18 @@ borne avec/sans accolades), la règle, la façon de le chercher, et un renvoi depuis les deux sites de code plus `tools/rpc/README.md` — là où quelqu'un croise deux réponses. +- [ ] **Plugin V2C — déclarer `smartmeter` sur la classe `trydan`, ce qui suppose un + cumul d'énergie.** Relevé du 2026-08-28 : la classe `wallbox` (borne simulée) déclare + `connectable, evcharger, power, smartmeter, smartmeterconsumer` ; la classe `trydan` n'a + que `connectable, evcharger, power`. La V2C n'est donc **pas proposée comme compteur** — + l'app a élargi son sélecteur aux bornes publiant `currentPower` (correctif du jour), mais + c'est un contournement de l'écart, pas sa disparition. + **Le manque est de fond, et c'est le même que celui qui a bloqué LM-1009** : l'interface + `smartmeter` attend un **cumul** d'énergie ; la wallbox simulée publie + `totalEnergyConsumed`, la Trydan n'a que `sessionEnergy` / `chargeEnergy` — une session, + pas un total. À chercher dans la carte Modbus V2C, ou à **construire** comme + `sessionEnergy` l'a été (`1.15.2+etm13`). C'est un lot plugin, pas un correctif d'app. + - [ ] **Historique des états d'une borne — `sessionEnergy` n'est pas enregistré.** `suggestLogging` est écrasé par les préférences persistées de `things.conf` sur un Thing déjà appairé, et la V2C n'y a que `connected`, `charging`, `power`. La valeur courante est diff --git a/lib/providers/load_config_provider.dart b/lib/providers/load_config_provider.dart index d2b9903..a556188 100644 --- a/lib/providers/load_config_provider.dart +++ b/lib/providers/load_config_provider.dart @@ -346,6 +346,7 @@ class LoadConfigProvider extends ChangeNotifier { final service = _service; if (service == null) return 'NoService'; if (_draft != null) return 'PendingDraft'; + _removalDiff = const []; // 1. LIRE — la charge utile de la box, à cet instant. final List> frais; @@ -380,17 +381,54 @@ class LoadConfigProvider extends ChangeNotifier { return err; } - // L'acquittement ne vaut pas application : on attend `LoadConfigChanged`, avec le même - // filet de relecture que `save()`. + // 4. RELIRE ET VÉRIFIER, tout de suite — pas « la prochaine fois ». + // + // L'acquittement ne vaut pas application, et sur le SEUL geste qui détruit de la + // configuration, « la box a dit oui » ne suffit pas : il faut voir ce qu'elle porte + // ensuite. La relecture est immédiate et synchrone du point de vue de l'appelant — + // attendre `LoadConfigChanged` laisserait une fenêtre pendant laquelle un autre geste + // peut s'intercaler, et c'est exactement ce qui a rendu la vérification manuelle du + // 2026-08-28 inconcluante : deux écritures à deux minutes d'écart, impossible de dire + // laquelle avait bougé les rangs. + List> relu; + try { + relu = await service.getLoadConfig(); + } catch (e) { + _saveState = LoadSaveState.diverged; + _saveError = 'Suppression envoyée, mais la relecture a échoué ($e).'; + _expectedSignature = null; + notifyListeners(); + return 'RereadFailed'; + } + + _adopt(relu); + _expectedSignature = null; _confirmTimeout?.cancel(); - _confirmTimeout = Timer(const Duration(seconds: 6), () async { - if (_saveState != LoadSaveState.pending) return; - await load(); - _reconcile(); - }); - return null; + + // Le contrôle de l'aller-retour neutre, appliqué à une écriture qui retire : les + // survivantes doivent être identiques clé par clé, rangs compris. + final ecarts = diffPayloads(reste, relu); + if (ecarts.isEmpty) { + _saveState = LoadSaveState.confirmed; + _saveError = null; + notifyListeners(); + return null; + } + + _saveState = LoadSaveState.diverged; + _removalDiff = ecarts; + _saveError = 'La charge est retirée, mais ${ecarts.length} autre(s) écart(s) : ' + '${ecarts.take(3).join(' · ')}'; + notifyListeners(); + return 'Diverged'; } + /// Écarts constatés par la dernière suppression — vide quand elle n'a touché à rien + /// d'autre. Ce n'est pas un échec de l'app : c'est ce que la box porte, et il faut le + /// montrer plutôt que de le taire. + List get removalDiff => List.unmodifiable(_removalDiff); + List _removalDiff = const []; + void _onBoxConfirmed(List> configs) { _adopt(configs); _reconcile(); diff --git a/lib/screens/energy/load_mechanism_screen.dart b/lib/screens/energy/load_mechanism_screen.dart index 5977ad1..55ea254 100644 --- a/lib/screens/energy/load_mechanism_screen.dart +++ b/lib/screens/energy/load_mechanism_screen.dart @@ -1571,6 +1571,13 @@ class _CompteurEtSonde extends StatelessWidget { child: ThingPicker( service: service, interfaceRequise: 'smartmeter', + // Le moteur ne filtre sur aucune interface : `findConfiguredThing` puis + // `hasState("currentPower")`. Une borne qui publie sa puissance est donc + // un compteur valable — et c'est le seul moyen de voir « mesuré » sur + // la carte d'une borne dont la classe ne déclare pas `smartmeter` + // (la V2C Trydan, contrairement à la wallbox simulée). + interfacesAussi: const ['evcharger'], + etatRequis: 'currentPower', valeur: entry.meterThingId.isEmpty ? null : entry.meterThingId, placeholder: 'Choisir un appareil de mesure…', onChoisi: (id) => provider.replaceEntry( diff --git a/lib/services/load_priority.dart b/lib/services/load_priority.dart index 33ede79..783bb9b 100644 --- a/lib/services/load_priority.dart +++ b/lib/services/load_priority.dart @@ -151,6 +151,66 @@ List>? payloadWithoutLoad( return reste.length == frais.length ? null : reste; } +/// Compare ce qu'on a **envoyé** à ce que la box **porte ensuite**, clé par clé. +/// +/// C'est le contrôle de l'aller-retour neutre (`tools/rpc/loadconfig_roundtrip.dart`) +/// appliqué à une écriture qui **retire** : une suppression ne doit changer que la +/// présence d'une charge, jamais un champ d'une autre — pas même un rang. +/// +/// Il a fallu le faire faire à l'app plutôt qu'à l'opérateur : le 2026-08-28, la +/// vérification a été faite à la main, après un autre geste, et il est devenu impossible +/// de dire laquelle des deux écritures avait bougé les rangs. Une vérification qui arrive +/// trop tard ne prouve rien. +/// +/// Rend la liste des écarts, vide quand tout concorde. Les libellés sont destinés à être +/// LUS par quelqu'un qui vient de voir sa configuration se réécrire. +List diffPayloads( + List> envoye, List> relu) { + final ecarts = []; + final aId = {for (final m in envoye) m['id'] as String? ?? '': m}; + final bId = {for (final m in relu) m['id'] as String? ?? '': m}; + + for (final entry in aId.entries) { + final autre = bId[entry.key]; + if (autre == null) { + ecarts.add('${entry.key} a disparu'); + continue; + } + final champs = {...entry.value.keys, ...autre.keys}; + for (final k in champs) { + final av = entry.value[k]; + final ap = autre[k]; + if (!_memeValeur(av, ap)) { + ecarts.add('${entry.key} · $k : $av → $ap'); + } + } + } + for (final id in bId.keys) { + if (!aId.containsKey(id)) ecarts.add('$id est apparue'); + } + return ecarts; +} + +/// Égalité structurelle — les charges utiles portent des listes et des objets imbriqués +/// (`relays`, `sgReady`, `powerLevels`), que `==` compare par référence. +bool _memeValeur(Object? a, Object? b) { + if (a is Map && b is Map) { + if (a.length != b.length) return false; + for (final k in a.keys) { + if (!b.containsKey(k) || !_memeValeur(a[k], b[k])) return false; + } + return true; + } + if (a is List && b is List) { + if (a.length != b.length) return false; + for (var i = 0; i < a.length; i++) { + if (!_memeValeur(a[i], b[i])) return false; + } + return true; + } + return a == b; +} + /// Reconstitue les deux niveaux depuis la liste brute de `GetLoadConfig`. /// /// Le rang de domaine est l'**ordre d'apparition** dans l'ordre à plat trié par diff --git a/lib/widgets/load_cards.dart b/lib/widgets/load_cards.dart index f649cae..e8619ea 100644 --- a/lib/widgets/load_cards.dart +++ b/lib/widgets/load_cards.dart @@ -440,12 +440,19 @@ class _BoutonRetirerState extends State<_BoutonRetirer> { if (!mounted) return; setState(() => _enCours = false); + // La suppression relit et compare elle-même : le message dit ce que la box PORTE + // ensuite, pas ce qu'elle a acquitté. + final ecarts = provider.removalDiff; messenger.showSnackBar(SnackBar( + duration: Duration(seconds: err == null ? 4 : 10), + backgroundColor: err == null ? null : const Color(0xFF7F1D1D), content: Text(err == null - // « Demandée » et non « faite » : l'acquittement du RPC ne vaut pas application, - // c'est `LoadConfigChanged` qui tranche — et la liste se mettra à jour seule. - ? 'Suppression demandée. La liste se met à jour dès que la box confirme.' - : _messageErreur(err)), + ? 'Charge retirée. Relu sur la box : les autres sont identiques clé par clé, ' + 'rangs compris.' + : err == 'Diverged' + ? 'Charge retirée, MAIS la box a changé autre chose :\n' + '${ecarts.take(4).join('\n')}' + : _messageErreur(err)), )); } @@ -455,6 +462,9 @@ class _BoutonRetirerState extends State<_BoutonRetirer> { 'NotFound' => 'Cette charge n\'est plus dans la configuration de la box — rien à retirer.', 'NoService' => 'Aucune connexion à la box.', + 'RereadFailed' => + 'Suppression envoyée, mais impossible de relire la configuration pour le ' + 'vérifier. Rouvrez l\'écran avant tout autre geste.', _ => 'La box a refusé la suppression ($code). Rien n\'a été modifié.', }; diff --git a/lib/widgets/thing_picker.dart b/lib/widgets/thing_picker.dart index eb934d8..e1a36d4 100644 --- a/lib/widgets/thing_picker.dart +++ b/lib/widgets/thing_picker.dart @@ -26,6 +26,31 @@ class ThingPicker extends StatelessWidget { /// Interface nymea exigée (`power`, `etmvariableload`, …). final String interfaceRequise; + /// Interfaces **acceptées en plus** de [interfaceRequise], à condition que le Thing + /// publie [etatRequis]. + /// + /// ## Pourquoi une porte de sortie au filtre + /// + /// Le sélecteur de compteur filtrait sur `smartmeter`, et se trouvait donc **plus + /// restrictif que la box**. Le moteur, lui, ne regarde aucune interface : il fait + /// `findConfiguredThing(meterThingId)` puis `hasState("currentPower")` + /// (`attachMeasurements`, LM-1101). Une borne qui publie `currentPower` est un compteur + /// parfaitement valable pour lui. + /// + /// Constaté sur `.75` le 2026-08-28 : la classe `wallbox` (borne simulée) déclare + /// `smartmeter`, la classe `trydan` (V2C) non — elle n'a que `connectable, evcharger, + /// power`. La V2C était donc **impossible à désigner comme son propre compteur**, alors + /// que la box l'aurait accepté et que c'est le seul moyen de voir « mesuré » à côté de + /// « alloué » sur sa carte. + /// + /// Le filtre reste : c'est lui qui rend la liste utilisable. Il s'élargit là où la box + /// est plus large, pas partout. + final List interfacesAussi; + + /// État que doit publier un Thing retenu par [interfacesAussi] — le même que le moteur + /// exige. Sans lui, l'élargissement proposerait des appareils qui ne mesurent rien. + final String? etatRequis; + /// ThingId actuellement choisi, forme `{uuid}` ou nue. `null` = rien de choisi. final String? valeur; @@ -49,17 +74,33 @@ class ThingPicker extends StatelessWidget { required this.onChoisi, this.placeholder = 'Choisir un appareil…', this.dejaPris = const {}, + this.interfacesAussi = const [], + this.etatRequis, }); - /// Things portant [interfaceRequise], triés par nom. + /// Things portant [interfaceRequise] — plus ceux de [interfacesAussi] qui publient + /// [etatRequis]. Triés par nom. List _candidats() { - final classesOk = { - for (final c in service.thingClasses) - if (c.interfaces.contains(interfaceRequise)) c.id - }; + final classesOk = {}; + final classesElargies = {}; + for (final c in service.thingClasses) { + if (c.interfaces.contains(interfaceRequise)) { + classesOk.add(c.id); + } else if (interfacesAussi.any(c.interfaces.contains)) { + // L'élargissement se juge sur la CLASSE (l'état est déclaré là), pas sur la + // valeur courante : un Thing qui publie `currentPower` reste un compteur valable + // même quand cette puissance vaut zéro. + if (etatRequis == null || + c.stateTypes.any((st) => st.name == etatRequis)) { + classesElargies.add(c.id); + } + } + } final out = [ for (final t in service.things) - if (classesOk.contains(t.thingClassId)) t + if (classesOk.contains(t.thingClassId) || + classesElargies.contains(t.thingClassId)) + t ]..sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase())); return out; } diff --git a/test/evcharger_config_test.dart b/test/evcharger_config_test.dart index 8e28efb..acd740c 100644 --- a/test/evcharger_config_test.dart +++ b/test/evcharger_config_test.dart @@ -176,6 +176,47 @@ void main() { } }); + test('la relecture confirme quand RIEN d\'autre n\'a bougé', () { + final envoye = payloadWithoutLoad(_bancRaw(), 'chauffe-eau')!; + // La box rend ce qu'on lui a donné, dans un autre ordre : l'ordre de la liste n'est + // pas une différence, la comparaison est par identifiant. + final relu = envoye.reversed.map((m) => {...m}).toList(); + expect(diffPayloads(envoye, relu), isEmpty); + }); + + test('un RANG changé au passage se voit — c\'est le cas du 2026-08-28', () { + // Une suppression qui renumérote n'est plus un nettoyage, c'est un geste + // d'arbitrage. Le 28, deux écritures à deux minutes d'écart avaient rendu + // impossible de dire laquelle avait déplacé le chauffe-eau du rang 1 au rang 3. + final envoye = payloadWithoutLoad(_bancRaw(), '{88160e45-7df2-48cc-9639-f0d15ae6e382}')!; + final relu = [ + for (final m in envoye) + if (m['id'] == 'chauffe-eau') {...m, 'priority': 3} else {...m}, + ]; + final ecarts = diffPayloads(envoye, relu); + expect(ecarts.length, 1); + expect(ecarts.single, contains('priority')); + expect(ecarts.single, contains('1 → 3')); + }); + + test('une charge qui disparaît ou apparaît en plus se voit aussi', () { + final envoye = payloadWithoutLoad(_bancRaw(), 'chauffe-eau')!; + expect(diffPayloads(envoye, envoye.sublist(1)).single, contains('a disparu')); + expect( + diffPayloads(envoye.sublist(1), envoye).single, contains('est apparue')); + }); + + test('les charges utiles imbriquées se comparent par VALEUR', () { + // `relays` et `sgReady` sont des listes et des objets : `==` les comparerait par + // référence, et toute relecture paraîtrait alors différente — l'alerte crierait à + // chaque suppression, donc plus personne ne la lirait. + final envoye = payloadWithoutLoad(_bancRaw(), 'pac-terrain')!; + final relu = [ + for (final m in envoye) jsonDecode(jsonEncode(m)) as Map + ]; + expect(diffPayloads(envoye, relu), isEmpty); + }); + test('une charge absente rend NULL, pas la liste inchangée', () { // « Rien à supprimer » et « supprimé » ne doivent pas se ressembler côté appelant : // une liste inchangée serait réécrite pour rien, sur la foi d'un succès. diff --git a/test/thing_picker_meter_test.dart b/test/thing_picker_meter_test.dart new file mode 100644 index 0000000..7e61b89 --- /dev/null +++ b/test/thing_picker_meter_test.dart @@ -0,0 +1,146 @@ +/// Le sélecteur de compteur était **plus restrictif que la box**. +/// +/// Il filtrait sur l'interface `smartmeter`. Le moteur, lui, n'en regarde aucune : il fait +/// `findConfiguredThing(meterThingId)` puis `hasState("currentPower")` +/// (`attachMeasurements`, LM-1101). +/// +/// Constaté sur `.75` le 2026-08-28 : la classe `wallbox` (borne simulée) déclare +/// `smartmeter`, la classe `trydan` (V2C) non — `connectable, evcharger, power` seulement. +/// La V2C était donc impossible à désigner comme son propre compteur, alors que c'est le +/// seul moyen de faire apparaître « mesuré » à côté de « alloué » sur sa carte, et que la +/// box l'aurait accepté. +library; + +import 'package:etm_powersync_app/models/nymea_models.dart'; +import 'package:etm_powersync_app/services/nymea_service.dart'; +import 'package:etm_powersync_app/widgets/thing_picker.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +NymeaStateType _st(String id, String nom, {String type = 'Double'}) => + NymeaStateType(id: id, name: nom, displayName: nom, type: type, unit: ''); + +/// Le parc de `.75`, réduit aux trois cas qui comptent. +NymeaService _service() => NymeaService() + ..adoptThingsForTest( + [ + const NymeaThing( + id: 'v2c', + name: 'V2C Trydan', + thingClassId: 'cls-trydan', + setupStatus: 'ok', + paramValues: []), + const NymeaThing( + id: 'sim', + name: 'Simulated wallbox', + thingClassId: 'cls-wallbox', + setupStatus: 'ok', + paramValues: []), + const NymeaThing( + id: 'ecs', + name: 'ECS-Meter', + thingClassId: 'cls-meter', + setupStatus: 'ok', + paramValues: []), + // Une borne SANS puissance publiée : élargir le filtre ne doit pas la faire entrer. + const NymeaThing( + id: 'muette', + name: 'Borne muette', + thingClassId: 'cls-borne-muette', + setupStatus: 'ok', + paramValues: []), + ], + [ + NymeaThingClass( + id: 'cls-trydan', + name: 'trydan', + displayName: 'Trydan', + interfaces: const ['connectable', 'evcharger', 'power'], + stateTypes: [_st('p', 'currentPower')], + ), + NymeaThingClass( + id: 'cls-wallbox', + name: 'wallbox', + displayName: 'Simulated wallbox', + interfaces: const ['evcharger', 'power', 'smartmeter', 'smartmeterconsumer'], + stateTypes: [_st('p', 'currentPower')], + ), + NymeaThingClass( + id: 'cls-meter', + name: 'smartMeterConsumer', + displayName: 'Compteur', + interfaces: const ['smartmeter', 'smartmeterconsumer'], + stateTypes: [_st('p', 'currentPower')], + ), + NymeaThingClass( + id: 'cls-borne-muette', + name: 'borneMuette', + displayName: 'Borne muette', + interfaces: const ['evcharger'], + stateTypes: [_st('c', 'charging', type: 'Bool')], + ), + ], + ); + +Widget _harness({ + List aussi = const [], + String? etatRequis, +}) => + MaterialApp( + home: Scaffold( + body: ThingPicker( + service: _service(), + interfaceRequise: 'smartmeter', + interfacesAussi: aussi, + etatRequis: etatRequis, + valeur: null, + onChoisi: (_) {}, + placeholder: 'Choisir un appareil de mesure…', + ), + ), + ); + +Future> _liste(WidgetTester tester) async { + await tester.tap(find.text('Choisir un appareil de mesure…')); + await tester.pumpAndSettle(); + return tester + .widgetList(find.byType(Text)) + .map((t) => t.data ?? '') + .toList(); +} + +void main() { + testWidgets('sans élargissement, la V2C n\'est PAS proposée', (tester) async { + // L'état d'avant : le comportement qu'on corrige, figé pour qu'on sache ce qu'il était. + await tester.pumpWidget(_harness()); + final noms = await _liste(tester); + + expect(noms, contains('ECS-Meter')); + expect(noms, contains('Simulated wallbox')); + expect(noms.contains('V2C Trydan'), isFalse); + }); + + testWidgets('élargi aux bornes qui publient currentPower, elle l\'est', + (tester) async { + await tester.pumpWidget( + _harness(aussi: const ['evcharger'], etatRequis: 'currentPower')); + final noms = await _liste(tester); + + expect(noms, contains('V2C Trydan')); + // Les compteurs restent : le filtre s'élargit, il ne se remplace pas. + expect(noms, contains('ECS-Meter')); + expect(noms, contains('Simulated wallbox')); + }); + + testWidgets('une borne qui ne publie AUCUNE puissance reste dehors', + (tester) async { + // L'élargissement suit la règle du moteur — `hasState("currentPower")` — et pas + // « toute borne fera l'affaire ». Proposer un appareil qui ne mesure rien ferait + // écrire une configuration muette, sans erreur. + await tester.pumpWidget( + _harness(aussi: const ['evcharger'], etatRequis: 'currentPower')); + final noms = await _liste(tester); + + expect(noms.contains('Borne muette'), isFalse); + }); +}