From b0b6666bc87eed30bb70c5383ab8d904c39e5307 Mon Sep 17 00:00:00 2001 From: Patrick Schurig Date: Tue, 8 Sep 2026 13:59:32 +0200 Subject: [PATCH] =?UTF-8?q?feat(ev):=20le=20boost=20est=20une=20D=C3=89ROG?= =?UTF-8?q?ATION=20=E2=80=94=20bouton=20d'action,=20dur=C3=A9e=20obligatoi?= =?UTF-8?q?re,=20six=20fins?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le lot complet côté app, écrit sur le contrat vérifié dans Introspect sur .75 en 1.15.2+etm61 : SetLoadDerogation { loadId, durationS, o:targetWh } / ClearLoadDerogation { loadId }, et derogationEnded[] { cause, loadId, o:restoredMode } au niveau de la trame. CE QUI EST UN BOUTON D'ACTION, PAS UN MODE. Une dérogation se superpose au mode courant et s'efface ; elle ne le remplace pas. La mettre dans le sélecteur ferait croire qu'on en sort en choisissant autre chose. UNE DURÉE, TOUJOURS. Bornée [60, 21600] côté moteur. L'écran n'offre AUCUNE option « jusqu'à annulation » — pas parce que l'appel échouerait, mais parce que la promesse serait mauvaise : une dérogation qui ne sait pas dire quand elle finit ne devrait pas exister (LM-1500). Le filet reste armé même avec une cible, parce qu'une cible repose sur un instrument et qu'un instrument peut se taire. L'ÉTAT NE SE LIT PAS DANS LE MODE. Si la borne était en manuel, le moteur force Eco le temps de la dérogation : GetChargingInfos montrerait alors un mode que personne n'a choisi. La vérité est sur la charge — LoadTelemetryEntry.enDerogation. Trois refus d'inventer, chacun figé par un test : - entrée de télémétrie absente -> le bouton se TAIT. « Pas d'entrée » n'est pas « pas de boost », c'est « on ne sait pas » ; - échéance dépassée alors que la box annonce encore la dérogation -> on la NOMME au lieu de rabattre à « 00:00 » : c'est une contradiction entre deux champs de la même trame, et le zéro l'effacerait ; - cause de fin inconnue -> aucun libellé. Une box plus récente peut en publier une septième ; lui coller la phrase d'une autre serait pire. Et `EnergyErrorInvalidParameter` sur l'arrêt ne crie pas au refus : la dérogation a pu finir seule pendant l'appui, et une course que l'utilisateur ne peut pas éviter n'est pas une erreur à lui montrer. ⚠️ boundBy a DEUX catalogues : drawCap|loadCeiling sur la dérogation, mais « minimum » sur ECO_FLOOR_GRID — relevé sur la V2C aujourd'hui. Même nom, sens différent : aucune traduction partagée. ⚠️ Écrit d'après le contrat, PAS observé : DEROGATION_ACTIVE, endsAt et boundBy ne sont pas dans Introspect (les params d'une décision sont une carte libre), et aucune dérogation n'a tourné pendant une sonde. Tous les champs restent nullables. Noté aussi qu'Introspect SOUS-DÉCLARE — il annonce draw{committedW} seul alors que la box publie authorisedW/binding/bindingPhase/perPhaseBound — donc son silence ne prouve rien. La feuille de choix défile : 47 px de débordement trouvés par le test sur une surface courte, donc réels sur un téléphone à police agrandie. 358 tests. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01GKM8FDNHZWogAsjjfB8pSw --- lib/models/load_telemetry.dart | 153 +++++++++++ lib/screens/energy/load_mechanism_screen.dart | 39 +++ lib/services/nymea_service.dart | 60 +++++ lib/widgets/ev_boost_button.dart | 242 ++++++++++++++++++ test/derogation_test.dart | 127 +++++++++ test/ev_boost_button_test.dart | 148 +++++++++++ 6 files changed, 769 insertions(+) create mode 100644 lib/widgets/ev_boost_button.dart create mode 100644 test/derogation_test.dart create mode 100644 test/ev_boost_button_test.dart diff --git a/lib/models/load_telemetry.dart b/lib/models/load_telemetry.dart index 9d7c20c..17b7890 100644 --- a/lib/models/load_telemetry.dart +++ b/lib/models/load_telemetry.dart @@ -365,6 +365,127 @@ class LoadDecision { double? numParam(String key) => _dn(params[key]); bool? boolParam(String key) => params[key] as bool?; + String? strParam(String key) { + final v = params[key]; + return (v is String && v.trim().isNotEmpty) ? v.trim() : null; + } +} + +/// **Une DÉROGATION en cours** — `decision.code == "DEROGATION_ACTIVE"` (`+etm61`). +/// +/// 🔴 **Ne jamais 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 : l'écran verrait alors un mode +/// que l'utilisateur n'a pas choisi, et le montrerait comme son réglage. La vérité est sur +/// la CHARGE, dans la télémétrie, jamais dans `GetChargingInfos`. +/// +/// ⚠️ **Écrit d'après le contrat du moteur, PAS observé sur machine.** `SetLoadDerogation` +/// et `ClearLoadDerogation` sont bien dans `Introspect` sur `.75` en `+etm61`, et +/// `derogationEnded[]` y est typé — mais `DEROGATION_ACTIVE`, `endsAt` et `boundBy` n'y +/// figurent pas : les `params` d'une décision sont une carte libre, donc leurs clés ne sont +/// pas déclarées. Aucune dérogation n'a encore tourné pendant une sonde. +/// +/// Et `Introspect` **sous-déclare** : il annonce `draw { committedW }` seul alors que la box +/// publie `authorisedW`, `binding`, `bindingPhase`, `perPhaseBound`. Son silence ne prouve +/// donc rien — mais l'inverse non plus, et ces champs restent tous nullables jusqu'à ce +/// qu'une dérogation réelle les montre. +class Derogation { + /// Fin prévue. La box la publie en ISO 8601 UTC ; rendue ici dans le fuseau du lecteur + /// par [instantDeLaBox], comme tout instant. + final DateTime? endsAt; + + /// Le maximum servi, et la part achetée au réseau. + final double? maxW; + final double? gridW; + + /// Ce qui BRIDE la dérogation, quand quelque chose la bride : + /// `drawCap` — l'abonnement · `loadCeiling` — la charge est à son plafond de cycle. + /// + /// Pour une borne, ce plafond porte déjà son autorisation de phase, et [maxW] est alors + /// la réponse à « pourquoi pas plus ». + /// + /// ⚠️ Vocabulaire PROPRE à la dérogation. `ECO_FLOOR_GRID` publie aussi un `boundBy`, et + /// il vaut `"minimum"` — relevé sur la V2C le 2026-09-08. Deux champs de même nom, deux + /// catalogues : ne pas partager de traduction entre eux. + final String? boundBy; + + const Derogation({this.endsAt, this.maxW, this.gridW, this.boundBy}); + + static Derogation? depuis(LoadDecision? d) { + if (d == null || d.code != kDerogationActive) return null; + return Derogation( + endsAt: instantDeLaBox(d.params['endsAt']), + maxW: d.numParam('maxW'), + gridW: d.numParam('gridW'), + boundBy: d.strParam('boundBy'), + ); + } + + /// Temps restant, ou `null` si la box n'a pas publié d'échéance. + /// + /// **Jamais négatif rabattu à zéro sans le dire** : une échéance dépassée alors que la + /// dérogation est toujours annoncée active est une incohérence, pas un compte à rebours + /// terminé. [depasse] la nomme. + Duration? restant({DateTime? maintenant}) { + final f = endsAt; + if (f == null) return null; + return f.difference(maintenant ?? DateTime.now()); + } + + bool depasse({DateTime? maintenant}) { + final r = restant(maintenant: maintenant); + return r != null && r.isNegative; + } +} + +const String kDerogationActive = 'DEROGATION_ACTIVE'; + +/// Bornes de `durationS`, **imposées par le moteur** et refusées hors de là. +/// +/// Une dérogation qui ne sait pas dire quand elle finit ne devrait pas exister (LM-1500) : +/// c'est pour ça qu'il n'y a pas de valeur « illimitée » à proposer, et pas seulement parce +/// que l'appel échouerait. +const int kDerogationMinS = 60; +const int kDerogationMaxS = 21600; + +/// **La fin d'une dérogation, et ses six causes.** +/// +/// Publiée au niveau de la TRAME (`derogationEnded[]`), pas dans `loads[]` : au +/// débranchement la charge n'y est déjà plus, donc y chercher la fin ne la trouverait +/// jamais. Présente le **seul cycle** de la fin — la manquer, c'est la perdre. +class DerogationEnded { + final String loadId; + final String cause; + + /// Le mode restitué, quand il y en a un. **Absent** quand la fin vient d'un changement + /// de mode : on ne restitue pas, ce serait écraser le mode que l'utilisateur vient de + /// choisir. + final int? restoredMode; + + const DerogationEnded( + {required this.loadId, required this.cause, this.restoredMode}); + + factory DerogationEnded.fromJson(Map m) => DerogationEnded( + loadId: m['loadId'] as String? ?? '', + cause: m['cause'] as String? ?? '', + restoredMode: (m['restoredMode'] as num?)?.toInt(), + ); + + /// Six causes, six phrases — et elles ne demandent pas la même chose au lecteur. + /// + /// `null` pour une cause inconnue : une box plus récente que l'app peut en publier une + /// septième, et lui inventer un libellé serait pire que de dire « fin non reconnue ». + String? get phrase => switch (cause) { + 'targetReached' => 'Cible atteinte.', + 'durationElapsed' => 'Durée écoulée.', + 'unplugged' => 'Véhicule débranché.', + 'cancelled' => 'Annulée.', + 'modeChanged' => 'Un mode a été choisi pendant la dérogation.', + // La seule qui appelle une VÉRIFICATION : sans mesure, le moteur ne peut plus + // savoir où en est la charge, et il préfère s'arrêter que continuer à l'aveugle. + 'measurementLost' => + 'Mesure perdue — vérifiez le lien avec la borne ou son compteur.', + _ => null, + }; } /// **L'avancement d'une obligation éco** — le bloc `progress` d'un niveau, depuis `+etm52`. @@ -572,6 +693,12 @@ class LoadLevel { /// incomplète, elle est **fausse** si on la lit seule. final LoadDecision? decision; + /// La dérogation en cours sur cette charge, ou `null`. Voir [Derogation] — et surtout : + /// ne pas la déduire du mode de recharge. + Derogation? get derogation => Derogation.depuis(decision); + + bool get enDerogation => decision?.code == kDerogationActive; + /// L'avancement de l'obligation — **niveau `eco` seulement**. Voir [EcoProgress]. final EcoProgress? progress; @@ -636,6 +763,16 @@ class LoadTelemetryEntry { final LoadMechanism? mechanism; final LoadDecision decision; + /// **La dérogation en cours sur cette charge**, ou `null`. + /// + /// 🔴 Elle se lit ICI, sur la charge — jamais dans `GetChargingInfos`. Si la borne était + /// en manuel, le moteur force Eco le temps de la dérogation : le mode publié n'est alors + /// pas celui que l'utilisateur a choisi, et un écran qui s'y fierait afficherait un + /// réglage que personne n'a posé. + Derogation? get derogation => Derogation.depuis(decision); + + bool get enDerogation => decision.code == kDerogationActive; + const LoadTelemetryEntry({ required this.raw, required this.loadId, @@ -853,6 +990,14 @@ class LoadTelemetry { /// `null` en mode dégradé — il n'existe alors aucun budget pour ce cycle. final TelemetryBudget? budget; + /// **Les dérogations terminées à ce cycle** — publiées au niveau de la trame, et + /// présentes le SEUL cycle de la fin. Vide le reste du temps. + /// + /// Elles ne peuvent pas vivre dans `loads[]` : au débranchement, la charge n'y est déjà + /// plus. Un écran qui attendrait la fin dans la charge ne la verrait jamais arriver + /// précisément dans le cas où elle compte le plus. + final List derogationEnded; + /// Les charges arbitrées, **bornes de recharge comprises depuis `+etm22`**. /// /// Jusque-là `buildTelemetry()` sautait les bornes : elles étaient arbitrées, leurs @@ -894,6 +1039,7 @@ class LoadTelemetry { required this.budget, required this.loads, this.drawCommittedW, + this.derogationEnded = const [], }); factory LoadTelemetry.fromJson(Map m) { @@ -904,6 +1050,13 @@ class LoadTelemetry { raw: m, degradedMode: m['degradedMode'] as bool? ?? false, timestamp: instantDeLaBox(ts), + // `derogationEnded` vit au niveau de la TRAME, pas dans `loads[]` : au débranchement + // la charge n'y est déjà plus. Présent le seul cycle de la fin. + derogationEnded: [ + for (final e in (m['derogationEnded'] as List? ?? const [])) + if (e is Map) + DerogationEnded.fromJson(Map.from(e)) + ], budget: budget is Map ? TelemetryBudget.fromJson(Map.from(budget)) : null, diff --git a/lib/screens/energy/load_mechanism_screen.dart b/lib/screens/energy/load_mechanism_screen.dart index e4eba2b..80ab257 100644 --- a/lib/screens/energy/load_mechanism_screen.dart +++ b/lib/screens/energy/load_mechanism_screen.dart @@ -18,6 +18,7 @@ import '../../services/nymea_service.dart'; import '../../services/rpc_schema.dart'; import '../../theme/etm_tokens.dart'; import '../../widgets/load_cards.dart'; +import '../../widgets/ev_boost_button.dart'; import '../../widgets/ev_mode_buttons.dart'; import '../../widgets/garde_brouillon.dart'; import '../../widgets/schema_gated.dart'; @@ -504,6 +505,28 @@ class _SectionBorne extends StatelessWidget { onSetMode: (m) => service.setChargingInfo(mode: m, evChargerId: borne.thingId), ), + // **Le boost est une ACTION à côté du sélecteur, pas une quatrième position.** + // + // Une dérogation se superpose au mode courant et s'efface ; elle ne le + // remplace pas. La mettre dans le sélecteur ferait croire qu'on en sort en + // choisissant autre chose, alors qu'elle finit d'elle-même. + // + // L'entrée de télémétrie vient de `loads[]` : `null` quand la borne n'est pas + // arbitrée en ce moment. Le bouton se tait alors — « pas d'entrée » n'est pas + // « pas de boost », c'est « on ne sait pas ». + if (_entreeTelemetrie(telemetry, borne.thingId) != null) ...[ + const SizedBox(height: 8), + EvBoostButton( + entry: _entreeTelemetrie(telemetry, borne.thingId), + onSet: ({required int durationS, double? targetWh}) => + service.setLoadDerogation( + loadId: _entreeTelemetrie(telemetry, borne.thingId)!.loadId, + durationS: durationS, + targetWh: targetWh), + onClear: () => service.clearLoadDerogation( + _entreeTelemetrie(telemetry, borne.thingId)!.loadId), + ), + ], if (borne.isUnmanaged) ...[ const SizedBox(height: 8), Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -2309,3 +2332,19 @@ class _SectionObligationState extends State<_SectionObligation> { ); } } + +/// L'entrée de télémétrie d'une borne, cherchée sur l'identifiant **nu**. +/// +/// Les identifiants voyagent avec ou sans accolades selon l'appel (UI_data_contract §9) : +/// comparer les chaînes brutes ferait rater la borne une fois sur deux, et l'écran +/// conclurait « pas arbitrée » sur une borne qui l'est. +LoadTelemetryEntry? _entreeTelemetrie( + LoadTelemetryProvider telemetry, String thingId) { + String nu(String x) => + x.replaceAll('{', '').replaceAll('}', '').toLowerCase(); + final cible = nu(thingId); + for (final e in telemetry.telemetry?.loads ?? const []) { + if (nu(e.loadId) == cible) return e; + } + return null; +} diff --git a/lib/services/nymea_service.dart b/lib/services/nymea_service.dart index 1544e19..20a1b95 100644 --- a/lib/services/nymea_service.dart +++ b/lib/services/nymea_service.dart @@ -13,6 +13,7 @@ import '../models/ev_charger_live.dart'; import '../models/modbus_models.dart'; // ModbusRtuMaster, SerialPort import '../models/nymea_models.dart'; import '../models/consumer_series.dart'; +import '../models/load_telemetry.dart' show kDerogationMinS, kDerogationMaxS; import 'energy_ratios.dart'; // seam interim ratios (§2.1) // ── Protocole de connexion ───────────────────────────────────────────────────── @@ -1006,6 +1007,65 @@ class NymeaService extends ChangeNotifier { /// La charge utile porte `currentPower`, `totalConsumption`, `totalProduction` et /// `thingId`. **Seul `currentPower` est lu ici**, brut : voir `consumer_series.dart` pour /// pourquoi le signe n'est pas corrigé. + /// **Poser une dérogation** — le vrai « boost », et ce n'est pas un mode. + /// + /// 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. Le mode précédent est mémorisé par le moteur et restitué à la + /// fin. + /// + /// 🔴 **`durationS` est obligatoire et bornée `[60, 21600]`** — refusée hors de là. C'est + /// le filet, et il reste armé **même quand une cible est donnée** : une cible repose sur + /// un instrument, et un instrument peut se taire. Il n'y a donc pas de « boost jusqu'à + /// annulation » à offrir : le moteur le refuserait. + /// + /// `targetWh` est une ÉNERGIE livrée depuis l'octroi — jamais un état de charge. Aucune + /// communication véhicule n'existe (LM-1009), et une cible en SoC supposerait la + /// capacité, l'état de santé et le rendement. + /// + /// Rend `null` si la box a accepté, sinon son code de refus. + Future setLoadDerogation({ + required String loadId, + required int durationS, + double? targetWh, + }) async { + // La borne est vérifiée ICI plutôt que par l'écran seul : deux appelants, deux + // vérifications qui divergent. Hors bornes, la box refuserait de toute façon — autant + // ne pas dépenser l'aller-retour et rendre un refus lisible. + if (durationS < kDerogationMinS || durationS > kDerogationMaxS) { + return 'DurationOutOfRange'; + } + try { + final r = await _sendRequest('NymeaEnergy.SetLoadDerogation', { + 'loadId': loadId, + 'durationS': durationS, + 'targetWh': ?targetWh, + }); + final err = (r['params']?['energyError'] ?? r['energyError']) as String?; + return (err == null || err == 'EnergyErrorNoError') ? null : err; + } catch (e) { + _log('setLoadDerogation: $e'); + return e.toString(); + } + } + + /// **Annuler une dérogation avant son terme**, et restituer le mode mémorisé. + /// + /// La box rend `EnergyErrorInvalidParameter` si aucune dérogation ne tournait sur cette + /// charge — un refus qui n'est pas une panne : c'est le cas d'une dérogation qui vient de + /// finir toute seule pendant qu'on appuyait. + Future clearLoadDerogation(String loadId) async { + try { + final r = await _sendRequest( + 'NymeaEnergy.ClearLoadDerogation', {'loadId': loadId}); + final err = (r['params']?['energyError'] ?? r['energyError']) as String?; + return (err == null || err == 'EnergyErrorNoError') ? null : err; + } catch (e) { + _log('clearLoadDerogation: $e'); + return e.toString(); + } + } + Future fetchThingPowerLogs({ required List<({String id, String label, String? domaine})> appareils, required DateTime from, diff --git a/lib/widgets/ev_boost_button.dart b/lib/widgets/ev_boost_button.dart new file mode 100644 index 0000000..1a08a9d --- /dev/null +++ b/lib/widgets/ev_boost_button.dart @@ -0,0 +1,242 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +import '../models/load_telemetry.dart'; +import '../theme/etm_tokens.dart'; + +/// **Le boost — un bouton d'ACTION, pas une quatrième position du sélecteur.** +/// +/// Une dérogation 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. Elle ne remplace donc pas un mode : elle passe par-dessus, et le +/// mode revient après. C'est pour ça qu'elle ne peut pas être un bouton de plus à côté de +/// Manuel / PV / Min+PV. +/// +/// ## Une durée, toujours +/// +/// `durationS` est obligatoire côté moteur et bornée `[60 s, 6 h]`. Ce n'est pas une +/// contrainte technique à contourner : une dérogation qui ne sait pas dire quand elle finit +/// ne devrait pas exister (LM-1500). L'écran n'offre donc **aucune option « jusqu'à +/// annulation »** — pas parce que l'appel échouerait, mais parce que la promesse serait +/// mauvaise. +/// +/// Le filet reste armé **même quand une cible d'énergie est donnée** : une cible repose sur +/// un instrument, et un instrument peut se taire. +/// +/// ## L'état ne se lit pas dans le mode +/// +/// 🔴 Si la borne était en manuel, le moteur force Eco le temps de la dérogation. Lire +/// `GetChargingInfos` montrerait alors un mode que l'utilisateur n'a pas choisi. La vérité +/// est sur la charge : [LoadTelemetryEntry.enDerogation]. +class EvBoostButton extends StatefulWidget { + /// L'entrée de télémétrie de CETTE borne, ou `null` si elle n'est pas arbitrée en ce + /// moment (pas de véhicule branché, mode manuel…). + /// + /// `null` n'est pas « pas de boost » : c'est « on ne sait pas », et le bouton se tait + /// plutôt que d'annoncer un état qu'il n'a pas lu. + final LoadTelemetryEntry? entry; + + /// Pose la dérogation. Rend `null` si la box a accepté, sinon son code de refus. + final Future Function({required int durationS, double? targetWh}) onSet; + + /// Annule la dérogation en cours. + final Future Function() onClear; + + const EvBoostButton({ + super.key, + required this.entry, + required this.onSet, + required this.onClear, + }); + + @override + State createState() => _EvBoostButtonState(); +} + +class _EvBoostButtonState extends State { + bool _busy = false; + Timer? _tick; + + @override + void initState() { + super.initState(); + // Le compte à rebours avance à la seconde, mais **la vérité reste la trame** : on + // redessine, on ne décide pas. Si la box cesse d'annoncer la dérogation, elle + // disparaît au cycle suivant même si l'échéance locale n'est pas atteinte. + _tick = Timer.periodic(const Duration(seconds: 1), (_) { + if (mounted && (widget.entry?.enDerogation ?? false)) setState(() {}); + }); + } + + @override + void dispose() { + _tick?.cancel(); + super.dispose(); + } + + Future _lancer(int durationS, double? targetWh) async { + setState(() => _busy = true); + final err = await widget.onSet(durationS: durationS, targetWh: targetWh); + if (!mounted) return; + setState(() => _busy = false); + if (err != null) _dire('La box a refusé le boost ($err).'); + } + + Future _arreter() async { + setState(() => _busy = true); + final err = await widget.onClear(); + if (!mounted) return; + setState(() => _busy = false); + // `EnergyErrorInvalidParameter` = aucune dérogation ne tournait. Ce n'est pas une + // panne : elle a pu finir toute seule pendant l'appui. On ne crie pas au refus pour + // une course que l'utilisateur ne peut pas éviter. + if (err != null && err != 'EnergyErrorInvalidParameter') { + _dire('La box a refusé l\'arrêt ($err).'); + } + } + + void _dire(String m) => + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(m))); + + @override + Widget build(BuildContext context) { + final e = widget.entry; + if (e == null) return const SizedBox.shrink(); + return e.enDerogation ? _enCours(context, e) : _repos(context); + } + + // ── au repos ────────────────────────────────────────────────────────────── + Widget _repos(BuildContext context) => SizedBox( + height: 44, + child: OutlinedButton.icon( + onPressed: _busy ? null : () => _choisirDuree(context), + icon: const Icon(Icons.bolt_rounded, size: 18), + label: const Text('Boost…'), + style: OutlinedButton.styleFrom( + foregroundColor: EtmTokens.solarOf(context), + side: BorderSide( + color: EtmTokens.solarOf(context).withValues(alpha: 0.6)), + shape: + RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + ), + ), + ); + + /// Les durées proposées tiennent **toutes** dans `[60, 21600]`. Aucune n'est « illimitée » + /// — voir la note de tête. + static const _durees = <(String, int)>[ + ('30 min', 1800), + ('1 h', 3600), + ('2 h', 7200), + ('4 h', 14400), + ('6 h', 21600), + ]; + + Future _choisirDuree(BuildContext context) async { + final choix = await showModalBottomSheet( + context: context, + // ⚠️ **La feuille défile.** Cinq durées, un titre et deux lignes d'explication + // dépassent la hauteur disponible dès qu'un écran est court ou que la police système + // est agrandie — un débordement de 47 px sur la surface de test, donc bien réel sur + // un téléphone. Un contenu qui peut excéder son viewport doit pouvoir défiler ; s'en + // remettre à « ça tient chez moi » est la même erreur que le montage trop lâche. + builder: (ctx) => SafeArea( + child: SingleChildScrollView( + child: Column(mainAxisSize: MainAxisSize.min, children: [ + const SizedBox(height: 12), + Text('Boost — pendant combien de temps ?', + style: EtmTokens.sans( + size: 15, + weight: FontWeight.w600, + color: EtmTokens.inkOf(ctx))), + Padding( + padding: const EdgeInsets.fromLTRB(20, 8, 20, 4), + child: Text( + 'La charge est servie à son maximum, achetée au réseau, devant toutes ' + 'les autres. Elle s\'arrête d\'elle-même au bout du temps choisi.', + textAlign: TextAlign.center, + style: EtmTokens.sans(size: 12, color: EtmTokens.mutedOf(ctx)), + ), + ), + for (final (label, s) in _durees) + ListTile( + leading: const Icon(Icons.timer_outlined), + title: Text(label), + onTap: () => Navigator.pop(ctx, s), + ), + const SizedBox(height: 8), + ]), + ), + ), + ); + if (choix != null) await _lancer(choix, null); + } + + // ── en cours ────────────────────────────────────────────────────────────── + Widget _enCours(BuildContext context, LoadTelemetryEntry e) { + final d = e.derogation; + final restant = d?.restant(); + final depasse = d?.depasse() ?? false; + + return Container( + padding: const EdgeInsets.fromLTRB(12, 10, 8, 10), + decoration: BoxDecoration( + color: EtmTokens.solarOf(context).withValues(alpha: 0.10), + borderRadius: BorderRadius.circular(10), + border: + Border.all(color: EtmTokens.solarOf(context).withValues(alpha: 0.5)), + ), + child: Row(children: [ + Icon(Icons.bolt_rounded, size: 18, color: EtmTokens.solarOf(context)), + const SizedBox(width: 8), + Expanded( + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text(_titre(restant, depasse), + style: EtmTokens.sans( + size: 13, + weight: FontWeight.w600, + color: EtmTokens.inkOf(context))), + if (_detail(d) != null) + Text(_detail(d)!, + style: + EtmTokens.sans(size: 11, color: EtmTokens.mutedOf(context))), + ]), + ), + TextButton( + onPressed: _busy ? null : _arreter, + child: const Text('Arrêter'), + ), + ]), + ); + } + + /// ⚠️ **Une échéance dépassée ne se rabat pas à « 00:00 ».** La box annonce encore la + /// dérogation active alors que son échéance est passée : c'est une contradiction entre + /// deux champs de la même trame, pas un compte à rebours terminé. L'afficher à zéro + /// effacerait la seule chose qui la signale. + static String _titre(Duration? restant, bool depasse) { + if (depasse) return 'Boost en cours — échéance dépassée'; + if (restant == null) return 'Boost en cours'; + final m = restant.inMinutes; + final s = restant.inSeconds % 60; + return m >= 60 + ? 'Boost — ${restant.inHours} h ${(m % 60).toString().padLeft(2, '0')} restantes' + : 'Boost — ${m.toString().padLeft(2, '0')}:${s.toString().padLeft(2, '0')} restantes'; + } + + /// Ce qui BRIDE, quand quelque chose bride. Silencieux sinon : « rien ne te bride » n'est + /// pas une information qu'un écran doit répéter à chaque seconde. + static String? _detail(Derogation? d) { + if (d == null) return null; + final max = d.maxW; + final borne = switch (d.boundBy) { + 'drawCap' => 'limité par l\'abonnement', + 'loadCeiling' => 'la borne est à son plafond', + _ => null, + }; + if (max == null && borne == null) return null; + final puissance = max == null ? null : '${(max / 1000).toStringAsFixed(1)} kW'; + return [puissance, borne].whereType().join(' · '); + } +} diff --git a/test/derogation_test.dart b/test/derogation_test.dart new file mode 100644 index 0000000..1fb2791 --- /dev/null +++ b/test/derogation_test.dart @@ -0,0 +1,127 @@ +/// **La dérogation — le vrai boost, et il n'est pas un mode.** +/// +/// Écrite d'après le contrat du moteur (`+etm61`, `SetLoadDerogation` / +/// `ClearLoadDerogation` vérifiées dans `Introspect` sur `.75`). Aucune dérogation n'a +/// encore tourné pendant une sonde : ces tests figent la LECTURE du contrat, pas une +/// observation. C'est écrit ici pour que personne ne les prenne pour une preuve de terrain. +library; + +import 'package:etm_powersync_app/models/load_telemetry.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('on ne lit pas le MODE pour savoir si un boost tourne', () { + test('la dérogation se lit sur la décision de la CHARGE', () { + final d = LoadDecision.fromJson({ + 'code': 'DEROGATION_ACTIVE', + 'params': { + 'endsAt': '2026-09-08T14:30:00Z', + 'maxW': 7400.0, + 'gridW': 7400.0, + 'boundBy': 'drawCap', + }, + }); + final dero = Derogation.depuis(d)!; + expect(dero.maxW, 7400); + expect(dero.gridW, 7400); + expect(dero.boundBy, 'drawCap'); + // Un instant se rend chez le lecteur — comme partout ailleurs. + expect(dero.endsAt!.isUtc, isFalse); + expect(dero.endsAt!.isAtSameMomentAs(DateTime.utc(2026, 9, 8, 14, 30)), isTrue); + }); + + test('toute autre décision ne donne AUCUNE dérogation', () { + final d = LoadDecision.fromJson({ + 'code': 'ECO_FLOOR_GRID', + // ⚠️ `ECO_FLOOR_GRID` porte AUSSI un `boundBy`, et son vocabulaire est différent : + // relevé « minimum » sur la V2C le 2026-09-08. Deux champs de même nom, deux + // catalogues — les confondre ferait lire un boost là où il y a un plancher. + 'params': {'boundBy': 'minimum', 'minAlwaysW': 4140.0}, + }); + expect(Derogation.depuis(d), isNull); + }); + }); + + group('le compte à rebours ne se rabat pas à zéro en silence', () { + final fin = DateTime.utc(2026, 9, 8, 14, 30); + Derogation dero() => Derogation(endsAt: fin.toLocal()); + + test('avant le terme, il reste du temps', () { + final r = dero().restant(maintenant: fin.toLocal().subtract(const Duration(minutes: 5))); + expect(r, const Duration(minutes: 5)); + expect(dero().depasse(maintenant: fin.toLocal().subtract(const Duration(minutes: 5))), + isFalse); + }); + + test('après le terme, l\'incohérence se NOMME', () { + // Une échéance dépassée pendant que la box annonce encore la dérogation active + // n'est pas « un compte à rebours fini » : c'est une contradiction entre deux champs + // de la même trame. La rabattre à « 00:00 » l'effacerait. + final apres = fin.toLocal().add(const Duration(minutes: 2)); + expect(dero().depasse(maintenant: apres), isTrue); + expect(dero().restant(maintenant: apres)!.isNegative, isTrue); + }); + + test('sans échéance publiée, il n\'y a pas de compte à rebours — et pas zéro', () { + const d = Derogation(maxW: 7400); + expect(d.restant(), isNull); + expect(d.depasse(), isFalse); + }); + }); + + group('six causes de fin, six phrases', () { + test('chacune dit ce qu\'elle demande au lecteur', () { + const causes = { + 'targetReached': 'Cible atteinte.', + 'durationElapsed': 'Durée écoulée.', + 'unplugged': 'Véhicule débranché.', + 'cancelled': 'Annulée.', + 'modeChanged': 'Un mode a été choisi pendant la dérogation.', + }; + for (final e in causes.entries) { + expect(DerogationEnded(loadId: 'x', cause: e.key).phrase, e.value); + } + // La seule qui appelle une VÉRIFICATION plutôt qu'un constat. + expect(const DerogationEnded(loadId: 'x', cause: 'measurementLost').phrase, + contains('vérifiez')); + }); + + test('une cause INCONNUE ne reçoit pas de libellé inventé', () { + // Une box plus récente que l'app peut en publier une septième. Lui coller la phrase + // d'une autre serait pire que de dire « fin non reconnue ». + expect(const DerogationEnded(loadId: 'x', cause: 'somethingNew').phrase, isNull); + }); + + test('`restoredMode` absent sur un changement de mode — et c\'est voulu', () { + // On ne restitue pas : ce serait écraser le mode que l'utilisateur vient de choisir. + final e = DerogationEnded.fromJson( + {'loadId': 'x', 'cause': 'modeChanged'}); + expect(e.restoredMode, isNull); + final f = DerogationEnded.fromJson( + {'loadId': 'x', 'cause': 'durationElapsed', 'restoredMode': 0}); + expect(f.restoredMode, 0); + }); + }); + + group('la trame porte la fin, pas la charge', () { + test('`derogationEnded` se lit au niveau de la TRAME', () { + // Au débranchement, la charge n'est déjà plus dans `loads[]` : chercher la fin + // dedans ne la trouverait jamais, précisément dans le cas où elle compte le plus. + final t = LoadTelemetry.fromJson({ + 'degradedMode': false, + 'loads': const [], + 'derogationEnded': [ + {'loadId': 'v2c', 'cause': 'unplugged'} + ], + }); + expect(t.loads, isEmpty); + expect(t.derogationEnded, hasLength(1)); + expect(t.derogationEnded.first.cause, 'unplugged'); + }); + + test('absente, la liste est vide — pas nulle', () { + final t = LoadTelemetry.fromJson({'degradedMode': false, 'loads': const []}); + expect(t.derogationEnded, isEmpty); + }); + }); +} diff --git a/test/ev_boost_button_test.dart b/test/ev_boost_button_test.dart new file mode 100644 index 0000000..62aaaf9 --- /dev/null +++ b/test/ev_boost_button_test.dart @@ -0,0 +1,148 @@ +/// **Le bouton de boost — ce qu'il refuse de faire compte autant que ce qu'il fait.** +library; + +import 'package:etm_powersync_app/models/load_telemetry.dart'; +import 'package:etm_powersync_app/widgets/ev_boost_button.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +LoadTelemetryEntry _entree({Map? decision}) => + LoadTelemetryEntry.fromJson({ + 'loadId': '{4d1adafd}', + 'allocatedW': 4140.0, + 'available': true, + 'decision': decision ?? + {'code': 'ECO_FLOOR_GRID', 'params': {'gridW': 4140}}, + }); + +Widget _harness({ + LoadTelemetryEntry? entry, + Future Function({required int durationS, double? targetWh})? onSet, + Future Function()? onClear, +}) => + MaterialApp( + home: Scaffold( + body: EvBoostButton( + entry: entry, + onSet: onSet ?? ({required durationS, targetWh}) async => null, + onClear: onClear ?? () async => null, + ), + ), + ); + +void main() { + testWidgets('sans entrée de télémétrie, le bouton se TAIT', (tester) async { + // « Pas d'entrée » n'est pas « pas de boost » : c'est « on ne sait pas ». Afficher un + // bouton de boost sur une borne qu'on ne voit pas arbitrée promettrait une action dont + // on ignore si elle a un sens. + await tester.pumpWidget(_harness(entry: null)); + expect(find.byType(OutlinedButton), findsNothing); + expect(find.textContaining('Boost'), findsNothing); + }); + + testWidgets('au repos, il propose des durées — et AUCUNE illimitée', (tester) async { + await tester.pumpWidget(_harness(entry: _entree())); + await tester.tap(find.text('Boost…')); + await tester.pumpAndSettle(); + + for (final d in ['30 min', '1 h', '2 h', '4 h', '6 h']) { + expect(find.text(d), findsOneWidget); + } + // Le contrôle qui MORD : une dérogation qui ne sait pas dire quand elle finit ne doit + // pas exister (LM-1500), et le moteur refuserait au-delà de 6 h de toute façon. + for (final interdit in ['Illimité', 'Jusqu\'à annulation', '12 h', '24 h']) { + expect(find.text(interdit), findsNothing); + } + }); + + testWidgets('choisir une durée pose la dérogation avec CETTE durée', (tester) async { + int? recu; + await tester.pumpWidget(_harness( + entry: _entree(), + onSet: ({required durationS, targetWh}) async { + recu = durationS; + return null; + }, + )); + await tester.tap(find.text('Boost…')); + await tester.pumpAndSettle(); + await tester.tap(find.text('2 h')); + await tester.pumpAndSettle(); + expect(recu, 7200); + }); + + group('en cours', () { + LoadTelemetryEntry active({String? endsAt, String? boundBy, double? maxW}) => + _entree(decision: { + 'code': 'DEROGATION_ACTIVE', + 'params': { + if (endsAt != null) 'endsAt': endsAt, + if (boundBy != null) 'boundBy': boundBy, + if (maxW != null) 'maxW': maxW, + }, + }); + + testWidgets('l\'état vient de la CHARGE, et offre d\'arrêter', (tester) async { + await tester.pumpWidget(_harness( + entry: active( + endsAt: + DateTime.now().toUtc().add(const Duration(minutes: 42)).toIso8601String()))); + expect(find.textContaining('Boost —'), findsOneWidget); + expect(find.text('Arrêter'), findsOneWidget); + expect(find.text('Boost…'), findsNothing); + }); + + testWidgets('une échéance DÉPASSÉE se nomme, elle ne se rabat pas à zéro', + (tester) async { + // La box annonce encore la dérogation active alors que son échéance est passée : + // c'est une contradiction entre deux champs de la même trame. « 00:00 » l'effacerait. + await tester.pumpWidget(_harness( + entry: active( + endsAt: DateTime.now() + .toUtc() + .subtract(const Duration(minutes: 3)) + .toIso8601String()))); + expect(find.textContaining('échéance dépassée'), findsOneWidget); + expect(find.textContaining('00:00'), findsNothing); + }); + + testWidgets('sans échéance publiée, pas de compte à rebours inventé', (tester) async { + await tester.pumpWidget(_harness(entry: active())); + expect(find.text('Boost en cours'), findsOneWidget); + }); + + testWidgets('ce qui BRIDE se dit, le reste se tait', (tester) async { + await tester.pumpWidget( + _harness(entry: active(boundBy: 'drawCap', maxW: 7400))); + expect(find.textContaining('limité par l\'abonnement'), findsOneWidget); + expect(find.textContaining('7.4 kW'), findsOneWidget); + + // Rien ne bride : « rien ne te bride » n'est pas une information à répéter. + await tester.pumpWidget(_harness(entry: active())); + await tester.pumpAndSettle(); + expect(find.textContaining('limité'), findsNothing); + }); + + testWidgets('un refus « aucune dérogation » ne crie pas au refus', (tester) async { + // Elle a pu finir toute seule pendant l'appui : une course que l'utilisateur ne peut + // pas éviter ne doit pas lui être présentée comme une erreur. + await tester.pumpWidget(_harness( + entry: active(), + onClear: () async => 'EnergyErrorInvalidParameter', + )); + await tester.tap(find.text('Arrêter')); + await tester.pumpAndSettle(); + expect(find.textContaining('a refusé'), findsNothing); + }); + + testWidgets('un VRAI refus, lui, se voit', (tester) async { + await tester.pumpWidget(_harness( + entry: active(), + onClear: () async => 'EnergyErrorHardwareFailure', + )); + await tester.tap(find.text('Arrêter')); + await tester.pumpAndSettle(); + expect(find.textContaining('EnergyErrorHardwareFailure'), findsOneWidget); + }); + }); +}