From 5a515827297897d557e1f3246ddb34ddb24aad92 Mon Sep 17 00:00:00 2001 From: Patrick Schurig Date: Tue, 8 Sep 2026 12:19:39 +0200 Subject: [PATCH] =?UTF-8?q?fix(ev):=20=C2=AB=20Boost=20=C2=BB=20devient=20?= =?UTF-8?q?=C2=AB=20Manuel=20=C2=BB=20=E2=80=94=20le=20nom=20promettait=20?= =?UTF-8?q?le=20contraire=20du=20comportement?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Décision de Patrick : trois modes suffisent — Manuel, PV, Min+PV. Le boost n'est pas un quatrième mode, c'est la dérogation (+etm61). LE FAIT. Le bouton écrivait `ChargingModeNormal`, qui sort la borne de l'arbitrage et lui restitue ses valeurs manuelles stockées — manualChargingEnabled, manualMaxChargingCurrent, manualDesiredPhaseCount. Sur .75 elles valaient false / 6 A / 3 phases : mesuré le 2026-09-08 à 11:09:51, l'appui a ARRÊTÉ la charge et armé un verrou de 300 s. Pas une régression — c'est ce que Normal a toujours fait. C'est le nom qui mentait. Renommé jusque dans le code (`ChargingMode.boost` -> `ChargingMode.manuel`), pas seulement sur l'étiquette : un identifiant faux reste un piège pour le prochain lecteur, et c'est précisément ce qui a duré des mois ici. L'ordre met Manuel en tête, parce que ChargingModeNormal est aussi le mode d'office d'une borne détectée. ET LE RENOMMAGE NE SUFFISAIT PAS. « Manuel » dit QUI commande, pas ce que la borne va faire : tant que l'app n'offre pas de régler ces valeurs stockées, le mode veut dire « ce qui traînait la dernière fois ». Passer en manuel demande donc confirmation, et la confirmation dit la conséquence — la charge peut s'arrêter — AVANT, pas après. ⚠️ La confirmation ne se déclenche pas sur un ré-appui du mode déjà en place : une confirmation qu'on apprend à cliquer sans lire ne protège plus de rien. Un test le fige. Vérifié sur .75 (1.15.2+etm61) : SetLoadDerogation et ClearLoadDerogation sont dans Introspect, avec derogationEnded / durationS / restoredMode. Le contrat de la dérogation est écrit dans CLAUDE.md — durée obligatoire bornée [60, 21600], état lu dans decision.code == "DEROGATION_ACTIVE" et jamais dans le mode, fin au niveau de la trame avec six causes. Aucun bouton de boost n'est écrit : c'est le lot suivant. Et une sonde pour le moteur, faite au passage : la V2C est bien restée en ChargingModeNormal et elle est ABSENTE de loads[] — hors arbitrage. Leur hypothèse est confirmée sans redémarrage. 339 tests. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01GKM8FDNHZWogAsjjfB8pSw --- CLAUDE.md | 57 +++++++++- lib/models/energy_data.dart | 22 +++- lib/models/ev_charger_live.dart | 2 +- lib/screens/energy/load_mechanism_screen.dart | 4 +- lib/services/nymea_service.dart | 4 +- lib/widgets/ev_favorite_tile.dart | 2 +- lib/widgets/ev_mode_buttons.dart | 42 +++++++- test/ev_favorite_tile_test.dart | 4 +- test/ev_mode_buttons_test.dart | 102 ++++++++++++++++-- test/load_mechanism_ev_test.dart | 4 +- 10 files changed, 221 insertions(+), 22 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index edfcb64..493f3d7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -76,17 +76,68 @@ nymeaService.call('NymeaEnergy.SetChargingInfo', { ### Modes UI EV — 3 boutons + option échéance ``` -[PV] [Min+PV] [Boost] - ↓ +[Manuel] [PV] [Min+PV] + ↓ Option échéance ? → charge cible (targetPercentage) + heure (endDateTime) +Manuel → ChargingModeNormal PV → ChargingModeEco Min+PV → ChargingModeEcoWithMinCurrent -Boost → ChargingModeNormal PV + échéance → ChargingModeEcoWithTargetTime + targetPercentage + endDateTime Min+PV + échéance → ChargingModeEcoMinWithTargetTime + targetPercentage + endDateTime ``` +> 🔴 **Le bouton s'appelait « Boost », et le nom était faux — il ARRÊTAIT la charge.** +> `ChargingModeNormal` sort la borne de l'arbitrage et lui restitue ses valeurs manuelles +> (`manualChargingEnabled`, `manualMaxChargingCurrent`, `manualDesiredPhaseCount`). Sur +> `.75` elles valaient `false / 6 A / 3 phases` : mesuré le 2026-09-08 à 11:09:51, l'appui +> a stoppé la charge et armé un verrou de 300 s. **Ce n'était pas une régression** — c'est +> ce que `Normal` a toujours fait ; c'est le nom qui promettait le contraire. +> +> Renommé **jusque dans le code** (`ChargingMode.manuel`), pas seulement sur l'étiquette : +> un identifiant faux reste un piège pour le prochain lecteur. +> +> ⚠️ **Et passer en Manuel se confirme.** « Manuel » dit qui commande, pas ce que la borne +> va faire : tant que l'app n'offre pas de régler ces valeurs stockées, le mode veut dire +> « ce qui traînait la dernière fois ». La confirmation le dit avant, plutôt que de le +> laisser découvrir après. + +### Le BOOST n'est pas un mode — c'est une DÉROGATION (`+etm61`) + +``` +NymeaEnergy.SetLoadDerogation { loadId, durationS, o:targetWh } +NymeaEnergy.ClearLoadDerogation { loadId } +``` + +Vérifiées présentes dans `Introspect` sur `.75` en `1.15.2+etm61`. Elle se **superpose** au +mode courant, sert la charge à son maximum **en tête de passe éco** — devant toute autre +charge, quel que soit son rang — financée au réseau, et **s'efface d'elle-même**. + +- **`durationS` est obligatoire**, bornée `[60, 21600]`, refusée hors de là (LM-1500). Donc + **pas de « boost jusqu'à annulation »** : l'écran doit faire saisir une durée, le moteur + refuserait le reste. `targetWh` est optionnel, en **énergie**, jamais en SoC — pas de + communication véhicule (cf. LM-1009). +- 🔴 **Ne pas lire le MODE pour savoir si un boost tourne.** Si la borne était en manuel, le + moteur force Eco le temps de la dérogation, et l'écran verrait un mode que l'utilisateur + n'a pas choisi. La vérité est sur la charge : `decision.code == "DEROGATION_ACTIVE"`, avec + `endsAt` (ISO 8601 UTC) pour le compte à rebours, `maxW` / `gridW`, et `boundBy ∈ drawCap` + (l'abonnement bride) `| loadCeiling` (la charge est à son plafond de cycle). +- **La fin arrive au niveau de la TRAME, pas dans `loads[]`** — au débranchement la charge + n'y est déjà plus. Champ `derogationEnded[]`, présent le seul cycle de la fin : + +```jsonc +"derogationEnded": [ { "loadId": "…", "cause": "durationElapsed", "restoredMode": 0 } ] +``` + +`cause ∈ targetReached | unplugged | measurementLost | durationElapsed | modeChanged | +cancelled` — **six causes, six messages** : cible atteinte (rien à faire) · mesure perdue +(regarder le lien ou le compteur) · débranchement · durée écoulée · mode changé (quelqu'un a +repris le volant) · annulée à la main. + +Depuis `+etm61`, appuyer sur un bouton de mode pendant une dérogation la **termine +proprement** (`cause: "modeChanged"`, `restoredMode` absent — on ne restitue pas, ce serait +écraser le mode que l'utilisateur vient de choisir). + > ⚠️ **`minCurrent` n'existe nulle part dans `ChargingInfo`** — et c'est normal : ce n'est > pas un réglage. **Tranché le 2026-09-07.** Le plancher est une limite MATÉRIELLE, et elle > est déjà sur la box, dans la classe du Thing borne : diff --git a/lib/models/energy_data.dart b/lib/models/energy_data.dart index 90c6a30..2142451 100644 --- a/lib/models/energy_data.dart +++ b/lib/models/energy_data.dart @@ -139,7 +139,27 @@ class EnergyData { isPlausibleKwh(gridImportKwh) ? gridImportKwh : null; } -enum ChargingMode { pv, minPv, boost } +/// Les trois modes de recharge offerts par l'écran. +/// +/// 🔴 **`manuel` s'appelait `boost`, et le nom était FAUX.** Il pose +/// `ChargingModeNormal`, qui **sort la borne de l'arbitrage** et restitue ses valeurs +/// manuelles — `manualChargingEnabled`, `manualMaxChargingCurrent`, +/// `manualDesiredPhaseCount`. Sur `.75` ces valeurs valaient `false / 6 A / 3 phases` : +/// mesuré le 2026-09-08 à 11:09:51, le bouton « Boost » a donc **arrêté la charge** et armé +/// un verrou de 300 s. +/// +/// Ce n'était pas une régression : c'est ce que `Normal` a toujours fait. Le nom promettait +/// le contraire du comportement, et c'est le nom qui a menti pendant des mois — d'où le +/// renommage jusque dans le code, et pas seulement sur l'étiquette. +/// +/// Le vrai boost existe depuis `+etm61`, et **ce n'est pas un mode** : c'est une +/// dérogation (`NymeaEnergy.SetLoadDerogation`) qui se superpose au mode courant, sert la +/// charge à son maximum en tête de passe éco, et s'efface d'elle-même. Elle aura son propre +/// bouton d'action, avec une durée obligatoire — pas une entrée de ce sélecteur. +/// +/// L'ordre compte : `manuel` en tête, parce que `ChargingModeNormal` est aussi le mode +/// **d'office** d'une borne détectée (cf. `EvChargerLive.isUnmanaged`). +enum ChargingMode { manuel, pv, minPv } class HistoryPoint { final DateTime time; diff --git a/lib/models/ev_charger_live.dart b/lib/models/ev_charger_live.dart index 5d7ecdf..1fd1478 100644 --- a/lib/models/ev_charger_live.dart +++ b/lib/models/ev_charger_live.dart @@ -133,7 +133,7 @@ class EvChargerLive { /// une valeur inconnue — une borne peut porter un mode qu'aucune version d'app ne /// connaît, et deviner serait pire que dire « mode non reconnu ». ChargingMode? get uiMode => switch (apiMode) { - 'ChargingModeNormal' => ChargingMode.boost, + 'ChargingModeNormal' => ChargingMode.manuel, 'ChargingModeEco' || 'ChargingModeEcoWithTargetTime' => ChargingMode.pv, 'ChargingModeEcoWithMinCurrent' || 'ChargingModeEcoMinWithTargetTime' => diff --git a/lib/screens/energy/load_mechanism_screen.dart b/lib/screens/energy/load_mechanism_screen.dart index 59cf4d5..e4eba2b 100644 --- a/lib/screens/energy/load_mechanism_screen.dart +++ b/lib/screens/energy/load_mechanism_screen.dart @@ -336,7 +336,7 @@ class _Mecanisme extends StatelessWidget { 'branchée, et changent avec elle — les figer ici en ferait une seconde source ' 'de vérité, fausse dès le prochain véhicule.\n\n' 'Ce qui se règle depuis cet écran : le libellé, le domaine, le rang. Le mode de ' - 'recharge (PV, Min+PV, Boost) se règle depuis la tuile « Borne EV » des ' + 'recharge (Manuel, PV, Min+PV) se règle depuis la tuile « Borne EV » des ' 'favoris — c\'est le seul endroit de l\'app qui écrit SetChargingInfo.', style: TextStyle( fontSize: 12, height: 1.4, color: EtmTokens.mutedOf(context)), @@ -538,7 +538,7 @@ class _SectionBorne extends StatelessWidget { final base = switch (ui) { ChargingMode.pv => 'PV', ChargingMode.minPv => 'Min+PV', - ChargingMode.boost => 'Boost', + ChargingMode.manuel => 'Manuel', }; return b.hasDeadlineMode ? 'lu : $base + échéance' : 'lu : $base'; } diff --git a/lib/services/nymea_service.dart b/lib/services/nymea_service.dart index 89d3b26..1544e19 100644 --- a/lib/services/nymea_service.dart +++ b/lib/services/nymea_service.dart @@ -1432,7 +1432,7 @@ class NymeaService extends ChangeNotifier { }) async { // (mode d'UI, échéance) → énumération publiée par la box. final String apiMode; - if (mode == ChargingMode.boost) { + if (mode == ChargingMode.manuel) { apiMode = 'ChargingModeNormal'; } else if (mode == ChargingMode.pv && !deadline) { apiMode = 'ChargingModeEco'; @@ -1468,7 +1468,7 @@ class NymeaService extends ChangeNotifier { // avant d'atteindre le handler. info.remove('chargingState'); - if (deadline && mode != ChargingMode.boost) { + if (deadline && mode != ChargingMode.manuel) { info['targetPercentage'] = targetSoc; if (endTime != null) { info['endDateTime'] = endTime.millisecondsSinceEpoch ~/ 1000; diff --git a/lib/widgets/ev_favorite_tile.dart b/lib/widgets/ev_favorite_tile.dart index e6f2492..5a84c7e 100644 --- a/lib/widgets/ev_favorite_tile.dart +++ b/lib/widgets/ev_favorite_tile.dart @@ -153,7 +153,7 @@ class EvFavoriteTile extends StatelessWidget { final base = switch (ui) { ChargingMode.pv => 'PV', ChargingMode.minPv => 'Min+PV', - ChargingMode.boost => 'Boost', + ChargingMode.manuel => 'Manuel', }; return live.hasDeadlineMode ? 'Mode $base + échéance' : 'Mode $base'; } diff --git a/lib/widgets/ev_mode_buttons.dart b/lib/widgets/ev_mode_buttons.dart index f77e884..1426f5b 100644 --- a/lib/widgets/ev_mode_buttons.dart +++ b/lib/widgets/ev_mode_buttons.dart @@ -51,7 +51,47 @@ class _EvModeButtonsState extends State { /// répondu, l'app ne sait pas ce que porte la borne. ChargingMode? _pending; + /// **Passer en Manuel se confirme.** Ce n'est pas de la prudence décorative. + /// + /// `ChargingModeNormal` sort la borne de l'arbitrage et lui **restitue ses valeurs + /// manuelles stockées** — `manualChargingEnabled`, `manualMaxChargingCurrent`, + /// `manualDesiredPhaseCount`. Elles peuvent dire n'importe quoi, y compris « charge + /// désactivée » : sur `.75` elles valaient `false / 6 A / 3 phases`, et le bouton — qui + /// s'appelait alors « Boost » — a **arrêté la charge** le 2026-09-08 à 11:09:51, avec un + /// verrou de 300 s derrière. + /// + /// Le renommage seul ne suffisait pas : « Manuel » dit bien QUI commande, mais rien de + /// ce que la borne va faire. Tant que l'app n'offre pas de régler ces valeurs, Manuel + /// veut dire « ce qui traînait la dernière fois », et l'utilisateur a le droit de le + /// savoir avant, pas de le découvrir après. + Future _confirmerManuel() async { + final ok = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Rendre la main à la borne ?'), + content: const Text( + 'En mode manuel, le HEMS ne pilote plus cette borne : elle reprend ses propres ' + 'réglages enregistrés (charge autorisée ou non, courant, nombre de phases).\n\n' + 'Selon ce qu\'ils contiennent, la charge peut s\'arrêter.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('Annuler')), + FilledButton( + onPressed: () => Navigator.pop(ctx, true), + child: const Text('Passer en manuel')), + ], + ), + ); + return ok == true; + } + Future _set(ChargingMode m) async { + if (m == ChargingMode.manuel && widget.live.uiMode != ChargingMode.manuel) { + if (!await _confirmerManuel()) return; + if (!mounted) return; + } setState(() => _pending = m); final err = await widget.onSetMode(m); if (!mounted) return; @@ -77,9 +117,9 @@ class _EvModeButtonsState extends State { /// pour de vrai. Widget _bouton(ChargingMode m) { final (label, couleur) = switch (m) { + ChargingMode.manuel => ('Manuel', EtmTokens.mutedOf(context)), ChargingMode.pv => ('PV', EtmTokens.amber), ChargingMode.minPv => ('Min+PV', EtmTokens.blue), - ChargingMode.boost => ('Boost', EtmTokens.green), }; final selected = widget.live.uiMode == m; final busy = _pending != null; diff --git a/test/ev_favorite_tile_test.dart b/test/ev_favorite_tile_test.dart index 2690faa..8e216ef 100644 --- a/test/ev_favorite_tile_test.dart +++ b/test/ev_favorite_tile_test.dart @@ -71,7 +71,7 @@ void main() { // 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); + expect(find.textContaining('Mode Manuel'), findsOneWidget); }); testWidgets('`joignable == null` n\'est PAS hors ligne', (tester) async { @@ -183,7 +183,7 @@ void main() { (tester) async { // Deux écrans qui écrivent SetChargingInfo sont deux comportements qui divergent. await tester.pumpWidget(_harness(live: _v2c())); - for (final label in ['PV', 'Min+PV', 'Boost']) { + for (final label in ['Manuel', 'PV', 'Min+PV']) { expect(find.widgetWithText(OutlinedButton, label), findsNothing, reason: '$label ne doit plus être écrivable depuis les favoris'); } diff --git a/test/ev_mode_buttons_test.dart b/test/ev_mode_buttons_test.dart index b020495..c0dd4e6 100644 --- a/test/ev_mode_buttons_test.dart +++ b/test/ev_mode_buttons_test.dart @@ -69,19 +69,19 @@ void main() { }); testWidgets('la sélection suit la BOX, pas la dernière commande', (tester) async { - // `_v2c()` est en ChargingModeNormal : c'est Boost qui doit être allumé, et lui seul, + // `_v2c()` est en ChargingModeNormal : c'est Manuel qui doit être allumé, et lui seul, // quoi qu'on ait tapé juste avant. await tester.pumpWidget(_harness(live: _v2c())); await tester.tap(find.text('PV')); await tester.pumpAndSettle(); - final boost = tester.widget( - find.widgetWithText(OutlinedButton, 'Boost')); + final manuel = tester.widget( + find.widgetWithText(OutlinedButton, 'Manuel')); final pv = tester.widget(find.widgetWithText(OutlinedButton, 'PV')); - expect(boost.style?.backgroundColor?.resolve({}), + expect(manuel.style?.backgroundColor?.resolve({}), isNot(Colors.transparent), - reason: 'Boost est le mode que la box porte'); + reason: 'Manuel est le mode que la box porte'); expect(pv.style?.backgroundColor?.resolve({}), Colors.transparent, reason: 'PV vient d\'être demandé, mais la box ne l\'a pas encore dit'); }); @@ -89,7 +89,7 @@ void main() { testWidgets('un mode non lu n\'allume RIEN', (tester) async { // Trois boutons éteints valent mieux qu'un allumé au hasard. await tester.pumpWidget(_harness(live: _v2c(apiMode: null))); - for (final label in ['PV', 'Min+PV', 'Boost']) { + for (final label in ['Manuel', 'PV', 'Min+PV']) { final b = tester .widget(find.widgetWithText(OutlinedButton, label)); expect(b.style?.backgroundColor?.resolve({}), Colors.transparent); @@ -98,7 +98,7 @@ void main() { testWidgets('les cibles tactiles font au moins 44 px', (tester) async { await tester.pumpWidget(_harness(live: _v2c())); - for (final label in ['PV', 'Min+PV', 'Boost']) { + for (final label in ['Manuel', 'PV', 'Min+PV']) { final taille = tester.getSize(find.ancestor( of: find.text(label), matching: find.byType(OutlinedButton), @@ -107,4 +107,92 @@ void main() { reason: '$label : cible tactile trop petite'); } }); + + group('« Manuel » se confirme — il peut ARRÊTER la charge', () { + // Le bouton s'appelait « Boost ». `ChargingModeNormal` sort la borne de l'arbitrage et + // lui restitue ses valeurs manuelles stockées : sur `.75` elles valaient + // `manualChargingEnabled: false`, et l'appui a stoppé la charge le 2026-09-08 à + // 11:09:51, avec un verrou de 300 s derrière. + // + // Renommer ne suffisait pas : « Manuel » dit QUI commande, pas ce que la borne va + // faire. Tant que l'app n'offre pas de régler ces valeurs, le mode veut dire « ce qui + // traînait la dernière fois ». + + testWidgets('l\'appui n\'écrit RIEN tant qu\'on n\'a pas confirmé', (tester) async { + var appels = 0; + await tester.pumpWidget(_harness( + live: _v2c(apiMode: 'ChargingModeEco'), + onSetMode: (_) async { + appels++; + return null; + }, + )); + + await tester.tap(find.text('Manuel')); + await tester.pumpAndSettle(); + + expect(find.textContaining('Rendre la main à la borne'), findsOneWidget); + expect(appels, 0, reason: 'rien ne part avant que l\'utilisateur ait dit oui'); + expect(find.textContaining('la charge peut s\'arrêter'), findsOneWidget, + reason: 'la conséquence se dit AVANT, pas après'); + + await tester.tap(find.text('Annuler')); + await tester.pumpAndSettle(); + expect(appels, 0); + }); + + testWidgets('confirmé, il écrit une fois', (tester) async { + var appels = 0; + await tester.pumpWidget(_harness( + live: _v2c(apiMode: 'ChargingModeEco'), + onSetMode: (_) async { + appels++; + return null; + }, + )); + + await tester.tap(find.text('Manuel')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Passer en manuel')); + await tester.pumpAndSettle(); + + expect(appels, 1); + }); + + testWidgets('déjà en manuel, aucune question — rien ne change', (tester) async { + // Le contrôle qui MORD : la confirmation ne doit pas se déclencher sur un ré-appui + // du mode déjà en place, sinon elle devient un péage qu'on apprend à cliquer sans + // lire — et une confirmation qu'on ne lit plus ne protège plus de rien. + var appels = 0; + await tester.pumpWidget(_harness( + live: _v2c(), // déjà ChargingModeNormal + onSetMode: (_) async { + appels++; + return null; + }, + )); + + await tester.tap(find.text('Manuel')); + await tester.pumpAndSettle(); + + expect(find.textContaining('Rendre la main'), findsNothing); + expect(appels, 1); + }); + + testWidgets('les autres modes ne demandent rien', (tester) async { + var appels = 0; + await tester.pumpWidget(_harness( + live: _v2c(), + onSetMode: (_) async { + appels++; + return null; + }, + )); + + await tester.tap(find.text('Min+PV')); + await tester.pumpAndSettle(); + expect(find.textContaining('Rendre la main'), findsNothing); + expect(appels, 1); + }); + }); } diff --git a/test/load_mechanism_ev_test.dart b/test/load_mechanism_ev_test.dart index 1750989..48b359c 100644 --- a/test/load_mechanism_ev_test.dart +++ b/test/load_mechanism_ev_test.dart @@ -203,9 +203,9 @@ void main() { expect(find.widgetWithText(OutlinedButton, 'PV'), findsOneWidget); expect(find.widgetWithText(OutlinedButton, 'Min+PV'), findsOneWidget); - expect(find.widgetWithText(OutlinedButton, 'Boost'), findsOneWidget); + expect(find.widgetWithText(OutlinedButton, 'Manuel'), findsOneWidget); // Le mode LU est affiché à côté, et c'est celui de la box. - expect(find.textContaining('lu : Boost'), findsOneWidget); + expect(find.textContaining('lu : Manuel'), findsOneWidget); // Et l'avertissement d'une borne neuve, là où on la met en service. expect(find.textContaining('hors arbitrage'), findsOneWidget); });