From 33d89a8c269c657086f0479f614a1b9b9b1188ea Mon Sep 17 00:00:00 2001 From: Patrick Schurig Date: Fri, 28 Aug 2026 12:36:00 +0200 Subject: [PATCH] =?UTF-8?q?fix(ev):=20la=20r=C3=A9serve=20THING=5FMISSING?= =?UTF-8?q?=20est=20lev=C3=A9e=20par=20la=20mesure,=20et=20la=20carte=20v?= =?UTF-8?q?=C3=A9hicule=20part?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deux gestes que le relevé du banc a tranchés, dans les deux sens. 1. THING_MISSING — MESURÉ, la réserve tombe GetLoadTelemetry sur .75, 2026-08-28 10:29 UTC : la Terra AC supprimée est publiée available=false · faultCode=THING_MISSING · decision=LOAD_UNAVAILABLE{frozenW:0} · mechanism.kind=evcharger. Le code SORT. Le test de rendu ne repose plus sur une trame composée mais sur cette capture (test/fixtures/loadtelemetry_hems75_thingmissing.json). Le masquage relevé mardi (etmvariableloadadapter.cpp:148-150, m_faulted testé avant que THING_MISSING ne puisse s'exprimer) concerne l'adaptateur des charges VARIABLES — relais et SG-Ready. L'adaptateur evcharger n'a pas ce chemin, d'où la différence. Il n'est donc pas corrigé, seulement contourné par le hasard du chemin de code : remonté au brief moteur avec la trame, parce que les deux codes envoient le client faire deux gestes différents — « écriture échouée » fait démonter un coffret, « appareil absent » fait rouvrir nymea. 2. EVChargingCard — elle part, et la monter aurait publié de la fiction Trois des grandeurs qu'elle affiche ne sont JAMAIS lues sur la box : chargingMode, chargingPower (3,6 kW) et solarSourcePercent (82 %) sont des valeurs par défaut de EnergyData — _updateEnergyData ne les touche pas, et le mode n'est écrit qu'en optimiste après une commande, jamais relu par GetChargingInfos. Sa feuille « 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. Ce n'était pas du code mort à ranimer, c'était un écran à réécrire depuis le modèle. ⚠️ Et la surface vivante est pire que la morte : _EVWidget (favorites_screen.dart:419) affiche les MÊMES valeurs par défaut comme si elles étaient mesurées — « 3600 W », « 82 % solaire », « Mode PV » — et écrit le mode sur des pastilles de 7 à 10 px sans restituer un refus. Entrée 🔴 au TODO, avec ce qu'une carte de recharge peut dire aujourd'hui : le mode lu par GetChargingInfos, la puissance mesurée du Thing, l'échéance comme intention saisie. Ni SOC (n'existe pas), ni chargeEnergy (remis à zéro à chaque interruption), ni courant minimum, ni phaseCount. 146 tests passent. flutter analyze : 27 remarques, deux de moins qu'avant — les deux étaient dans le fichier supprimé. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01SQbZKrWqsMFP1Lh2jjjd9f --- TODO.md | 26 +- docs/BRIEF_plugin_depuis_maquettes.md | 40 + integration_test/ui_3g2_on_device_test.dart | 8 +- lib/widgets/ev_charging_card.dart | 692 ------------------ .../loadtelemetry_hems75_thingmissing.json | 85 +++ test/merged_config_screen_test.dart | 34 +- 6 files changed, 170 insertions(+), 715 deletions(-) delete mode 100644 lib/widgets/ev_charging_card.dart create mode 100644 test/fixtures/loadtelemetry_hems75_thingmissing.json diff --git a/TODO.md b/TODO.md index d79f721..d3f3f4f 100644 --- a/TODO.md +++ b/TODO.md @@ -46,9 +46,29 @@ (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. -- [ ] **`EVChargingCard` n'est montée par AUCUN écran** (constaté le 2026-08-27 — le - tableau de bord utilise `features/dashboard/widgets/`). Soit elle rejoint un écran, soit - elle part ; en l'état c'est du code mort qui a l'air vivant. +- [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 + ne sont **jamais lues sur la box** : `chargingMode`, `chargingPower` (3,6 kW) et + `solarSourcePercent` (82 %) sont des valeurs par défaut de `EnergyData`, et le mode n'est + écrit qu'en optimiste après une commande — jamais relu par `GetChargingInfos`. Sa feuille + « 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 : + - 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) ; + - la **puissance mesurée** de la borne, prise sur le Thing (`currentPower`) ; + - l'**échéance** (`targetPercentage` + `endDateTime`) — mais uniquement comme **intention + saisie**, jamais comme un avancement : LM-1009 §C1. Pas de SOC véhicule (il n'existe + pas), pas de `chargeEnergy` (remis à zéro à chaque interruption), pas de courant + minimum (aucun champ), pas de `phaseCount` (la V2C ne le publie pas). - [ ] **Le passage sur appareil doit devenir une habitude, pas un rattrapage.** Il a glissé deux fois, et au premier passage il a trouvé deux cibles tactiles hors norme (26 px, 18 px) diff --git a/docs/BRIEF_plugin_depuis_maquettes.md b/docs/BRIEF_plugin_depuis_maquettes.md index 3708aa3..a953ebb 100644 --- a/docs/BRIEF_plugin_depuis_maquettes.md +++ b/docs/BRIEF_plugin_depuis_maquettes.md @@ -2,6 +2,46 @@ --- +## 2026-08-28 — `THING_MISSING` sort sur les bornes, et reste masqué sur les charges variables + +**Mesuré**, `NymeaEnergy.GetLoadTelemetry` sur `.75` le 2026-08-28 à 10:29 UTC. La charge +`{88160e45-…}` (Terra AC Charger (TCP), dont le Thing a été supprimé de nymea) est publiée : + +```json +{ "loadId": "{88160e45-7df2-48cc-9639-f0d15ae6e382}", + "available": false, "allocatedW": 0, "faultCode": "THING_MISSING", + "decision": { "code": "LOAD_UNAVAILABLE", "params": { "frozenW": 0 } }, + "mechanism": { "kind": "evcharger" }, "funding": "surplus" } +``` + +**Ce que ça règle** : l'app affiche désormais, sur ce cas réel, « l'appareil n'existe plus +dans nymea — réinstallez-le, ou retirez cette charge », et **retire le bouton « Lever le +défaut »** : `ClearLoadFault` répondrait `EnergyErrorNoError` sans rien changer, et le défaut +reviendrait au cycle suivant. Rendu figé par test sur la trame ci-dessus. + +**Ce qui reste chez vous** : le masquage relevé le 2026-08-25 dans +`etmvariableloadadapter.cpp:148-150` — `m_faulted` est testé **avant** que `THING_MISSING` +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**. + +**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 — +câble, bus, alimentation ; `THING_MISSING` fait rouvrir nymea et réinstaller l'appareil. +Publier le premier là où le second est vrai, c'est envoyer quelqu'un démonter un coffret pour +un Thing effacé. L'app ne peut pas rattraper ça : elle affiche le code, elle ne le devine pas. + +**Deux points mineurs sur la même trame**, sans urgence : +- `funding: "surplus"` est publié sur une charge `available: false` à 0 W. Le champ ne veut + rien dire pour une charge qui ne reçoit rien ; l'omettre serait plus juste que d'annoncer + un financement. +- Les deux autres bornes (`Simulated wallbox`, `V2C Trydan`) sont **absentes de `loads[]`** — + aucun véhicule branché, conforme à `+etm24`. Rien à changer : c'est lisible, et l'app le + présente comme « hors arbitrage », jamais comme un défaut. + +--- + ## 2026-08-27 (soir) — la batterie comme charge : sept questions **Origine** : la maquette « Configurer · Batterie », septième colonne de diff --git a/integration_test/ui_3g2_on_device_test.dart b/integration_test/ui_3g2_on_device_test.dart index 5371f13..b1f8db6 100644 --- a/integration_test/ui_3g2_on_device_test.dart +++ b/integration_test/ui_3g2_on_device_test.dart @@ -14,9 +14,11 @@ /// /// ## Ce qu'il n'exerce PAS, et pourquoi /// -/// La carte véhicule n'y figure pas : `EVChargingCard` **n'est montée par aucun écran** -/// (constaté le 2026-08-27 — le tableau de bord utilise `features/dashboard/widgets/`). -/// Il n'y a donc rien à regarder, et prétendre l'avoir vue serait faux. +/// La carte véhicule n'y figure pas : `EVChargingCard` **a été supprimée** le 2026-08-28. +/// Montée par aucun écran depuis sa création, elle affichait trois grandeurs que l'app ne +/// lit pas (mode, puissance, part solaire — des valeurs par défaut du modèle) et proposait +/// un réglage de courant minimum qui n'existe dans aucun contrat. Ce qu'une vraie carte de +/// recharge doit lire est écrit dans `TODO.md` ; il n'y a rien à exercer d'ici là. /// /// Le glissement est exercé **puis abandonné** (`discardDraft`) : le geste est ce qu'on /// veut voir, pas l'écriture. Réordonner pour de bon changerait l'ordre d'arbitrage d'une diff --git a/lib/widgets/ev_charging_card.dart b/lib/widgets/ev_charging_card.dart deleted file mode 100644 index d2ccd50..0000000 --- a/lib/widgets/ev_charging_card.dart +++ /dev/null @@ -1,692 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; - -import '../models/energy_data.dart'; -import '../providers/schema_provider.dart'; -import '../services/nymea_service.dart'; -import '../services/rpc_schema.dart'; -import '../theme/etm_tokens.dart'; -import 'schema_gated.dart'; - -class EVChargingCard extends StatefulWidget { - final EnergyData data; - final NymeaService service; - - const EVChargingCard({ - super.key, - required this.data, - required this.service, - }); - - @override - State createState() => _EVChargingCardState(); -} - -class _EVChargingCardState extends State { - // Deadline option state — local UI, not persisted - bool _deadlineEnabled = false; - int _targetSoc = 80; - DateTime _endTime = DateTime.now().add(const Duration(hours: 4)); - - void _selectMode(ChargingMode mode) { - // Selecting Boost auto-disables deadline - if (mode == ChargingMode.boost && _deadlineEnabled) { - setState(() => _deadlineEnabled = false); - } - _send( - mode: mode, - deadline: mode != ChargingMode.boost && _deadlineEnabled, - ); - } - - /// Envoie, et **annonce le refus** au lieu de le perdre. La cause exacte reste au - /// journal de la box : on ne la devine pas. - Future _send({required ChargingMode mode, required bool deadline}) async { - final err = await widget.service.setChargingInfo( - mode: mode, - deadline: deadline, - targetSoc: _targetSoc, - endTime: _endTime, - ); - if (err == null || !mounted) return; - ScaffoldMessenger.of(context).showSnackBar(SnackBar( - content: Text('La box a refusé le mode de recharge ($err). ' - 'Détail dans son journal.'), - )); - } - - void _toggleDeadline(bool enabled) { - setState(() => _deadlineEnabled = enabled); - _send(mode: widget.data.chargingMode, deadline: enabled); - } - - void _applyDeadline() { - _send(mode: widget.data.chargingMode, deadline: _deadlineEnabled); - } - - /// Support des champs d'échéance, lu dans le schéma de la box. - /// - /// Les deux vont ensemble : une échéance sans cible de charge n'a pas de sens, et - /// l'inverse non plus. On grise donc le bloc entier dès que l'un manque. - FieldSupport _socSupport(BuildContext context) { - final schema = context.watch().schema; - final soc = schema.typeSupport('ChargingInfo', ['targetPercentage']); - final end = schema.typeSupport('ChargingInfo', ['endDateTime']); - if (soc == FieldSupport.unknown || end == FieldSupport.unknown) { - return FieldSupport.unknown; - } - if (soc == FieldSupport.absent || end == FieldSupport.absent) { - return FieldSupport.absent; - } - return FieldSupport.writable; - } - - /// Vrai si le contrat de recharge ne porte aucun champ de courant minimal. - bool _minCurrentAbsent(BuildContext context) { - final schema = context.watch().schema; - if (!schema.loaded) return false; - return schema.typeSupport('ChargingInfo', ['minCurrent']) == - FieldSupport.absent; - } - - String _statusLabel(ChargingMode mode) { - switch (mode) { - case ChargingMode.pv: - return _deadlineEnabled - ? 'Surplus PV → Boost auto avant deadline' - : 'Surplus PV disponible'; - case ChargingMode.minPv: - return _deadlineEnabled - ? 'Min garanti + PV → Boost si deadline' - : 'Minimum + surplus PV'; - case ChargingMode.boost: - return 'Charge rapide (réseau)'; - } - } - - Future _pickEndTime() async { - final initial = TimeOfDay.fromDateTime(_endTime); - final picked = await showTimePicker(context: context, initialTime: initial); - if (picked != null) { - setState(() { - final now = DateTime.now(); - _endTime = DateTime(now.year, now.month, now.day, picked.hour, picked.minute); - // If picked time is in the past, roll to tomorrow - if (_endTime.isBefore(now)) { - _endTime = _endTime.add(const Duration(days: 1)); - } - }); - _applyDeadline(); - } - } - - @override - Widget build(BuildContext context) { - final mode = widget.data.chargingMode; - final data = widget.data; - final showDeadlineOption = mode != ChargingMode.boost; - - return Container( - decoration: BoxDecoration( - color: EtmTokens.surfaceOf(context), - borderRadius: BorderRadius.circular(EtmTokens.radiusLg), - boxShadow: EtmTokens.cardShadow, - ), - padding: const EdgeInsets.all(20), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Header - Row( - children: [ - Container( - width: 34, height: 34, - decoration: BoxDecoration( - color: EtmTokens.navy.withValues(alpha: 0.07), - borderRadius: BorderRadius.circular(10), - ), - child: const Icon(Icons.ev_station_rounded, color: EtmTokens.navy, size: 20), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('Borne de recharge (EVSE)', - style: EtmTokens.sans(size: 15, weight: FontWeight.w600)), - Text(_statusLabel(mode), - style: EtmTokens.sans(size: 12, color: EtmTokens.mutedOf(context))), - ], - ), - ), - GestureDetector( - onTap: () => _showSettings(context), - child: Icon(Icons.tune, color: EtmTokens.faintOf(context), size: 20), - ), - ], - ), - const SizedBox(height: 14), - - // Status badge + power - Row( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), - decoration: BoxDecoration( - color: EtmTokens.greenSoft, - borderRadius: BorderRadius.circular(99), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Container(width: 6, height: 6, - decoration: const BoxDecoration(color: EtmTokens.green, shape: BoxShape.circle)), - const SizedBox(width: 5), - Text('En charge', - style: EtmTokens.sans(size: 12, weight: FontWeight.w600, color: EtmTokens.greenDark)), - ], - ), - ), - const SizedBox(height: 8), - Text( - '${(data.chargingPower * 1000).toStringAsFixed(0)}', - style: EtmTokens.mono(size: 38, weight: FontWeight.w700), - ), - Text('W Puissance actuelle', - style: EtmTokens.sans(size: 12, color: EtmTokens.mutedOf(context))), - ], - ), - const Spacer(), - Column( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Text('${data.solarSourcePercent.toStringAsFixed(0)}%', - style: EtmTokens.mono(size: 22, color: EtmTokens.green)), - Text('solaire', style: EtmTokens.sans(size: 11, color: EtmTokens.mutedOf(context))), - ], - ), - ], - ), - const SizedBox(height: 16), - - // Mode buttons — 3 boutons : PV / Min+PV / Boost - Row( - children: [ - _ModeButton( - label: 'PV', - icon: Icons.wb_sunny_rounded, - color: EtmTokens.amber, - isSelected: mode == ChargingMode.pv, - onTap: () => _selectMode(ChargingMode.pv), - ), - const SizedBox(width: 8), - _ModeButton( - label: 'Min+PV', - icon: Icons.bolt, - color: EtmTokens.blue, - isSelected: mode == ChargingMode.minPv, - onTap: () => _selectMode(ChargingMode.minPv), - ), - const SizedBox(width: 8), - _ModeButton( - label: 'Boost', - icon: Icons.rocket_launch_rounded, - color: EtmTokens.green, - isSelected: mode == ChargingMode.boost, - onTap: () => _selectMode(ChargingMode.boost), - ), - ], - ), - - // Échéance — visible pour PV et Min+PV, et **active seulement si la box - // porte les champs**. Le grisage vient du schéma, jamais d'une table de - // versions : le jour où le plugin expose `endDateTime`, le réglage s'active - // seul sur les box à jour et reste gris sur les autres. - if (showDeadlineOption) ...[ - const SizedBox(height: 8), - SchemaGated( - support: _socSupport(context), - fieldName: 'targetPercentage / endDateTime', - label: 'Échéance de recharge', - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _DeadlineToggleRow( - enabled: _deadlineEnabled, - onChanged: _toggleDeadline, - ), - if (_deadlineEnabled) ...[ - const SizedBox(height: 10), - _DeadlineParamsRow( - targetSoc: _targetSoc, - endTime: _endTime, - onSocChanged: (v) { - setState(() => _targetSoc = v); - _applyDeadline(); - }, - onPickTime: _pickEndTime, - ), - ], - ], - ), - ), - // Le mode « minimum garanti » existe côté box, mais la VALEUR du courant - // minimal n'est dans aucun champ de `ChargingInfo` : elle ne se règle pas - // ici. Le dire, plutôt que d'offrir un réglage sans effet. - if (_minCurrentAbsent(context)) ...[ - const SizedBox(height: 8), - _AbsentFieldNote( - 'Le courant minimal du mode « Min + PV » n\'est pas réglable depuis ' - 'l\'app : la box choisit le mode, mais n\'expose aucun champ ' - '« minCurrent » dans son contrat de recharge.', - ), - ], - ], - - // Cible de charge — affichée SEULEMENT quand l'écran l'a lui-même écrite. - // - // `targetPercentage` n'est pas un réglage que l'app peut lire et présenter - // comme un choix de l'utilisateur : `SetChargingInfo` reconstruit l'objet - // entier, et le défaut C++ du champ vaut **80**. Une écriture qui ne parlait - // que du mode de charge laisse donc 80 derrière elle, sans que rien ne le - // signale — reproduit sur `.75` le 2026-08-27. Montrer « cible 80 % » hors - // échéance afficherait cette empreinte comme une intention. - // - // Hors échéance, l'app n'a pas de cible : elle n'en montre pas. - if (_deadlineEnabled) ...[ - const SizedBox(height: 14), - _SocProgress(targetSoc: _targetSoc), - ], - ], - ), - ); - } - - void _showSettings(BuildContext context) { - showModalBottomSheet( - context: context, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(20)), - ), - builder: (_) => const _ChargingSettingsSheet(), - ); - } -} - -class _DeadlineToggleRow extends StatelessWidget { - final bool enabled; - final ValueChanged onChanged; - - const _DeadlineToggleRow({required this.enabled, required this.onChanged}); - - @override - Widget build(BuildContext context) { - return Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Row( - children: [ - Icon(Icons.flag_outlined, size: 16, - color: enabled ? EtmTokens.blue : EtmTokens.faintOf(context)), - const SizedBox(width: 6), - Text('Cible deadline', - style: EtmTokens.sans(size: 13, - color: enabled ? EtmTokens.blue : EtmTokens.mutedOf(context), - weight: enabled ? FontWeight.w600 : FontWeight.w400)), - ], - ), - Switch( - value: enabled, - onChanged: onChanged, - activeThumbColor: EtmTokens.blue, - materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, - ), - ], - ); - } -} - -class _DeadlineParamsRow extends StatelessWidget { - final int targetSoc; - final DateTime endTime; - final ValueChanged onSocChanged; - final VoidCallback onPickTime; - - const _DeadlineParamsRow({ - required this.targetSoc, - required this.endTime, - required this.onSocChanged, - required this.onPickTime, - }); - - @override - Widget build(BuildContext context) { - final timeLabel = - '${endTime.hour.toString().padLeft(2, '0')}:${endTime.minute.toString().padLeft(2, '0')}'; - - return Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), - decoration: BoxDecoration( - color: EtmTokens.blueSoft, - borderRadius: BorderRadius.circular(12), - ), - child: Column( - children: [ - Row( - children: [ - const Icon(Icons.battery_charging_full, size: 16, color: EtmTokens.blue), - const SizedBox(width: 6), - Text('SOC cible : $targetSoc %', - style: EtmTokens.sans(size: 12, color: EtmTokens.blue)), - Expanded( - child: Slider( - value: targetSoc.toDouble(), - min: 0, - max: 100, - divisions: 20, - label: '$targetSoc %', - activeColor: EtmTokens.blue, - onChanged: (v) => onSocChanged(v.round()), - ), - ), - ], - ), - GestureDetector( - onTap: onPickTime, - child: Row( - children: [ - const Icon(Icons.access_time, size: 16, color: EtmTokens.blue), - const SizedBox(width: 6), - Text('Heure d\'arrivée :', style: EtmTokens.sans(size: 12, color: EtmTokens.blue)), - const SizedBox(width: 8), - Text(timeLabel, style: EtmTokens.mono(size: 13, color: EtmTokens.blue)), - const SizedBox(width: 4), - const Icon(Icons.edit, size: 12, color: EtmTokens.blue), - ], - ), - ), - ], - ), - ); - } -} - -class _ModeButton extends StatelessWidget { - final String label; - final IconData icon; - final Color color; - final bool isSelected; - final VoidCallback onTap; - - const _ModeButton({ - required this.label, - required this.icon, - required this.color, - required this.isSelected, - required this.onTap, - }); - - @override - Widget build(BuildContext context) { - return Expanded( - child: GestureDetector( - onTap: onTap, - child: AnimatedContainer( - duration: const Duration(milliseconds: 200), - padding: const EdgeInsets.symmetric(vertical: 11), - decoration: BoxDecoration( - color: isSelected ? color : color.withValues(alpha: 0.10), - borderRadius: BorderRadius.circular(12), - border: Border.all(color: isSelected ? color : color.withValues(alpha: 0.3)), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(icon, color: isSelected ? Colors.white : color, size: 15), - const SizedBox(width: 4), - Text( - label, - style: EtmTokens.sans( - size: 13, - weight: FontWeight.w600, - color: isSelected ? Colors.white : color, - ), - ), - ], - ), - ), - ), - ); - } -} - -/// Barre de progression SOC de la voiture avec target et valeur courante. -/// Cible de charge du véhicule — **la cible seule, jamais un état de charge**. -/// -/// Cette carte affichait « 62 % » et « Aujourd'hui à 07:30 » en dur. Les deux sont -/// retirés, et il faut dire pourquoi le second correctif possible n'en est pas un : -/// -/// **aucun SOC de véhicule n'existe dans ce contrat.** Vérifié sur `.75` le 2026-08-27, -/// sur les 58 classes de la box : aucune classe portant l'interface `evcharger` ne -/// déclare d'état de charge, aucune classe ne porte l'interface `car`, et les bornes du -/// banc publient `pluggedIn`, `charging`, `maxChargingCurrent` — jamais un pourcentage. -/// -/// **Et l'énergie livrée n'est pas là non plus, sur la borne du banc.** La V2C Trydan -/// arrivée le 2026-08-28 ne publie ni `sessionEnergy` ni `phaseCount` : elle publie -/// `chargeEnergy`, exact pendant la charge et **remis à zéro à chaque interruption** — -/// donc à chaque nuage sur du pilotage par surplus. Le détail et la mesure sont dans -/// `LoadMechanism` (`models/load_telemetry.dart`). Tant que le plugin ne publie pas le -/// `sessionEnergy` construit qu'il prépare, l'avancement de LM-1009 se dit **« pas -/// mesurable »** — et surtout pas zéro, qui se lirait « rien livré ». -/// -/// Le seul `batteryLevel` de l'installation est celui de la **batterie de la maison** -/// (Fronius Storage, 50 %), rendu visible par le correctif `energystorage` du plugin -/// SunSpec. C'est une autre grandeur : la brancher ici afficherait la charge du bâtiment -/// en face d'une cible de voiture. Un chiffre faux et plausible est pire qu'un chiffre -/// absent — c'était déjà le défaut du 62 en dur, le remplacer par 50 le garderait en le -/// rendant crédible. -/// -/// Reste donc ce que l'app SAIT : la cible qu'elle vient elle-même d'écrire. La barre de -/// progression disparaît avec la mesure — une jauge sans grandeur mesurée est un dessin. -class _SocProgress extends StatelessWidget { - final int targetSoc; - const _SocProgress({required this.targetSoc}); - - @override - Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), - decoration: BoxDecoration( - color: EtmTokens.blueSoft, - borderRadius: BorderRadius.circular(13), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text('Charge demandée jusqu\'à', - style: EtmTokens.sans(size: 13, color: EtmTokens.navy)), - Text('$targetSoc%', - style: EtmTokens.mono(size: 13, color: EtmTokens.blue)), - ], - ), - const SizedBox(height: 2), - Text( - 'La box ne publie pas l\'état de charge du véhicule : l\'avancement ne peut ' - 'pas être affiché.', - style: EtmTokens.sans(size: 11, color: EtmTokens.mutedOf(context)), - ), - ], - ), - ); - } -} - -class _ChargingSettingsSheet extends StatefulWidget { - const _ChargingSettingsSheet(); - - @override - State<_ChargingSettingsSheet> createState() => _ChargingSettingsSheetState(); -} - -class _ChargingSettingsSheetState extends State<_ChargingSettingsSheet> { - double _minPower = 6; - double _maxPower = 16; - bool _scheduleEnabled = false; - TimeOfDay _startTime = const TimeOfDay(hour: 22, minute: 0); - TimeOfDay _endTime = const TimeOfDay(hour: 6, minute: 0); - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.all(20), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text('Paramètres de charge', - style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), - const SizedBox(height: 20), - Text('Courant minimum (A)', style: EtmTokens.sans(size: 14, weight: FontWeight.w500)), - Slider( - value: _minPower, - min: 6, - max: 16, - divisions: 10, - label: '${_minPower.toStringAsFixed(0)} A', - onChanged: (v) => setState(() => _minPower = v), - activeColor: EtmTokens.green, - ), - Text('Courant maximum (A)', style: EtmTokens.sans(size: 14, weight: FontWeight.w500)), - Slider( - value: _maxPower, - min: 6, - max: 32, - divisions: 26, - label: '${_maxPower.toStringAsFixed(0)} A', - onChanged: (v) => setState(() => _maxPower = v), - activeColor: EtmTokens.green, - ), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text('Programmation horaire', style: EtmTokens.sans(size: 14, weight: FontWeight.w500)), - Switch( - value: _scheduleEnabled, - onChanged: (v) => setState(() => _scheduleEnabled = v), - activeColor: EtmTokens.green, - ), - ], - ), - if (_scheduleEnabled) ...[ - Row( - children: [ - Expanded( - child: _TimePicker( - label: 'Début', - time: _startTime, - onChanged: (t) => setState(() => _startTime = t), - ), - ), - const SizedBox(width: 16), - Expanded( - child: _TimePicker( - label: 'Fin', - time: _endTime, - onChanged: (t) => setState(() => _endTime = t), - ), - ), - ], - ), - ], - const SizedBox(height: 16), - SizedBox( - width: double.infinity, - child: ElevatedButton( - onPressed: () => Navigator.pop(context), - style: ElevatedButton.styleFrom( - backgroundColor: EtmTokens.green, - foregroundColor: Colors.white, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), - ), - child: const Text('Enregistrer'), - ), - ), - ], - ), - ); - } -} - -class _TimePicker extends StatelessWidget { - final String label; - final TimeOfDay time; - final ValueChanged onChanged; - - const _TimePicker({ - required this.label, - required this.time, - required this.onChanged, - }); - - @override - Widget build(BuildContext context) { - return GestureDetector( - onTap: () async { - final picked = await showTimePicker(context: context, initialTime: time); - if (picked != null) onChanged(picked); - }, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), - decoration: BoxDecoration( - border: Border.all(color: Colors.grey.shade300), - borderRadius: BorderRadius.circular(8), - ), - child: Row( - children: [ - Icon(Icons.access_time, size: 16, color: EtmTokens.faintOf(context)), - const SizedBox(width: 8), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(label, style: EtmTokens.sans(size: 11, color: EtmTokens.mutedOf(context))), - Text( - '${time.hour.toString().padLeft(2, '0')}:${time.minute.toString().padLeft(2, '0')}', - style: EtmTokens.mono(size: 13), - ), - ], - ), - ], - ), - ), - ); - } -} -/// Note d'un champ que la box n'expose pas — **avec sa raison**, jamais un simple gris. -class _AbsentFieldNote extends StatelessWidget { - final String text; - - const _AbsentFieldNote(this.text); - - @override - Widget build(BuildContext context) { - final muted = EtmTokens.mutedOf(context); - return Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Icon(Icons.cloud_off_outlined, size: 13, color: muted), - const SizedBox(width: 5), - Expanded( - child: Text(text, - style: TextStyle(fontSize: 11.5, color: muted, height: 1.3)), - ), - ], - ); - } -} diff --git a/test/fixtures/loadtelemetry_hems75_thingmissing.json b/test/fixtures/loadtelemetry_hems75_thingmissing.json new file mode 100644 index 0000000..b76e7a7 --- /dev/null +++ b/test/fixtures/loadtelemetry_hems75_thingmissing.json @@ -0,0 +1,85 @@ +{ + "_note": "Capturé pour éprouver le RENDU de THING_MISSING sur une charge réelle. La Terra AC Charger (TCP) a été supprimée de nymea le 2026-08-26 ; son entrée LoadConfig survit au rang 4, et la télémétrie la publie available=false / faultCode=THING_MISSING / decision=LOAD_UNAVAILABLE{frozenW:0}, mechanism.kind=evcharger. ⚠️ Le masquage relevé mardi (etmvariableloadadapter.cpp:148-150, m_faulted testé avant THING_MISSING) concerne l'adaptateur des charges VARIABLES : sur l'adaptateur evcharger, le code sort tel quel. Les deux autres bornes (Simulated wallbox, V2C Trydan) sont absentes de loads[] — aucun véhicule branché, comportement +etm24.", + "_source": "dump réel HEMS 192.168.1.75, NymeaEnergy.GetLoadTelemetry, 2026-08-28 10:29 UTC — plugin 1.15.2+etm24.", + "telemetry": { + "budget": { + "allocatedW": 5000, + "evReservedW": 0, + "recreditedW": 5000, + "remainingW": 5741.7001953125, + "surplusW": 5741.7001953125 + }, + "degradedMode": false, + "loads": [ + { + "allocatedW": 3500, + "available": true, + "decision": { + "code": "SURPLUS_SETPOINT", + "params": { + "budgetW": 9242, + "setpointW": 3500, + "stepped": true + } + }, + "funding": "surplus", + "loadId": "chauffe-eau", + "measuredW": 1500, + "mechanism": { + "kind": "relay", + "maxStageW": 3500, + "stageW": 3500, + "stagesW": [ + 0, + 500, + 1000, + 1500, + 2000, + 2500, + 3000, + 3500 + ] + } + }, + { + "allocatedW": 1500, + "available": true, + "decision": { + "code": "LOCK_MIN_STATE_HOLD", + "params": { + "state": 3 + } + }, + "funding": "surplus", + "loadId": "pac-terrain", + "lock": { + "kind": "minStateHold", + "remainingS": 60 + }, + "measuredW": 800, + "mechanism": { + "estimatedPowerW": 1500, + "kind": "sgReady", + "state": 3 + } + }, + { + "allocatedW": 0, + "available": false, + "decision": { + "code": "LOAD_UNAVAILABLE", + "params": { + "frozenW": 0 + } + }, + "faultCode": "THING_MISSING", + "funding": "surplus", + "loadId": "{88160e45-7df2-48cc-9639-f0d15ae6e382}", + "mechanism": { + "kind": "evcharger" + } + } + ], + "timestamp": "2026-08-28T10:29:00Z" + } +} \ No newline at end of file diff --git a/test/merged_config_screen_test.dart b/test/merged_config_screen_test.dart index 4df16b2..3d2ebaa 100644 --- a/test/merged_config_screen_test.dart +++ b/test/merged_config_screen_test.dart @@ -291,24 +291,19 @@ void main() { testWidgets('THING_MISSING se lit « appareil absent », et n\'offre RIEN à lever', (tester) async { - // ⚠️ Télémétrie COMPOSÉE ici, pas capturée : le banc publiait `WRITE_FAILED` sur - // cette charge au dernier relevé (l'adaptateur épuise son échelle d'écriture avant - // que `THING_MISSING` ne sorte). Ce test éprouve donc le RENDU du code, sur le - // `loadId` réel de la Terra AC supprimée — pas la présence du code sur la box. - const terra = '{88160e45-7df2-48cc-9639-f0d15ae6e382}'; + // Télémétrie CAPTURÉE sur `.75` le 2026-08-28 à 10:29 UTC — la réserve de la veille + // est levée par la mesure. La box publie bien `THING_MISSING` sur cette charge : + // `available: false`, `decision: LOAD_UNAVAILABLE {frozenW: 0}`, + // `mechanism.kind: evcharger`. + // + // Le masquage relevé mardi côté moteur (`etmvariableloadadapter.cpp:148-150`, + // `m_faulted` testé avant que `THING_MISSING` ne puisse s'exprimer) concerne + // l'adaptateur des charges **variables** — relais et SG-Ready. L'adaptateur + // `evcharger` n'en souffre pas, et c'est pour ça que le code sort ici. + final telemetry = LoadTelemetryProvider() + ..adoptForTest(_fixture('loadtelemetry_hems75_thingmissing.json')['telemetry'] + as Map); final config = LoadConfigProvider()..adoptForTest(v2cConfig()); - final telemetry = LoadTelemetryProvider()..adoptForTest({ - 'degradedMode': false, - 'loads': [ - { - 'loadId': terra, - 'allocatedW': 0, - 'available': false, - 'faultCode': 'THING_MISSING', - 'decision': {'code': 'NO_BUDGET', 'params': const {}}, - }, - ], - }); await tester.pumpWidget(_harness(config: config, telemetry: telemetry)); await tester.pump(); @@ -320,6 +315,11 @@ void main() { expect(find.textContaining('Réinstallez-le dans nymea'), findsOneWidget); // Et le libellé ne doit pas se lire « votre configuration est cassée ». expect(find.textContaining('n\'existe plus dans nymea'), findsOneWidget); + // Les deux autres bornes du banc sont ABSENTES de la télémétrie captée — aucun + // véhicule branché (`+etm24`). Elles restent affichées comme charges configurées, + // et aucune ne porte de défaut : « hors arbitrage » n'est pas « en panne ». + expect(find.text('V2C Trydan'), findsWidgets); + expect(find.textContaining('En défaut'), findsOneWidget); }); }); }