diff --git a/TODO.md b/TODO.md index d3f3f4f..7fbca20 100644 --- a/TODO.md +++ b/TODO.md @@ -35,16 +35,16 @@ prêtes, les motifs n'ont **jamais été vus sur machine**. Signaler la première occurrence réelle. Ne rien bâtir autour d'ici là. -- [ ] ⏳ **Deux grandeurs de la V2C attendent le plugin — ne rien câbler d'ici là.** - Mesuré sur `.75` le 2026-08-28, sonde `tools/rpc/evcharger_watch.dart`. - - `chargeEnergy` est **exact pendant la charge** et **remis à zéro à chaque - interruption** — donc à chaque nuage sur du pilotage par surplus. Le plugin V2C - publiera un `sessionEnergy` construit ; attendre celui-là, et **n'accumuler rien côté - app**. D'ici là, la condition C1 de LM-1009 s'applique en vrai sur cette borne : - « pas mesurable » et « rien livré » sont deux états différents, et zéro dit le second. - - `powerL1/L2/L3` sont des **ampères mal nommés** ; le renommage `currentL1/L2/L3` - (unité Ampere) est en cours côté plugin. Ne pas les afficher avant son déploiement, et - **n'écrire aucune conversion côté app** — elle survivrait au correctif. +- [ ] ⏳ **`sessionEnergy` existe maintenant — mais rien ne prouve encore qu'il survive à + une interruption.** Relevé du 2026-08-28 après-midi sur `.75` (`Integrations.GetThings`) : + la V2C publie `sessionEnergy` **et** `chargeEnergy`, tous deux à **4,229 kWh** — égaux, + ce qui est exactement ce qu'on attend d'une session sans interruption. Le test qui + tranche est une **remise à zéro observée de `chargeEnergy` avec un `sessionEnergy` qui + continue**. Tant qu'elle n'est pas vue, LM-1009 §C1 reste entière : **pas d'avancement + affiché**. À vérifier sur la campagne de mesures en cours. + - Deux attentes **levées** au même relevé : le renommage `currentL1/L2/L3` (unité + Ampère) **est déployé** — plus de `powerL1/L2/L3` — et `phaseCount` **existe** sur la + Trydan (valeur 3), contrairement à ce que `docs/PREPA_BANC_20260828.md` avait conclu. - [x] ~~**`EVChargingCard` n'est montée par AUCUN écran**~~ — **tranché le 2026-08-28 : elle part.** La monter aurait publié de la fiction. Trois des grandeurs qu'elle affiche @@ -54,13 +54,25 @@ « Paramètres de charge » proposait en plus un **courant minimum**, qui n'existe dans aucun contrat, et son bouton « Enregistrer » ne faisait que `Navigator.pop` : rien n'était écrit. -- [ ] 🔴 **La borne EV n'a plus AUCUNE surface honnête dans l'app — et il en reste une - malhonnête.** `_EVWidget` (`screens/favorites_screen.dart:419`) est **vivante**, affiche - les mêmes valeurs par défaut comme si elles étaient mesurées (« 3600 W », « 82 % solaire », - « Mode PV ») et **écrit** le mode de recharge sur des pastilles de 7 à 10 px, sans - restituer un refus. Deux gestes, dans cet ordre : rendre ce widget muet sur ce qu'il ne - lit pas, puis bâtir la vraie carte. - Ce qu'une carte de recharge peut dire aujourd'hui, et rien de plus : +- [x] ~~**La tuile EV des favoris affiche des valeurs par défaut comme des mesures**~~ — + **fait le 2026-08-28 : rendue muette, pas retirée.** La retirer aurait laissé un trou + dans une liste que le client a composée lui-même. `EvFavoriteTile` + (`widgets/ev_favorite_tile.dart`) lit la borne — `pluggedIn`, `charging`, `currentPower` + du Thing, mode par `GetChargingInfos` + `ChargingInfoChanged` — et **dit** ce qui n'est + pas publié plutôt que d'afficher zéro. L'écriture du mode **restitue le refus** et les + cibles tactiles passent de 7–10 px à 44 px. Les trois champs fictifs de `EnergyData` + (`chargingMode`, `chargingPower`, `solarSourcePercent`) sont **supprimés du modèle** : + la fiction n'a plus de porte d'entrée. + +- [ ] 🔴 **La vue « consommation » du Sankey est INVENTÉE — tableau de bord, écran + d'accueil.** `features/dashboard/widgets/energy_flow_card.dart` : au drill-down + consommation, *PAC = 38 % de la maison*, *Eau chaude = 18 %*, *Autres = 22 %* — trois + ratios en dur, dont un seul (`Autres`) porte le marqueur `estimated`. La voiture, elle, + a été branchée sur la mesure réelle de la borne le 2026-08-28 (`currentPower`, « non + lu » sans borne). Le reste attend une décision : la source existe — `GetLoadTelemetry` + publie `measuredW` **par charge** (relevé du jour : chauffe-eau 1500 W, PAC 800 W) — mais + brancher le Sankey dessus est une refonte de l'écran d'accueil, pas un correctif. + Ce qu'une carte de recharge dédiée pourrait dire, si elle revient un jour : - le **mode courant**, lu par `NymeaEnergy.GetChargingInfos` et tenu à jour par `ChargingInfoChanged` — l'écriture existe déjà et est vérifiée (`nymea_service.setChargingInfo`, lecture → patch → réécriture complète) ; diff --git a/docs/BRIEF_plugin_depuis_maquettes.md b/docs/BRIEF_plugin_depuis_maquettes.md index a953ebb..e7e71ad 100644 --- a/docs/BRIEF_plugin_depuis_maquettes.md +++ b/docs/BRIEF_plugin_depuis_maquettes.md @@ -24,7 +24,10 @@ reviendrait au cycle suivant. Rendu figé par test sur la trame ci-dessus. ne puisse s'exprimer, donc une charge variable dont le Thing a disparu sort en `WRITE_FAILED` après épuisement de l'échelle d'écriture. L'adaptateur `evcharger` n'a pas ce défaut, ce qui explique que le code sorte proprement ici et pas là. Il n'est donc **pas -corrigé, seulement contourné par le hasard du chemin de code**. +corrigé, seulement contourné par le hasard du chemin de code** — et la nuance compte : ce +n'est pas « ne se produit pas », c'est « **ne s'est pas encore produit** ». Il attend une +installation où une charge variable — relais, SG-Ready — perd son Thing. Le banc n'a pas +encore eu ce cas ; une installation client l'aura. **Pourquoi ça vaut un correctif et pas un renvoi à la doc.** Les deux codes envoient le client faire deux gestes différents : `WRITE_FAILED` fait chercher un défaut de liaison — diff --git a/lib/features/dashboard/widgets/energy_flow_card.dart b/lib/features/dashboard/widgets/energy_flow_card.dart index 9cfd0f5..dbf6fef 100644 --- a/lib/features/dashboard/widgets/energy_flow_card.dart +++ b/lib/features/dashboard/widgets/energy_flow_card.dart @@ -8,7 +8,16 @@ enum _DrillView { overview, cons, prod } class EnergyFlowCard extends StatefulWidget { final EnergyData data; - const EnergyFlowCard({super.key, required this.data}); + + /// Puissance **mesurée** de la borne de recharge (`currentPower` du Thing), ou `null` + /// quand aucune borne n'est déclarée — ou qu'elle ne publie pas cet état. + /// + /// Elle valait 3,6 kW en dur jusqu'au 2026-08-28 : `EnergyData.chargingPower` était une + /// valeur par défaut que rien ne remplaçait, et le Sankey dessinait donc en permanence + /// un flux vers une voiture qui n'était pas branchée. + final double? evChargerPowerW; + const EnergyFlowCard( + {super.key, required this.data, this.evChargerPowerW}); @override State createState() => _EnergyFlowCardState(); @@ -115,7 +124,8 @@ class _EnergyFlowCardState extends State child: LayoutBuilder( builder: (context, constraints) { final size = constraints.maxWidth; - final segments = _buildSegments(widget.data, _view); + final segments = + _buildSegments(widget.data, _view, widget.evChargerPowerW); return AnimatedBuilder( animation: _particleCtrl, builder: (context, _) { @@ -143,6 +153,7 @@ class _EnergyFlowCardState extends State key: ValueKey(_view), view: _view, data: widget.data, + evChargerPowerW: widget.evChargerPowerW, pulseAnim: disableAnim ? null : _pulseAnim, onTapSolar: () => _switchView(_DrillView.prod), onTapMaison: () => _switchView(_DrillView.cons), @@ -192,7 +203,8 @@ class _EnergyFlowCardState extends State static const double _nE = 0.22; static const double _hE = 0.39; - static List _buildSegments(EnergyData d, _DrillView view) { + static List _buildSegments( + EnergyData d, _DrillView view, double? evW) { const nE = _nE; const hE = _hE; @@ -216,7 +228,7 @@ class _EnergyFlowCardState extends State return [ FlowSegment(from: Offset(0.5, hE), to: Offset(0.5, nE), color: EtmTokens.heat, powerW: total * 0.38), FlowSegment(from: Offset(1-hE, 0.5), to: Offset(1-nE, 0.5), color: EtmTokens.water, powerW: total * 0.18), - FlowSegment(from: Offset(0.5, 1-hE), to: Offset(0.5, 1-nE), color: EtmTokens.eco, powerW: d.chargingPower * 1000), + FlowSegment(from: Offset(0.5, 1-hE), to: Offset(0.5, 1-nE), color: EtmTokens.eco, powerW: evW ?? 0), FlowSegment(from: Offset(hE, 0.5), to: Offset(nE, 0.5), color: EtmTokens.gridColor, powerW: total * 0.22, dashed: true), ]; @@ -235,6 +247,9 @@ class _EnergyFlowCardState extends State class _NodeLayer extends StatefulWidget { final _DrillView view; final EnergyData data; + + /// Puissance mesurée de la borne — `null` = non lue. Voir [EnergyFlowCard]. + final double? evChargerPowerW; final Animation? pulseAnim; final VoidCallback onTapSolar; final VoidCallback onTapMaison; @@ -243,6 +258,7 @@ class _NodeLayer extends StatefulWidget { super.key, required this.view, required this.data, + required this.evChargerPowerW, required this.pulseAnim, required this.onTapSolar, required this.onTapMaison, @@ -370,7 +386,12 @@ class _NodeLayerState extends State<_NodeLayer> label: 'Eau chaude', value: '${(total * 0.18 / 1000).toStringAsFixed(1)} kW'))), _at(Offset(0.5, 0.88), ScaleTransition(scale: _stagger(3), child: _sat(context, icon: Icons.ev_station_rounded, color: eco(context), - label: 'Voiture', value: '${d.chargingPower.toStringAsFixed(1)} kW'))), + label: 'Voiture', + // « non lu » plutôt qu'un zéro : sans borne déclarée, l'app ne sait pas + // qu'il ne se charge rien — elle sait qu'elle ne mesure rien. + value: widget.evChargerPowerW == null + ? 'non lu' + : '${(widget.evChargerPowerW! / 1000).toStringAsFixed(1)} kW'))), _at(Offset(0.12, 0.5), ScaleTransition(scale: _stagger(4), child: _sat(context, icon: Icons.devices_other_rounded, color: EtmTokens.gridColor, label: 'Autres', value: '${(total * 0.22 / 1000).toStringAsFixed(1)} kW', diff --git a/lib/models/energy_data.dart b/lib/models/energy_data.dart index cf9704e..f440c8b 100644 --- a/lib/models/energy_data.dart +++ b/lib/models/energy_data.dart @@ -28,10 +28,17 @@ class EnergyData { final double autonomyRate; // % final double dayGains; // € - // Borne de recharge - final ChargingMode chargingMode; - final double chargingPower; // kW - final double solarSourcePercent; // % + // ── Borne de recharge : PLUS RIEN ICI ────────────────────────────────────── + // + // `chargingMode`, `chargingPower` (3,6 kW) et `solarSourcePercent` (82 %) ont vécu ici + // avec des valeurs par défaut que rien ne remplaçait : `_updateEnergyData` ne les + // touchait pas, et le mode n'avançait qu'en optimiste après une commande envoyée depuis + // l'app. Trois écrans les affichaient comme des mesures — la tuile des favoris, le + // Sankey du tableau de bord, et la carte véhicule (supprimée le 2026-08-28). + // + // Ce que la box publie vraiment sur une borne vit désormais dans `EvChargerLive` + // (`models/ev_charger_live.dart`), champ par champ, nullable : `null` veut dire « non + // publié », jamais zéro. final bool gridLimitOk; const EnergyData({ @@ -50,9 +57,6 @@ class EnergyData { this.selfConsumptionRate = 0, this.autonomyRate = 0, this.dayGains = 0, - this.chargingMode = ChargingMode.pv, - this.chargingPower = 3.6, - this.solarSourcePercent = 82, this.gridLimitOk = true, }); @@ -72,9 +76,6 @@ class EnergyData { double? selfConsumptionRate, double? autonomyRate, double? dayGains, - ChargingMode? chargingMode, - double? chargingPower, - double? solarSourcePercent, bool? gridLimitOk, }) { return EnergyData( @@ -93,9 +94,6 @@ class EnergyData { selfConsumptionRate: selfConsumptionRate ?? this.selfConsumptionRate, autonomyRate: autonomyRate ?? this.autonomyRate, dayGains: dayGains ?? this.dayGains, - chargingMode: chargingMode ?? this.chargingMode, - chargingPower: chargingPower ?? this.chargingPower, - solarSourcePercent: solarSourcePercent ?? this.solarSourcePercent, gridLimitOk: gridLimitOk ?? this.gridLimitOk, ); } diff --git a/lib/models/ev_charger_live.dart b/lib/models/ev_charger_live.dart new file mode 100644 index 0000000..1c37699 --- /dev/null +++ b/lib/models/ev_charger_live.dart @@ -0,0 +1,76 @@ +/// Ce qu'une borne de recharge **publie réellement**, et rien d'autre. +/// +/// ## Pourquoi ce type existe +/// +/// L'app portait trois grandeurs de recharge qui n'étaient jamais lues sur la box : +/// `EnergyData.chargingMode` (défaut `pv`), `chargingPower` (défaut 3,6 kW) et +/// `solarSourcePercent` (défaut 82 %). Elles étaient affichées comme des mesures — « 3600 +/// W », « 82 % solaire » — sur un écran de favoris que le client peut ouvrir. C'est la même +/// famille de défaut que le SOC à 62 % en dur et les quatre pièces de climatisation +/// fictives : une valeur par défaut présentée comme une lecture. +/// +/// Ce type ne porte donc **que des champs nullables**, et `null` veut dire *la box ne le +/// publie pas* — jamais zéro. L'écran doit le dire, pas le combler. +/// +/// ## Ce qui est lisible aujourd'hui, mesuré sur `.75` le 2026-08-28 +/// +/// Les deux bornes du banc (Simulated wallbox, V2C Trydan) publient `pluggedIn`, +/// `charging`, `currentPower` et `maxChargingCurrent`. Le mode, lui, ne vient pas du Thing +/// mais de `NymeaEnergy.GetChargingInfos` — les deux bornes y étaient en +/// `ChargingModeNormal`, `ChargingStateIdle`. +/// +/// **`sessionEnergy` existe** sur les deux (4,229 kWh sur la V2C), et le renommage +/// `currentL1/L2/L3` **est déployé** — plus de `powerL1/L2/L3`. Ces deux champs ne sont pas +/// portés ici pour autant : voir la note de `LoadMechanism` +/// (`models/load_telemetry.dart`), l'égalité `sessionEnergy == chargeEnergy` relevée ce +/// jour ne prouve pas encore que le premier survit à une interruption de charge, et c'est +/// exactement ce que LM-1009 §C1 demande de savoir avant d'afficher un avancement. +library; + +import 'energy_data.dart' show ChargingMode; + +class EvChargerLive { + /// `thingId` de la borne — l'identifiant que `SetChargingInfo` attend. + final String thingId; + final String name; + + /// États du Thing. `null` = non publié par cette borne. + final bool? pluggedIn; + final bool? charging; + final double? currentPowerW; + + /// Mode **tel que la box le porte** (`ChargingModeEco`, `ChargingModeNormal`, …), lu par + /// `GetChargingInfos`. `null` = pas encore lu, ou aucune entrée pour cette borne — et + /// dans ce cas l'écran n'a **aucun** mode à présenter comme courant. + final String? apiMode; + + const EvChargerLive({ + required this.thingId, + required this.name, + this.pluggedIn, + this.charging, + this.currentPowerW, + this.apiMode, + }); + + /// Traduction du mode de la box vers les trois boutons de l'IU. + /// + /// Les cinq valeurs de l'énumération se replient sur trois : les variantes « avec + /// échéance » gardent le bouton de base, l'échéance étant une option à côté. `null` pour + /// 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, + 'ChargingModeEco' || 'ChargingModeEcoWithTargetTime' => ChargingMode.pv, + 'ChargingModeEcoWithMinCurrent' || + 'ChargingModeEcoMinWithTargetTime' => + ChargingMode.minPv, + _ => null, + }; + + /// Vrai quand la borne porte une échéance dans son mode — l'information est dans le nom + /// de l'énumération, pas dans un champ à part. + bool get hasDeadlineMode => + apiMode == 'ChargingModeEcoWithTargetTime' || + apiMode == 'ChargingModeEcoMinWithTargetTime'; +} diff --git a/lib/models/load_telemetry.dart b/lib/models/load_telemetry.dart index a02ed85..df08ffe 100644 --- a/lib/models/load_telemetry.dart +++ b/lib/models/load_telemetry.dart @@ -181,9 +181,20 @@ class LoadMechanism { /// publiera un `sessionEnergy` construit ; attendre celui-là**, et n'accumuler rien /// côté app en attendant. /// - /// Même prudence pour `powerL1/L2/L3` de cette borne : ce sont des **ampères mal nommés** - /// (renommage `currentL1/L2/L3` en cours côté plugin). Ne pas les afficher, et surtout - /// n'écrire aucune conversion ici — elle survivrait au correctif et le contredirait. + /// **Relevé de suivi, 2026-08-28 après-midi** — deux des trois attentes sont levées, la + /// troisième ne l'est pas : + /// + /// - le renommage **est déployé** : la V2C publie `currentL1/L2/L3` (5,86 / 5,94 / + /// 6,02 A), et `powerL1/L2/L3` a disparu. Il n'y a donc plus de conversion à ne pas + /// écrire — mais toujours aucune raison d'afficher des ampères par phase avant qu'un + /// écran ne les demande ; + /// - `phaseCount` **existe** sur la Trydan (valeur 3) : la note qui le disait absent de + /// la carte Modbus était en retard sur la machine ; + /// - `sessionEnergy` **existe aussi** (4,229 kWh) — mais il valait **exactement** + /// `chargeEnergy` au moment du relevé. L'égalité ne prouve rien : c'est justement ce + /// qu'on attend d'une session sans interruption. **Tant qu'une remise à zéro de + /// `chargeEnergy` n'a pas été observée avec un `sessionEnergy` qui, lui, continue, + /// la condition C1 de LM-1009 reste entière** : ne pas afficher d'avancement. // ── evcharger (3g) — l'ÉTAT RÉEL de la borne ──────────────────────────────── // diff --git a/lib/screens/dashboard_screen.dart b/lib/screens/dashboard_screen.dart index d430e9f..c543d9a 100644 --- a/lib/screens/dashboard_screen.dart +++ b/lib/screens/dashboard_screen.dart @@ -79,7 +79,12 @@ class _DashboardScreenState extends State { sliver: SliverList( delegate: SliverChildListDelegate([ const SizedBox(height: 14), - EnergyFlowCard(data: data), + // La voiture du Sankey vient de la BORNE, pas d'un défaut de + // modèle : `null` quand aucune borne n'est déclarée. + EnergyFlowCard( + data: data, + evChargerPowerW: + service.evCharger?.currentPowerW), const SizedBox(height: 14), KpiRow(data: data, balance: _bilan), const SizedBox(height: 14), diff --git a/lib/screens/favorites_screen.dart b/lib/screens/favorites_screen.dart index 5d77d57..fd72acc 100644 --- a/lib/screens/favorites_screen.dart +++ b/lib/screens/favorites_screen.dart @@ -3,6 +3,7 @@ import 'package:provider/provider.dart'; import '../models/energy_data.dart'; import '../models/nymea_models.dart'; import '../services/nymea_service.dart'; +import '../widgets/ev_favorite_tile.dart'; import '../theme/etm_tokens.dart'; import '../main.dart' show DrawerMenuButton; @@ -207,7 +208,12 @@ class _FavoriteCard extends StatelessWidget { sub: d.gridPower > 0 ? 'Achat réseau' : 'Vente réseau', ), FavoriteType.rates => _RatesWidget(data: d), - FavoriteType.evCharger => _EVWidget(data: d, service: service), + // La tuile ne reçoit plus `EnergyData` : ses trois grandeurs de recharge + // n'étaient jamais lues sur la box. Elle lit la borne, ou dit qu'il n'y a + // rien à lire. + FavoriteType.evCharger => EvFavoriteTile( + live: service.evCharger, + onSetMode: (m) => service.setChargingInfo(mode: m)), FavoriteType.thingState => _ThingStateWidget(fav: fav, service: service), FavoriteType.chart => _MiniChart(history: service.historyPoints), }; @@ -416,75 +422,6 @@ class _RateTile extends StatelessWidget { } } -class _EVWidget extends StatelessWidget { - final EnergyData data; - final NymeaService service; - const _EVWidget({required this.data, required this.service}); - - @override - Widget build(BuildContext context) { - final (color, label) = switch (data.chargingMode) { - ChargingMode.pv => (EtmTokens.amber, 'PV'), - ChargingMode.minPv => (EtmTokens.blue, 'Min+PV'), - ChargingMode.boost => (EtmTokens.green, 'Boost'), - }; - - return Row( - children: [ - Container( - width: 52, height: 52, - decoration: BoxDecoration( - color: color.withValues(alpha: 0.12), - borderRadius: BorderRadius.circular(14), - ), - child: Icon(Icons.ev_station_rounded, color: color, size: 28), - ), - const SizedBox(width: 14), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('${(data.chargingPower * 1000).toStringAsFixed(0)} W', - style: EtmTokens.mono( - size: 26, weight: FontWeight.w700, color: color)), - Text('Mode $label', - style: EtmTokens.sans(size: 12, color: color)), - Text( - '${data.solarSourcePercent.toStringAsFixed(0)}% solaire', - style: EtmTokens.sans(size: 11, color: EtmTokens.mutedOf(context))), - ], - ), - ), - // Sélecteur de mode rapide - Column( - mainAxisSize: MainAxisSize.min, - children: ChargingMode.values.map((m) { - final mc = switch (m) { - ChargingMode.pv => EtmTokens.amber, - ChargingMode.minPv => EtmTokens.blue, - ChargingMode.boost => EtmTokens.green, - }; - final sel = data.chargingMode == m; - return GestureDetector( - onTap: () => service.setChargingInfo(mode: m), - child: AnimatedContainer( - duration: const Duration(milliseconds: 150), - width: sel ? 10 : 7, - height: sel ? 10 : 7, - margin: const EdgeInsets.symmetric(vertical: 3), - decoration: BoxDecoration( - color: sel ? mc : mc.withValues(alpha: 0.3), - shape: BoxShape.circle, - ), - ), - ); - }).toList(), - ), - ], - ); - } -} - class _ThingStateWidget extends StatelessWidget { final FavoriteWidget fav; final NymeaService service; diff --git a/lib/services/nymea_service.dart b/lib/services/nymea_service.dart index 10f4311..768404a 100644 --- a/lib/services/nymea_service.dart +++ b/lib/services/nymea_service.dart @@ -7,7 +7,8 @@ import 'package:flutter/foundation.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:web_socket_channel/web_socket_channel.dart'; import '../models/daily_balance.dart'; -import '../models/energy_data.dart'; // EnergyData, HistoryPoint, PowerBalanceEntry +import '../models/energy_data.dart'; +import '../models/ev_charger_live.dart'; // EnergyData, HistoryPoint, PowerBalanceEntry import '../models/modbus_models.dart'; // ModbusRtuMaster, SerialPort import '../models/nymea_models.dart'; import 'energy_ratios.dart'; // seam interim ratios (§2.1) @@ -671,6 +672,24 @@ class NymeaService extends ChangeNotifier { // seraient le même silence. _onLoadTelemetryChanged(params); break; + case 'NymeaEnergy.ChargingInfoAdded': + case 'NymeaEnergy.ChargingInfoChanged': + // La box confirme le mode qu'elle porte VRAIMENT. C'est la seule source du mode + // affiché : l'app ne le déduit plus de la commande qu'elle vient d'envoyer. + final info = params['chargingInfo']; + if (info is Map) { + final m = Map.from(info); + final id = m['evChargerId'] as String?; + if (id != null) { + _chargingInfos[id] = m; + notifyListeners(); + } + } + break; + case 'NymeaEnergy.ChargingInfoRemoved': + final id = params['evChargerId'] as String?; + if (id != null && _chargingInfos.remove(id) != null) notifyListeners(); + break; case 'NymeaEnergy.LoadConfigChanged': // Émise après TOUTE persistance réussie (SetLoadConfig ou relecture du // fichier édité hors app). Porte la liste complète à jour : c'est LA @@ -808,6 +827,9 @@ class NymeaService extends ChangeNotifier { _loadEnergyStatus(), _loadEnergyHistory(), ]); + // Après les Things : la borne se trouve par son interface, et `GetChargingInfos` ne + // dit rien tant qu'on ne sait pas à quel Thing rattacher chaque entrée. + await refreshChargingInfos(); } void _startHeartbeat() { @@ -1300,15 +1322,107 @@ class NymeaService extends ChangeNotifier { } } - // L'état local ne suit que si la box a accepté — sinon l'UI montrerait un mode - // que la borne n'a jamais reçu. - if (error == null) { - _energyData = _energyData.copyWith(chargingMode: mode); - notifyListeners(); - } + // ⚠️ L'app ne tient PLUS d'état de mode local, même après un succès. + // + // Elle le faisait, et c'était la racine de la fiction : `EnergyData.chargingMode` + // partait de `pv` par défaut, n'était jamais relu sur la box, et n'avançait qu'ici — + // donc l'écran affichait « Mode PV » sur une borne que personne n'avait réglée, et + // « Boost » après une commande que la borne pouvait avoir appliquée autrement. + // + // Le mode affiché vient désormais de `GetChargingInfos` et de la notification + // `ChargingInfoChanged`. On relit après une écriture acceptée : c'est la box qui dit + // ce qu'elle porte, pas l'app qui se souvient de ce qu'elle a demandé. + if (error == null) await refreshChargingInfos(); return error; } + // ── Ce que la borne publie, et rien de plus ───────────────────────────────── + + /// `evChargerId` → charge utile `ChargingInfo` telle que la box la porte. + /// + /// Alimenté par [refreshChargingInfos] et par les notifications + /// `ChargingInfoAdded/Changed/Removed`. Vide tant que rien n'a été lu — et « vide » doit + /// se lire « pas encore lu », jamais « aucun mode ». + final Map> _chargingInfos = {}; + + /// Relit `NymeaEnergy.GetChargingInfos` pour TOUTES les bornes. + Future refreshChargingInfos() async { + if (_isSimulation || !_connected) return; + try { + final r = await _sendRequest('NymeaEnergy.GetChargingInfos', {}); + final list = r['params']?['chargingInfos']; + if (list is! List) return; + _chargingInfos.clear(); + for (final e in list) { + if (e is Map) { + final m = Map.from(e); + final id = m['evChargerId'] as String?; + if (id != null) _chargingInfos[id] = m; + } + } + _log('[Recharge] GetChargingInfos → ${_chargingInfos.length} borne(s)', + force: true); + notifyListeners(); + } catch (e) { + _log('[Recharge] GetChargingInfos: $e', force: true); + } + } + + /// Valeur d'un état du Thing **par son nom** (`pluggedIn`, `currentPower`, …). + /// + /// Les états voyagent par `stateTypeId` (un UUID) ; le nom vit dans la `ThingClass`. Sans + /// la classe, on ne peut rien nommer — et `null` est alors la seule réponse honnête. + Object? _stateByName(NymeaThing thing, String name) { + NymeaThingClass? cls; + for (final c in _thingClasses) { + if (c.id == thing.thingClassId) { + cls = c; + break; + } + } + if (cls == null) return null; + String? id; + for (final st in cls.stateTypes) { + if (st.name == name) { + id = st.id; + break; + } + } + if (id == null) return null; + for (final s in thing.states) { + if (s.stateTypeId == id) return s.value; + } + return null; + } + + /// La première borne de recharge configurée, **telle que la box la publie**. + /// + /// `null` = aucune borne sur cette installation. Chaque champ de [EvChargerLive] est + /// nullable pour la même raison : une borne qui ne publie pas `currentPower` ne consomme + /// pas 0 W, elle ne le dit pas. Cf. LM-1009 §C1. + EvChargerLive? get evCharger { + final id = _findEvChargerId(); + if (id == null) return null; + NymeaThing? thing; + for (final t in _things) { + if (t.id == id) { + thing = t; + break; + } + } + if (thing == null) return null; + final info = _chargingInfos[id]; + final power = _stateByName(thing, 'currentPower'); + return EvChargerLive( + thingId: id, + name: thing.name, + pluggedIn: _stateByName(thing, 'pluggedIn') as bool?, + charging: _stateByName(thing, 'charging') as bool?, + currentPowerW: power is num ? power.toDouble() : null, + apiMode: info?['chargingMode'] as String?, + ); + } + // ═══════════════════════════════════════════════════════════════════════════ // CONFIG ÉNERGIE — rôles & charges pilotées (écran Rôles & appareils) // ═══════════════════════════════════════════════════════════════════════════ diff --git a/lib/widgets/ev_favorite_tile.dart b/lib/widgets/ev_favorite_tile.dart new file mode 100644 index 0000000..3818ed5 --- /dev/null +++ b/lib/widgets/ev_favorite_tile.dart @@ -0,0 +1,243 @@ +import 'package:flutter/material.dart'; + +import '../models/energy_data.dart' show ChargingMode; +import '../models/ev_charger_live.dart'; +import '../theme/etm_tokens.dart'; + +/// La tuile « Borne EV » des favoris — **rendue muette le 2026-08-28**. +/// +/// ## Ce qu'elle affichait, et pourquoi c'était grave +/// +/// Elle montrait « 3600 W », « 82 % solaire » et « Mode PV » : trois valeurs par défaut de +/// `EnergyData` que l'app **n'a jamais lues sur la box**. Le mode n'était relu nulle part — +/// il partait de `pv` et n'avançait qu'après une commande envoyée depuis l'app. Un client +/// ouvrant ce favori voyait donc une borne en train de charger à 3,6 kW alors que rien +/// n'était branché. +/// +/// La tuile **reste** — la retirer laisserait un trou dans une liste que le client a +/// composée lui-même, sans explication. Elle dit maintenant ce que la box publie, et là où +/// il n'y a rien à lire, elle le dit. +/// +/// ## Les trois grandeurs qui restent, et leur source +/// +/// | Affiché | Source | Absent = | +/// |---|---|---| +/// | branché / en charge | états `pluggedIn`, `charging` du Thing | « état non publié » | +/// | puissance | état `currentPower` du Thing | « puissance non publiée » | +/// | mode | `NymeaEnergy.GetChargingInfos` + `ChargingInfoChanged` | « mode non lu » | +/// +/// Pas de part solaire : la box ne dit pas d'où vient le courant d'une borne. Pas +/// d'avancement de charge : LM-1009 §C1 — `chargeEnergy` est remis à zéro à chaque +/// interruption, et l'égalité `sessionEnergy == chargeEnergy` relevée le 2026-08-28 ne +/// prouve pas encore que le second y survive. +/// +/// ## L'écriture restitue le refus, ou n'existe pas +/// +/// Les trois modes s'écrivaient sur des pastilles de 7 à 10 px qui **avalaient les +/// refus** : `setChargingInfo` rend un code d'erreur, personne ne le lisait. Une commande +/// muette est pire qu'une lecture seule — l'utilisateur croit avoir réglé la borne. Les +/// boutons font désormais 44 px, montrent l'attente, et affichent le refus tel que la box +/// le nomme. +class EvFavoriteTile extends StatefulWidget { + /// `null` = aucune borne déclarée sur l'installation. + final EvChargerLive? live; + + /// Écrit le mode et rend `null` si la box a accepté, sinon le code de refus. + final Future Function(ChargingMode) onSetMode; + + const EvFavoriteTile({super.key, required this.live, required this.onSetMode}); + + @override + State createState() => _EvFavoriteTileState(); +} + +class _EvFavoriteTileState extends State { + /// Mode en cours d'écriture — jamais affiché comme sélectionné : tant que la box n'a pas + /// répondu, l'app ne sait pas ce que porte la borne. + ChargingMode? _pending; + + Future _set(ChargingMode m) async { + setState(() => _pending = m); + final err = await widget.onSetMode(m); + if (!mounted) return; + setState(() => _pending = null); + if (err == null) return; + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text('La box a refusé le mode de recharge ($err). ' + 'Détail dans son journal.'), + )); + } + + @override + Widget build(BuildContext context) { + final live = widget.live; + if (live == null) { + return _Muet( + icon: Icons.ev_station_outlined, + titre: 'Borne de recharge', + detail: 'Aucune borne déclarée sur cette installation.', + ); + } + + final couleur = live.charging == true ? EtmTokens.green : EtmTokens.blue; + + return Row( + children: [ + Container( + width: 52, + height: 52, + decoration: BoxDecoration( + color: couleur.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(14), + ), + child: Icon(Icons.ev_station_rounded, color: couleur, size: 28), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // La puissance MESURÉE, ou le fait qu'elle ne soit pas publiée. Jamais 0 W + // par défaut : « 0 W » et « pas de mesure » sont deux choses différentes. + if (live.currentPowerW != null) + Text('${live.currentPowerW!.round()} W', + style: EtmTokens.mono( + size: 26, weight: FontWeight.w700, color: couleur)) + else + Text('Puissance non publiée', + style: EtmTokens.sans( + size: 13, color: EtmTokens.mutedOf(context))), + Text(live.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: EtmTokens.sans(size: 12, color: couleur)), + Text(_etat(live), + style: EtmTokens.sans( + size: 11, color: EtmTokens.mutedOf(context))), + Text(_mode(live), + style: EtmTokens.sans( + size: 11, color: EtmTokens.mutedOf(context))), + ], + ), + ), + const SizedBox(width: 8), + Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (final m in ChargingMode.values) _bouton(live, m), + ], + ), + ], + ); + } + + /// Branché ? En charge ? — et « non publié » quand la borne ne le dit pas. + String _etat(EvChargerLive live) { + if (live.pluggedIn == null && live.charging == null) { + return 'Branchement non publié par cette borne'; + } + if (live.charging == true) return 'En charge'; + if (live.pluggedIn == true) return 'Véhicule branché, à l\'arrêt'; + if (live.pluggedIn == false) return 'Aucun véhicule branché'; + return 'À l\'arrêt'; + } + + /// Le mode **que la box porte**, jamais celui que l'app a demandé. + String _mode(EvChargerLive live) { + if (live.apiMode == null) return 'Mode non lu'; + final ui = live.uiMode; + if (ui == null) return 'Mode non reconnu (${live.apiMode})'; + final base = switch (ui) { + ChargingMode.pv => 'PV', + ChargingMode.minPv => 'Min+PV', + ChargingMode.boost => 'Boost', + }; + return live.hasDeadlineMode ? 'Mode $base + échéance' : 'Mode $base'; + } + + /// 44 px de cible tactile — les pastilles précédentes faisaient 7 à 10 px, et le premier + /// passage sur appareil avait déjà trouvé deux cibles hors norme sur d'autres écrans. + Widget _bouton(EvChargerLive live, ChargingMode m) { + final (label, couleur) = switch (m) { + ChargingMode.pv => ('PV', EtmTokens.amber), + ChargingMode.minPv => ('Min+PV', EtmTokens.blue), + ChargingMode.boost => ('Boost', EtmTokens.green), + }; + // Sélection = ce que la BOX porte. Un mode non lu ne sélectionne rien : mieux vaut + // trois boutons éteints qu'un bouton allumé au hasard. + final selected = live.uiMode == m; + final busy = _pending != null; + + return Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: SizedBox( + height: 44, + width: 76, + child: OutlinedButton( + onPressed: busy ? null : () => _set(m), + style: OutlinedButton.styleFrom( + padding: EdgeInsets.zero, + backgroundColor: + selected ? couleur.withValues(alpha: 0.14) : Colors.transparent, + side: BorderSide( + color: selected + ? couleur + : EtmTokens.lineOf(context).withValues(alpha: 0.6)), + shape: + RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + ), + child: _pending == m + ? const SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator(strokeWidth: 2)) + : Text(label, + style: EtmTokens.sans( + size: 11, + weight: selected ? FontWeight.w700 : FontWeight.w400, + color: selected ? couleur : EtmTokens.mutedOf(context))), + ), + ), + ); + } +} + +/// Tuile sans donnée : elle nomme ce qu'elle attend, plutôt que d'afficher un zéro. +class _Muet extends StatelessWidget { + final IconData icon; + final String titre; + final String detail; + + const _Muet({required this.icon, required this.titre, required this.detail}); + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Container( + width: 52, + height: 52, + decoration: BoxDecoration( + color: EtmTokens.mutedOf(context).withValues(alpha: 0.10), + borderRadius: BorderRadius.circular(14), + ), + child: Icon(icon, color: EtmTokens.mutedOf(context), size: 26), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(titre, + style: EtmTokens.sans(size: 14, weight: FontWeight.w600)), + const SizedBox(height: 2), + Text(detail, + style: EtmTokens.sans( + size: 11.5, color: EtmTokens.mutedOf(context))), + ], + ), + ), + ], + ); + } +} diff --git a/test/ev_favorite_tile_test.dart b/test/ev_favorite_tile_test.dart new file mode 100644 index 0000000..6bdccc7 --- /dev/null +++ b/test/ev_favorite_tile_test.dart @@ -0,0 +1,160 @@ +/// La tuile « Borne EV » **rendue muette** — éprouvée sur ce qu'elle affiche. +/// +/// Le défaut qu'elle corrigeait ne se voyait pas en test de fonction : les trois grandeurs +/// affichées (« 3600 W », « 82 % solaire », « Mode PV ») étaient des valeurs par défaut de +/// `EnergyData`, justes au sens du type et fausses à l'écran. Ces tests montent donc le +/// widget et lisent le texte, comme ceux des notes de configuration. +library; + +import 'package:etm_powersync_app/models/energy_data.dart' show ChargingMode; +import 'package:etm_powersync_app/models/ev_charger_live.dart'; +import 'package:etm_powersync_app/widgets/ev_favorite_tile.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +Widget _harness({ + required EvChargerLive? live, + Future Function(ChargingMode)? onSetMode, +}) => + MaterialApp( + home: Scaffold( + body: EvFavoriteTile( + live: live, + onSetMode: onSetMode ?? (_) async => null, + ), + ), + ); + +/// La V2C du banc, telle que `.75` la publiait le 2026-08-28 : états `pluggedIn`, +/// `charging`, `currentPower` sur le Thing, mode par `GetChargingInfos`. +EvChargerLive _v2c({ + bool? pluggedIn = false, + bool? charging = false, + double? powerW = 0, + String? apiMode = 'ChargingModeNormal', +}) => + EvChargerLive( + thingId: '4d1adafd-cafc-4813-93b1-ca8933ce71ab', + name: 'V2C Trydan', + pluggedIn: pluggedIn, + charging: charging, + currentPowerW: powerW, + apiMode: apiMode, + ); + +void main() { + group('la tuile ne montre que ce que la box publie', () { + testWidgets('sans borne, elle le dit — elle ne disparaît pas', + (tester) async { + // Retirer la tuile laisserait un trou dans une liste que le client a composée. + await tester.pumpWidget(_harness(live: null)); + expect(find.textContaining('Aucune borne déclarée'), findsOneWidget); + expect(find.textContaining('W'), findsNothing); + }); + + testWidgets('une borne qui ne publie rien n\'affiche AUCUN zéro', + (tester) async { + await tester.pumpWidget(_harness( + live: _v2c( + pluggedIn: null, charging: null, powerW: null, apiMode: null))); + + // Les trois absences se disent, chacune à sa place. + expect(find.text('Puissance non publiée'), findsOneWidget); + expect(find.textContaining('Branchement non publié'), findsOneWidget); + expect(find.text('Mode non lu'), findsOneWidget); + // Et surtout : pas de « 0 W », qui se lirait « la borne ne consomme rien ». + expect(find.textContaining('0 W'), findsNothing); + }); + + testWidgets('elle affiche la puissance MESURÉE et le mode DE LA BOX', + (tester) async { + await tester.pumpWidget(_harness( + live: _v2c( + pluggedIn: true, + charging: true, + powerW: 3907, + apiMode: 'ChargingModeEco'))); + + expect(find.text('3907 W'), findsOneWidget); + expect(find.text('En charge'), findsOneWidget); + expect(find.text('Mode PV'), findsOneWidget); + expect(find.text('V2C Trydan'), findsOneWidget); + // Aucune part solaire : la box ne dit pas d'où vient le courant d'une borne. + expect(find.textContaining('solaire'), findsNothing); + }); + + testWidgets('un mode à échéance se lit comme tel, un mode inconnu aussi', + (tester) async { + await tester.pumpWidget( + _harness(live: _v2c(apiMode: 'ChargingModeEcoMinWithTargetTime'))); + expect(find.text('Mode Min+PV + échéance'), findsOneWidget); + + // Une box peut porter un mode qu'aucune version d'app ne connaît. Deviner serait + // pire que le nommer. + await tester.pumpWidget(_harness(live: _v2c(apiMode: 'ChargingModeFutur'))); + await tester.pump(); + expect(find.textContaining('Mode non reconnu'), findsOneWidget); + }); + + testWidgets('branché mais à l\'arrêt n\'est pas « en charge »', + (tester) async { + await tester.pumpWidget( + _harness(live: _v2c(pluggedIn: true, charging: false, powerW: 0))); + expect(find.textContaining('branché, à l\'arrêt'), findsOneWidget); + // Ici le zéro est une MESURE, pas une absence : la borne publie `currentPower`. + expect(find.text('0 W'), findsOneWidget); + }); + }); + + group('l\'écriture du mode restitue le refus', () { + testWidgets('un refus de la box est affiché, avec son code', (tester) async { + await tester.pumpWidget(_harness( + live: _v2c(), + onSetMode: (_) async => 'EnergyErrorInvalidParameter', + )); + + await tester.tap(find.text('PV')); + await tester.pump(); // écriture en cours + await tester.pump(); // réponse + await tester.pump(); // SnackBar + + expect(find.textContaining('EnergyErrorInvalidParameter'), findsOneWidget); + expect(find.textContaining('a refusé'), findsOneWidget); + }); + + testWidgets('un succès n\'affiche rien — c\'est la box qui dira le mode', + (tester) async { + var appels = 0; + await tester.pumpWidget(_harness( + live: _v2c(), + onSetMode: (m) async { + appels++; + return null; + }, + )); + + await tester.tap(find.text('Boost')); + await tester.pumpAndSettle(); + + expect(appels, 1); + expect(find.textContaining('a refusé'), findsNothing); + // La sélection n'a pas bougé toute seule : la tuile affiche le mode que la box + // portait à l'ouverture, et attend `ChargingInfoChanged` pour changer d'avis. + expect(find.text('Mode Boost'), findsOneWidget); + }); + + testWidgets('les cibles tactiles font au moins 44 px', (tester) async { + // Les pastilles précédentes faisaient 7 à 10 px, et écrivaient pour de vrai. + await tester.pumpWidget(_harness(live: _v2c())); + for (final label in ['PV', 'Min+PV', 'Boost']) { + final taille = tester.getSize(find.ancestor( + of: find.text(label), + matching: find.byType(OutlinedButton), + )); + expect(taille.height, greaterThanOrEqualTo(44), + reason: '$label : cible tactile trop petite'); + expect(taille.width, greaterThanOrEqualTo(44)); + } + }); + }); +}