diff --git a/TODO.md b/TODO.md index 991baad..e21fa1e 100644 --- a/TODO.md +++ b/TODO.md @@ -35,8 +35,23 @@ 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à. -- [ ] ⏳ **`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`) : +- [ ] ⏳ **`sessionEnergy` est CONSTRUIT par le plugin — il ne reste que la trace.** + Le plugin V2C `1.15.2+etm13` (déployé le 2026-08-28 à midi) accumule les incréments de + `chargeEnergy`, détecte les remises à zéro, et ne repart qu'à l'ouverture d'une session : + ce n'est **pas** un alias du registre `0x0BC4`. La campagne de validation en cours produit + le cas qui lève la réserve — `chargeEnergy` à zéro, `sessionEnergy` qui continue. **Aucun + avancement affiché avant cette trace** (LM-1009 §C1). + +- [ ] **Historique des états d'une borne — `sessionEnergy` n'est pas enregistré.** + `suggestLogging` est écrasé par les préférences persistées de `things.conf` sur un Thing + déjà appairé, et la V2C n'y a que `connected`, `charging`, `power`. La valeur courante est + juste — « la nuit relue à 7 h » fonctionne — mais **il n'y aura pas de courbe**. Le + réglage est côté app : choisir les états enregistrés d'un Thing. À décider si un + historique de recharge est voulu ; sans lui, l'écran ne peut montrer qu'un instantané. + +- [x] ~~⏳ `sessionEnergy` existe — mais rien ne prouve qu'il survive à une interruption~~ — + **répondu le 2026-08-28** par l'origine du champ (entrée ci-dessus). Le relevé qui a posé + la question est conservé : `Integrations.GetThings` sur `.75`, cet après-midi — 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 diff --git a/lib/models/ev_charger_live.dart b/lib/models/ev_charger_live.dart index 1c37699..39bb0f1 100644 --- a/lib/models/ev_charger_live.dart +++ b/lib/models/ev_charger_live.dart @@ -21,14 +21,26 @@ /// /// **`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. +/// portés ici pour autant. Le plugin V2C `1.15.2+etm13` **construit** ce `sessionEnergy` — +/// il accumule les incréments de `chargeEnergy`, détecte les remises à zéro, et ne repart +/// qu'à l'ouverture d'une session — mais la trace qui le montre à l'œuvre (`chargeEnergy` +/// à zéro, `sessionEnergy` qui continue) sort de la campagne en cours. **Aucun avancement +/// n'est affiché avant elle** : LM-1009 §C1, cf. `LoadMechanism`. library; import 'energy_data.dart' show ChargingMode; +/// Pleine puissance — et **mode d'office** de toute borne détectée par le moteur. +const String kChargingModeNormal = 'ChargingModeNormal'; + +/// Ce que l'écran doit dire d'une borne en [EvChargerLive.isUnmanaged], mot pour mot aux +/// deux endroits qui l'affichent : la tuile des favoris, où les modes se changent, et la +/// carte de configuration, que l'installateur ouvre à la mise en service. +const String kEvUnmanagedNote = + 'Pleine puissance, hors arbitrage : le HEMS ne pilote pas cette borne. ' + 'C\'est aussi le mode d\'office d\'une borne qui vient d\'être détectée — ' + 'choisir PV ou Min+PV la fait entrer dans l\'arbitrage du surplus.'; + class EvChargerLive { /// `thingId` de la borne — l'identifiant que `SetChargingInfo` attend. final String thingId; @@ -68,6 +80,22 @@ class EvChargerLive { _ => null, }; + /// **La borne charge à pleine puissance, hors arbitrage.** + /// + /// `ChargingModeNormal` n'est pas seulement le « boost » que quelqu'un peut choisir : + /// c'est le **mode d'office d'une borne détectée**. Une borne neuve n'est donc jamais + /// pilotée par le HEMS tant que personne n'a posé un mode éco — et rien, ni dans la + /// télémétrie ni sur l'écran, ne le disait. L'installateur pose une borne, elle charge à + /// fond, et il cherche pourquoi le HEMS l'ignore. + /// + /// ⚠️ **L'app ne peut PAS distinguer « personne n'a choisi » de « quelqu'un a choisi + /// Boost »** : la box publie le même `chargingMode` dans les deux cas. La note dit donc + /// l'état — pleine puissance, hors arbitrage — et signale qu'il est aussi le défaut ; + /// elle n'accuse personne de ne pas avoir réglé. Même règle que la note de rangs ex + /// æquo : reproductible n'est pas choisi, et on ne fait pas dire à un écran ce que la + /// donnée ne porte pas. + bool get isUnmanaged => apiMode == kChargingModeNormal; + /// 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 => diff --git a/lib/models/load_telemetry.dart b/lib/models/load_telemetry.dart index df08ffe..5bee6cb 100644 --- a/lib/models/load_telemetry.dart +++ b/lib/models/load_telemetry.dart @@ -192,9 +192,24 @@ class LoadMechanism { /// 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. + /// qu'on attend d'une session sans interruption. + /// + /// **Ce que le plugin en dit** (V2C `1.15.2+etm13`, déployé le 28 à midi) : ce + /// `sessionEnergy` est **construit**, pas un alias du registre `0x0BC4`. Il accumule les + /// incréments de `chargeEnergy`, détecte les remises à zéro, et ne repart qu'à + /// l'ouverture d'une session. L'égalité observée s'explique donc d'elle-même. + /// + /// **La réserve tient quand même, jusqu'à la trace.** La campagne de validation en cours + /// produit exactement le cas qui la lève — `chargeEnergy` à zéro, `sessionEnergy` qui + /// continue. Tant qu'elle n'est pas au dossier, **aucun avancement de charge n'est + /// affiché** : la condition C1 de LM-1009 reste entière. Ce n'est pas de la défiance + /// envers le plugin, c'est la règle qui a évité d'afficher `chargeEnergy` lui-même. + /// + /// > ⚠️ **`sessionEnergy` ne sera pas historisé.** `suggestLogging` est écrasé par les + /// > préférences persistées de `things.conf` sur un Thing déjà appairé, et la V2C n'y a + /// > que `connected`, `charging`, `power`. La **valeur courante** est juste — « la nuit + /// > relue à 7 h » fonctionne — mais il n'y aura **pas de courbe** tant que les états + /// > enregistrés n'auront pas été choisis côté app. Cf. `TODO.md`. // ── evcharger (3g) — l'ÉTAT RÉEL de la borne ──────────────────────────────── // diff --git a/lib/services/nymea_service.dart b/lib/services/nymea_service.dart index 768404a..823d6e4 100644 --- a/lib/services/nymea_service.dart +++ b/lib/services/nymea_service.dart @@ -1368,6 +1368,34 @@ class NymeaService extends ChangeNotifier { } } + /// Mode de recharge que la box porte pour cette borne, ou `null` si rien n'a été lu. + /// + /// Accepte indifféremment un `thingId` et un identifiant de charge `GetLoadConfig` : le + /// premier arrive **sans** accolades (`4d1adafd-…`), le second **avec** (`{4d1adafd-…}`), + /// et c'est la même borne. Comparer les deux tels quels ne rend jamais rien — en + /// silence, ce qui est le pire des cas : l'écran conclurait « mode non lu » sur une borne + /// parfaitement lue. + String? chargingModeOf(String id) { + final nu = id.replaceAll('{', '').replaceAll('}', '').toLowerCase(); + for (final e in _chargingInfos.entries) { + final k = e.key.replaceAll('{', '').replaceAll('}', '').toLowerCase(); + if (k == nu) return e.value['chargingMode'] as String?; + } + return null; + } + + /// Porte de test — même usage que `LoadConfigProvider.adoptForTest` : éprouver + /// l'affichage sur une charge utile réelle sans box. + @visibleForTesting + void adoptChargingInfosForTest(List> infos) { + _chargingInfos.clear(); + for (final m in infos) { + final id = m['evChargerId'] as String?; + if (id != null) _chargingInfos[id] = m; + } + notifyListeners(); + } + /// 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 diff --git a/lib/widgets/controllable_loads_section.dart b/lib/widgets/controllable_loads_section.dart index b2a4590..f7fc6da 100644 --- a/lib/widgets/controllable_loads_section.dart +++ b/lib/widgets/controllable_loads_section.dart @@ -3,10 +3,12 @@ import 'package:provider/provider.dart'; import '../l10n/app_localizations.dart'; import '../l10n/l10n_ext.dart'; +import '../models/ev_charger_live.dart'; import '../models/load_config_entry.dart'; import '../providers/load_config_provider.dart'; import '../providers/load_telemetry_provider.dart'; import '../services/load_hardware.dart'; +import '../services/nymea_service.dart'; import '../services/load_priority.dart'; import '../theme/etm_tokens.dart'; import 'load_cards.dart'; @@ -260,6 +262,52 @@ class _NoteRangsExAequo extends StatelessWidget { } } +/// **Une borne en pleine puissance ne passe pas par l'arbitrage — et c'est son défaut.** +/// +/// Le moteur crée d'office une entrée de configuration par borne détectée, en +/// `ChargingModeNormal` : pleine puissance, hors arbitrage. Tant que personne n'a posé un +/// mode éco, le HEMS ne pilote donc pas cette borne — et rien ne le disait. C'est le +/// pendant exact de la note de rangs ex æquo : un état par défaut qui a toutes les +/// apparences d'un réglage. +/// +/// ⚠️ Elle ne dit pas « personne n'a choisi » : la box publie le même `chargingMode` pour +/// un boost délibéré et pour une borne jamais réglée. Elle nomme l'ÉTAT, et rappelle qu'il +/// est aussi le défaut. +class _NoteBornePleinePuissance extends StatelessWidget { + final LoadConfigEntry entry; + + const _NoteBornePleinePuissance({required this.entry}); + + @override + Widget build(BuildContext context) { + // `GetChargingInfos` porte l'`evChargerId` sans accolades, `GetLoadConfig` l'id de + // charge avec : `chargingModeOf` réconcilie les deux. + final mode = context.watch().chargingModeOf(entry.id); + if (mode != kChargingModeNormal) return const SizedBox.shrink(); + + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Container( + width: double.infinity, + padding: const EdgeInsets.all(9), + decoration: BoxDecoration( + color: EtmTokens.solar.withValues(alpha: 0.10), + borderRadius: BorderRadius.circular(EtmTokens.radiusCtrl), + ), + child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Icon(Icons.flash_on_rounded, size: 15, color: EtmTokens.solar), + const SizedBox(width: 7), + Expanded( + child: Text(kEvUnmanagedNote, + style: TextStyle( + fontSize: 11.5, height: 1.35, color: EtmTokens.inkOf(context))), + ), + ]), + ), + ); + } +} + /// Un domaine et ses charges. Les domaines se déplacent entre eux, les charges à /// l'intérieur — les deux niveaux que l'ordre de service demande. class _DomainBlock extends StatelessWidget { @@ -560,6 +608,14 @@ class LoadConfigCard extends StatelessWidget { const SizedBox(height: 8), Divider(height: 1, color: EtmTokens.lineOf(context)), const SizedBox(height: 8), + // ── Une borne à pleine puissance n'est pas arbitrée ──────────────── + // + // Elle se lit ici et nulle part ailleurs : la télémétrie n'en dit rien (une + // borne en `ChargingModeNormal` sans véhicule branché est simplement absente de + // `loads[]`), et l'écran de configuration est celui que l'installateur ouvre à + // la mise en service. Sans cette ligne, il pose une borne, elle charge à fond, + // et il cherche du côté du HEMS un défaut qui n'existe pas. + if (entry.isEvCharger) _NoteBornePleinePuissance(entry: entry), if (live != null) LoadLiveBlock( live: live, diff --git a/lib/widgets/ev_favorite_tile.dart b/lib/widgets/ev_favorite_tile.dart index 3818ed5..4d9de49 100644 --- a/lib/widgets/ev_favorite_tile.dart +++ b/lib/widgets/ev_favorite_tile.dart @@ -117,6 +117,22 @@ class _EvFavoriteTileState extends State { Text(_mode(live), style: EtmTokens.sans( size: 11, color: EtmTokens.mutedOf(context))), + // Pleine puissance = hors arbitrage, et c'est le mode d'office d'une borne + // détectée. Les trois boutons sont juste à côté : c'est l'endroit où le + // dire coûte le moins à celui qui veut le corriger. + if (live.isUnmanaged) ...[ + const SizedBox(height: 3), + Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Icon(Icons.info_outline_rounded, + size: 13, color: EtmTokens.amber), + const SizedBox(width: 4), + Expanded( + child: Text(kEvUnmanagedNote, + style: EtmTokens.sans( + size: 10.5, color: EtmTokens.mutedOf(context))), + ), + ]), + ], ], ), ), diff --git a/test/ev_favorite_tile_test.dart b/test/ev_favorite_tile_test.dart index 6bdccc7..3081bad 100644 --- a/test/ev_favorite_tile_test.dart +++ b/test/ev_favorite_tile_test.dart @@ -106,6 +106,37 @@ void main() { }); }); + group('pleine puissance = hors arbitrage, et l\'écran le dit', () { + testWidgets('ChargingModeNormal porte la note — c\'est le mode d\'office', + (tester) async { + // Une borne détectée arrive en ChargingModeNormal : pleine puissance, hors + // arbitrage. Sans cette ligne, l'installateur pose une borne, elle charge à fond, + // et il cherche pourquoi le HEMS l'ignore. + await tester.pumpWidget(_harness(live: _v2c(apiMode: 'ChargingModeNormal'))); + + expect(find.textContaining('hors arbitrage'), findsOneWidget); + expect(find.textContaining('mode d\'office'), findsOneWidget); + // Elle n'accuse personne : la box publie le même code pour un boost délibéré. + final note = tester + .widget(find.textContaining('hors arbitrage')) + .data!; + expect(note, isNot(contains('personne'))); + expect(note, isNot(contains('oubli'))); + }); + + testWidgets('un mode éco la fait disparaître', (tester) async { + await tester.pumpWidget(_harness(live: _v2c(apiMode: 'ChargingModeEco'))); + expect(find.textContaining('hors arbitrage'), findsNothing); + }); + + testWidgets('mode non lu : aucune note — on ne conclut pas d\'une absence', + (tester) async { + await tester.pumpWidget(_harness(live: _v2c(apiMode: null))); + expect(find.textContaining('hors arbitrage'), findsNothing); + expect(find.text('Mode non lu'), 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( diff --git a/test/merged_config_screen_test.dart b/test/merged_config_screen_test.dart index 3d2ebaa..5cbe678 100644 --- a/test/merged_config_screen_test.dart +++ b/test/merged_config_screen_test.dart @@ -41,6 +41,7 @@ List> _benchConfig() { Widget _harness({ required LoadConfigProvider config, required LoadTelemetryProvider telemetry, + NymeaService? service, }) => MultiProvider( providers: [ @@ -49,7 +50,7 @@ Widget _harness({ // La section « Recharge véhicule » lit les Things de la box pour savoir s'il y a // une borne. Hors connexion, la liste est vide et la section s'efface — c'est // exactement le comportement attendu sur une installation sans borne. - ChangeNotifierProvider.value(value: NymeaService()), + ChangeNotifierProvider.value(value: service ?? NymeaService()), ], child: MaterialApp( locale: const Locale('fr'), @@ -228,6 +229,71 @@ void main() { }); }); + // ─────────────────────────────────────────────────────────────────────────── + group('une borne à pleine puissance n\'est pas arbitrée — et l\'écran le nomme', () { + List> v2cConfig() => + (_fixture('loadconfig_hems75_v2c.json')['loadConfigs'] as List) + .cast>(); + + /// `GetChargingInfos` du banc, 2026-08-28 : les deux bornes appairées y sont en + /// `ChargingModeNormal` / `ChargingStateIdle`. **L'`evChargerId` n'a pas d'accolades**, + /// là où l'id de charge de `GetLoadConfig` en a — c'est le piège que + /// `chargingModeOf` réconcilie. + NymeaService service({String mode = 'ChargingModeNormal'}) => + NymeaService() + ..adoptChargingInfosForTest([ + { + 'evChargerId': '4d1adafd-cafc-4813-93b1-ca8933ce71ab', + 'chargingMode': mode, + 'chargingState': 'ChargingStateIdle', + }, + ]); + + testWidgets('la note est à l\'écran sur la borne en ChargingModeNormal', + (tester) async { + final config = LoadConfigProvider()..adoptForTest(v2cConfig()); + final telemetry = LoadTelemetryProvider() + ..adoptForTest({'degradedMode': false, 'loads': const []}); + + await tester.pumpWidget(_harness( + config: config, telemetry: telemetry, service: service())); + await tester.pump(); + + // Une seule des cinq charges est concernée : la V2C. Les deux autres bornes n'ont + // pas d'entrée dans la charge utile lue, et une absence ne se conclut pas. + expect(find.textContaining('hors arbitrage'), findsOneWidget); + expect(find.textContaining('choisir PV ou Min+PV'), findsOneWidget); + }); + + testWidgets('un mode éco fait taire la note', (tester) async { + final config = LoadConfigProvider()..adoptForTest(v2cConfig()); + final telemetry = LoadTelemetryProvider() + ..adoptForTest({'degradedMode': false, 'loads': const []}); + + await tester.pumpWidget(_harness( + config: config, + telemetry: telemetry, + service: service(mode: 'ChargingModeEcoWithMinCurrent'))); + await tester.pump(); + + expect(find.textContaining('hors arbitrage'), findsNothing); + }); + + testWidgets('⚠️ les accolades de l\'id de charge ne doivent PAS faire rater la borne', + (tester) async { + // `GetLoadConfig` rend `{4d1adafd-…}`, `GetChargingInfos` rend `4d1adafd-…`. Comparer + // les deux tels quels ne rend jamais rien — en silence : l'écran conclurait « mode + // non lu » sur une borne parfaitement lue, et la note ne sortirait jamais. + final svc = service(); + expect(svc.chargingModeOf('{4d1adafd-cafc-4813-93b1-ca8933ce71ab}'), + 'ChargingModeNormal'); + expect(svc.chargingModeOf('4d1adafd-cafc-4813-93b1-ca8933ce71ab'), + 'ChargingModeNormal'); + expect(svc.chargingModeOf('{09e520fd-d472-47c4-8887-6e7759372889}'), isNull, + reason: 'une borne sans entrée lue n\'a pas de mode — pas un mode par défaut'); + }); + }); + // ─────────────────────────────────────────────────────────────────────────── // Les deux notes de la configuration réelle du 2026-08-28 — MONTÉES, pas // seulement calculées.