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); + }); + }); +}