diff --git a/docs/BRIEF_plugin_depuis_maquettes.md b/docs/BRIEF_plugin_depuis_maquettes.md index 3bbbb74..cb4430e 100644 --- a/docs/BRIEF_plugin_depuis_maquettes.md +++ b/docs/BRIEF_plugin_depuis_maquettes.md @@ -2,6 +2,85 @@ --- +## 2026-08-30 (soir) — trois constats sur la borne, et deux d'entre eux FORGENT un fait + +**Contexte, mesuré** : V2C Trydan, Skoda branchée et en charge (4 356 W, 3 phases), passage +en Boost depuis l'app à 12:15:34, lien Modbus tombé à 12:15:31 — trois secondes avant. + +### 1. `chargingState` reste `Idle` pendant une charge réelle + +En `ChargingModeNormal` l'arbitre sort la borne du waterfall — `[Arbitre] Borne "V2C Trydan" +hors waterfall (mode manuel ou aucun véhicule branché)`, à chaque cycle — et cesse donc de +maintenir `chargingState`. Le champ garde sa dernière valeur, `ChargingStateIdle`, pendant que +la borne tire 4 356 W. + +**Un champ que plus personne ne maintient devrait être absent, pas figé.** C'est la règle que +`draw`, `counts` et `measurement` appliquent déjà — appliquée cette fois à un champ **vivant +qui cesse de l'être**, ce qui est le cas le plus difficile à voir : rien ne change, et c'est +justement le problème. Nous ne l'affichons pas, et nous ne l'afficherons pas tant qu'il peut +être périmé sans le dire. + +### 2. À la perte du lien, les états publiés passent à ZÉRO — c'est le plus grave + +Relevé état par état sur le même Thing, avant et après `marking unreachable` : + +| État | Avant | Après | +|---|---|---| +| `connected` | true | **false** | +| `pluggedIn` | true | **false** | +| `charging` | true | **false** | +| `currentPower` | 4 396 W | **0** | +| `statusMessage` | "" | « Hors ligne depuis 30/08 12:39 — timeout Modbus — vérifier signal WiFi de la borne » | + +La voiture était **branchée et en charge**. Un client qui relaie ces états affiche donc +« aucun véhicule branché, 0 W » — **un fait forgé, pas une absence**. Et avant les zéros, la +dernière valeur connue reste publiée un moment comme si elle était actuelle : vérifié, aucun +état ne bouge pendant 45 s alors que la borne est censée tirer 4 356 W. + +**La demande** : à la perte du lien, **ne pas republier** `pluggedIn` / `charging` / +`currentPower`. Les figer est déjà trompeur ; les remettre à zéro **fabrique une +affirmation** — et c'est exactement ce que la règle « un champ absent vaut mieux qu'un champ +nul ou forgé » interdit. Nous ne savons pas d'où viennent ces zéros (plugin V2C ou remise à +zéro générique de nymea à la déconnexion), et c'est à vous de le dire. + +Deux choses sauvent la lecture, et elles sont bien faites : `connected` bascule, et +`statusMessage` porte **l'heure de la coupure et la piste à suivre**, déjà rédigés. Nous +l'affichons tel quel. + +> **Corrigé côté app aujourd'hui** : sous `connected == false`, `EvChargerLive` met +> `pluggedIn`, `charging` et `currentPowerW` à `null` **à l'entrée** — ni le zéro forgé, ni la +> valeur périmée. La tuile dit « Hors ligne » et reprend votre phrase. Le **mode**, lui, reste +> affiché : il est stocké dans la box, pas lu sur la borne, donc un lien coupé ne l'invalide +> pas. + +### 3. `ChargingModeNormal` n'est pas un boost — et notre écran le promet + +Le journal est explicite : + +``` +EV charger "V2C Trydan" changed to normal mode. Restoring manual values of false 6 A 3 phases +``` + +Passer en Normal **restaure les valeurs manuelles mémorisées** — ici *power `false`, 6 A* — +et sort la borne de l'arbitrage. Donc : ce que le mode restaure est ce qui traînait, pas un +maximum ; et **plus personne ne commande ensuite**, donc rien ne monte le courant. + +Or l'attente derrière le bouton est explicite (Patrick, 2026-08-30) : **en Boost la borne +doit charger à sa puissance maximale.** Notre correspondance `[Boost] → ChargingModeNormal` +(`CLAUDE.md`) ne la tient pas. + +**La question, et sa contrainte** : comment une app exprime-t-elle « charge maintenant, à +fond » ? Nous **n'écrirons pas les actions du Thing** (`power`, `maxChargingCurrent`) depuis +l'écran : `+etm23` a rétabli *une charge, un commandeur*, et un boost écrit par l'app +rouvrirait exactement le défaut que ce lot a fermé — deux commandeurs, et une consigne publiée +qui n'atteint pas le matériel. Il faut donc que ça vienne de vous : soit le mode fixe lui-même +les valeurs manuelles au maximum de la borne, soit un mode (ou un paramètre) dédié le porte. + +Tant que ce n'est pas tranché, **nous ne renommons pas le bouton** : l'écran dirait « Boost » +sur un mode qui, selon ce qui traîne en mémoire, peut laisser la borne à l'arrêt. + +--- + ## 2026-08-30 (suite) — `draw.committedW` en préalable : OUI, sans réserve **Et le refus de livrer `counts` sur `budget.evReservedW` est le bon appel.** Une identité diff --git a/lib/models/ev_charger_live.dart b/lib/models/ev_charger_live.dart index e4898ca..765b21a 100644 --- a/lib/models/ev_charger_live.dart +++ b/lib/models/ev_charger_live.dart @@ -47,11 +47,48 @@ class EvChargerLive { final String thingId; final String name; - /// États du Thing. `null` = non publié par cette borne. + /// États du Thing. `null` = non publié par cette borne, **ou borne injoignable**. + /// + /// ## Pourquoi l'injoignable produit `null` ici, et pas la dernière valeur connue + /// + /// Mesuré sur `.75` le 2026-08-30, V2C Trydan : quand le lien Modbus tombe, la box ne se + /// contente pas de figer les états — elle **publie des zéros**. + /// + /// | État | Avant la coupure | Après | + /// |---|---|---| + /// | `connected` | true | **false** | + /// | `pluggedIn` | true | **false** | + /// | `charging` | true | **false** | + /// | `currentPower` | 4 396 W | **0** | + /// | `statusMessage` | "" | « Hors ligne depuis 30/08 12:39 — timeout Modbus … » | + /// + /// Une voiture branchée et en charge devient donc, à l'écran, « aucun véhicule branché, + /// 0 W » — **un fait forgé, pas une absence**. Et avant ces zéros, la dernière valeur + /// connue reste affichée un moment comme si elle était actuelle (vérifié : aucun état ne + /// bouge pendant 45 s alors que la borne est censée tirer 4 356 W). + /// + /// L'app refuse donc de relayer les deux : sous [horsLigne], ces trois champs sont mis à + /// `null` **à la construction**, pour qu'aucun écran ne puisse afficher ni le zéro forgé + /// ni la valeur périmée. Seuls [joignable] et [messageEtat] disent quelque chose de vrai. final bool? pluggedIn; final bool? charging; final double? currentPowerW; + /// L'état `connected` du Thing — **le seul champ qui reste vrai quand le lien tombe**. + /// + /// `null` = la borne ne publie pas cet état ; on ne conclut alors rien. + final bool? joignable; + + /// `statusMessage` du Thing : une phrase déjà écrite par le plugin, en français, avec + /// l'heure de la coupure et la piste à suivre. On l'affiche telle quelle — la + /// reformuler perdrait l'horodatage et le diagnostic. + final String? messageEtat; + + /// Le mode reste lisible **même hors ligne**, et c'est voulu : il est stocké dans la + /// box (`GetChargingInfos`), pas lu sur la borne. Un lien coupé n'invalide donc pas le + /// mode, seulement les états du matériel — et les trois boutons continuent d'écrire, + /// puisqu'ils écrivent eux aussi dans la box. + /// /// Mode **tel que la box le porte** (`ChargingModeEco`, `ChargingModeNormal`, …), lu par /// `GetChargingInfos`. `null` = pas encore lu, ou aucune entrée pour cette borne — et /// dans ce cas l'écran n'a **aucun** mode à présenter comme courant. @@ -64,8 +101,16 @@ class EvChargerLive { this.charging, this.currentPowerW, this.apiMode, + this.joignable, + this.messageEtat, }); + /// **La box ne joint plus la borne.** Rien de ce qu'elle publierait d'autre n'est un fait. + /// + /// `joignable == null` n'est pas hors ligne : une borne qui ne publie pas `connected` ne + /// dit rien de son lien, et supposer la panne serait le symétrique du zéro forgé. + bool get horsLigne => joignable == false; + /// Traduction du mode de la box vers les trois boutons de l'IU. /// /// Les cinq valeurs de l'énumération se replient sur trois : les variantes « avec diff --git a/lib/services/nymea_service.dart b/lib/services/nymea_service.dart index bd859c5..ecd4498 100644 --- a/lib/services/nymea_service.dart +++ b/lib/services/nymea_service.dart @@ -1483,13 +1483,22 @@ class NymeaService extends ChangeNotifier { if (thing == null) return null; final info = _chargingInfos[id]; final power = _stateByName(thing, 'currentPower'); + final joignable = _stateByName(thing, 'connected') as bool?; + final msg = _stateByName(thing, 'statusMessage'); + // Borne injoignable : la box publie des ZÉROS (pluggedIn false, charging false, + // currentPower 0) là où elle ne sait plus rien — mesuré sur la V2C le 2026-08-30. + // Les relayer ferait dire à l'écran « aucun véhicule branché » pendant une charge. + // On les jette ici, à l'entrée, plutôt que dans chaque écran. + final horsLigne = joignable == false; return EvChargerLive( thingId: id, name: thing.name, - pluggedIn: _stateByName(thing, 'pluggedIn') as bool?, - charging: _stateByName(thing, 'charging') as bool?, - currentPowerW: power is num ? power.toDouble() : null, + pluggedIn: horsLigne ? null : _stateByName(thing, 'pluggedIn') as bool?, + charging: horsLigne ? null : _stateByName(thing, 'charging') as bool?, + currentPowerW: (!horsLigne && power is num) ? power.toDouble() : null, apiMode: info?['chargingMode'] as String?, + joignable: joignable, + messageEtat: (msg is String && msg.isNotEmpty) ? msg : null, ); } diff --git a/lib/widgets/ev_favorite_tile.dart b/lib/widgets/ev_favorite_tile.dart index 77eca32..e6f2492 100644 --- a/lib/widgets/ev_favorite_tile.dart +++ b/lib/widgets/ev_favorite_tile.dart @@ -60,7 +60,9 @@ class EvFavoriteTile extends StatelessWidget { ); } - final couleur = live.charging == true ? EtmTokens.green : EtmTokens.blue; + final couleur = live.horsLigne + ? EtmTokens.amber + : (live.charging == true ? EtmTokens.green : EtmTokens.blue); return Row( children: [ @@ -80,7 +82,11 @@ class EvFavoriteTile extends StatelessWidget { children: [ // La puissance MESURÉE, ou le fait qu'elle ne soit pas publiée. Jamais 0 W // par défaut : « 0 W » et « pas de mesure » sont deux choses différentes. - if (live.currentPowerW != null) + if (live.horsLigne) + Text('Hors ligne', + style: EtmTokens.sans( + size: 20, weight: FontWeight.w700, color: couleur)) + else if (live.currentPowerW != null) Text('${live.currentPowerW!.round()} W', style: EtmTokens.mono( size: 26, weight: FontWeight.w700, color: couleur)) @@ -123,6 +129,13 @@ class EvFavoriteTile extends StatelessWidget { /// Branché ? En charge ? — et « non publié » quand la borne ne le dit pas. String _etat(EvChargerLive live) { + // Hors ligne : la box ne sait rien du branchement, et ce qu'elle publierait + // (« aucun véhicule branché », « 0 W ») est forgé. On dit la coupure, avec la phrase + // du plugin quand elle existe — elle porte l'heure et la piste. + if (live.horsLigne) { + return live.messageEtat ?? + 'Borne injoignable — la box ne sait plus si un véhicule est branché'; + } if (live.pluggedIn == null && live.charging == null) { return 'Branchement non publié par cette borne'; } diff --git a/test/ev_favorite_tile_test.dart b/test/ev_favorite_tile_test.dart index 8f4d4f1..2690faa 100644 --- a/test/ev_favorite_tile_test.dart +++ b/test/ev_favorite_tile_test.dart @@ -32,6 +32,58 @@ EvChargerLive _v2c({ ); void main() { + group('borne injoignable — ni le zéro forgé, ni la valeur périmée', () { + // Mesuré sur `.75` le 2026-08-30 : quand le lien Modbus de la V2C tombe, la box + // publie `connected: false`, `pluggedIn: false`, `charging: false`, + // `currentPower: 0` et un `statusMessage`. La voiture, elle, était branchée et en + // charge à 4 356 W. `NymeaService.evChargerFor` jette ces zéros ; ces tests + // vérifient ce que la tuile en fait. + EvChargerLive horsLigne({String? message}) => EvChargerLive( + thingId: '4d1adafd-cafc-4813-93b1-ca8933ce71ab', + name: 'V2C Trydan', + apiMode: 'ChargingModeNormal', + joignable: false, + messageEtat: message, + ); + + testWidgets('elle dit la coupure, avec la phrase du plugin', (tester) async { + await tester.pumpWidget(_harness( + live: horsLigne( + message: 'Hors ligne depuis 30/08 12:39 — timeout Modbus — ' + 'vérifier signal WiFi de la borne'))); + + expect(find.text('Hors ligne'), findsOneWidget); + expect(find.textContaining('timeout Modbus'), findsOneWidget); + // …et surtout PAS ce que la box publiait au même instant. + expect(find.textContaining('Aucun véhicule branché'), findsNothing); + expect(find.textContaining('0 W'), findsNothing); + }); + + testWidgets('sans phrase du plugin, elle nomme quand même l\'ignorance', + (tester) async { + await tester.pumpWidget(_harness(live: horsLigne())); + expect(find.textContaining('ne sait plus si un véhicule est branché'), + findsOneWidget); + }); + + testWidgets('le MODE reste lu — il vient de la box, pas de la borne', + (tester) async { + // Un lien coupé n'invalide pas un réglage stocké dans la box : le cacher + // priverait le client de la seule chose encore vraie à l'écran. + await tester.pumpWidget(_harness(live: horsLigne())); + expect(find.textContaining('Mode Boost'), findsOneWidget); + }); + + testWidgets('`joignable == null` n\'est PAS hors ligne', (tester) async { + // Une borne qui ne publie pas `connected` ne dit rien de son lien. Supposer la + // panne serait le symétrique exact du zéro forgé. + await tester.pumpWidget(_harness( + live: _v2c(pluggedIn: true, charging: true, powerW: 4356))); + expect(find.text('Hors ligne'), findsNothing); + expect(find.text('4356 W'), findsOneWidget); + }); + }); + group('la tuile ne montre que ce que la box publie', () { testWidgets('sans borne, elle le dit — elle ne disparaît pas', (tester) async {