From a1455290c996f75f97f338094e8c48679b372d9a Mon Sep 17 00:00:00 2001 From: Patrick Schurig Date: Thu, 27 Aug 2026 07:38:48 +0200 Subject: [PATCH] =?UTF-8?q?feat(3g):=20BATTERY=5FRESERVE=20branch=C3=A9,?= =?UTF-8?q?=20funding=20lu,=20et=20l'=C3=A9tat=20d'une=20borne=20passe=20d?= =?UTF-8?q?evant?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deux constats du brief moteur, qui touchent le même fichier de lecture. 1. kCodesReserveBatterie était vide, et les clés cherchées n'ont JAMAIS existé côté plugin : batteryLevel/soc et threshold/batteryLevelConsideration contre socPercent, reservePercent, withheldW. Le motif sortait donc en repli — c'est-à-dire en anglais brut — sur le seul cas que le client ne peut déduire d'aucune mesure : « il y a du surplus, une règle l'interdit ». Vérifié contre decisionreason.h. Les trois paramètres sont des entiers DÉJÀ en pourcentage : reservePercent vaut 40, pas 0,4 — à ne pas confondre avec batteryLevelConsideration, qui est la fraction du réglage. Et withheldW mène la phrase, comme côté plugin : le moteur en fait la condition d'émission du motif, parce qu'un motif à 0 W dirait « une règle vous bloque » là où la vérité est « il n'y a pas de surplus ». Le motif n'a PAS pu être observé en vrai sur .75 : la bascule du seuil à 0,9 a produit surplusW à −3 551 W, donc rien à confisquer, donc garde du plugin active et motif tu — ce qui est le comportement juste. Il faut du surplus réel sous le seuil. Seuil remis à 0,4. 2. funding (« surplus » / « grid ») est lu, et avec lui l'identité que le plugin déclare : somme des allocatedW financés au surplus == budget.allocatedW. Sommer sans filtrer compte une borne servie au réseau et creuse un écart qu'on ne peut pas interpréter. Absent = surplus, pas zéro : c'est le seul financement qui existait avant ce champ. 3. Les bornes sont entrées dans loads[] (+etm22), et adjustEvChargers() re-décide après le waterfall : pour une borne, allocatedW et son motif sont la DÉCISION DE L'ARBITRE, pas l'état de la borne. L'écran affiche donc d'abord ce que le matériel fait (mechanism.chargingEnabled / currentA, qui viennent du Thing), ensuite ce que le moteur a voulu, étiqueté comme tel. L'écart mesuré/commandé se tait sur une borne — « sous la consigne » y supposerait une consigne. La réserve ne touche PAS l'ECS ni la PAC, où l'allocation publiée est bien la commande : elle est portée par allocationIsCommand, et tombe avec le lot plugin 3g-2. EV_SURPLUS, EV_ECO_MIN et EV_IDLE ne sont plus émis. Les branches restent : les retirer rendrait illisible un journal antérieur, pas l'app plus juste. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016vrTVifar2GN5rtUh89Wkq --- lib/l10n/app_fr.arb | 38 +++++- lib/l10n/app_localizations.dart | 50 +++++++- lib/l10n/app_localizations_fr.dart | 44 +++++++ lib/models/load_telemetry.dart | 103 ++++++++++++++-- lib/services/telemetry_text.dart | 70 ++++++----- lib/widgets/load_cards.dart | 84 ++++++++++++- test/load_telemetry_test.dart | 189 ++++++++++++++++++++++++++--- 7 files changed, 525 insertions(+), 53 deletions(-) diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 9efbc11..35c606a 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -139,6 +139,33 @@ "@telemetryAllocated": { "placeholders": { "watts": { "type": "int", "format": "decimalPattern" } } }, + "telemetryEvArbiterIntent": "L'arbitre a décidé {watts} W", + "@telemetryEvArbiterIntent": { + "description": "Remplace « Alloué X W » pour une BORNE. Tant que le lot plugin 3g-2 n'est pas livré, adjustEvChargers() re-décide après le waterfall : l'allocation publiée est une intention, pas un ordre. Le verbe doit donc nommer QUI a décidé, pas ce que la borne fait.", + "placeholders": { "watts": { "type": "int", "format": "decimalPattern" } } + }, + "telemetryEvIntentDisclaimer": "Décision de l'arbitre — l'état réel de la borne est ci-dessus.", + "@telemetryEvIntentDisclaimer": { + "description": "Ligne qui suit le motif d'une borne. Sans elle, un écran qui affiche « budget sous le plancher » à côté d'une borne qui charge à 6 A a l'air faux — et c'est le moteur qui se contredit, pas l'écran. Tombe avec 3g-2." + }, + "telemetryEvCharging": "Borne : en charge, {current} A sur {phases} phase(s)", + "@telemetryEvCharging": { + "description": "ÉTAT RÉEL, lu dans mechanism.chargingEnabled / currentA / phaseCount — qui viennent du Thing, pas de l'arbitre.", + "placeholders": { + "current": { "type": "double", "format": "decimalPattern" }, + "phases": { "type": "int" } + } + }, + "telemetryEvIdle": "Borne : ne charge pas", + "telemetryEvUnplugged": "Borne : aucun véhicule branché", + "telemetryEvStateUnknown": "Borne : état non publié ce cycle", + "@telemetryEvStateUnknown": { + "description": "mechanism.chargingEnabled absent. Ne rien dire vaut mieux que déduire l'état du motif d'arbitrage, qui peut le contredire." + }, + "telemetryFundedByGrid": "Financée au réseau (échéance ou tarif dynamique) — hors budget de surplus", + "@telemetryFundedByGrid": { + "description": "funding == « grid » : l'allocation vit dans budget.evReservedW, pas dans budget.allocatedW. Sans cette mention, l'utilisateur voit une charge servie alors que le surplus est nul et croit à un défaut." + }, "telemetryNotArbitrated": "Non arbitrée — absente du dernier cycle publié", "@telemetryNotArbitrated": { "description": "Charge présente dans GetLoadConfig mais absente de loads[] : elle est désactivée, jamais perdue" @@ -289,9 +316,18 @@ "min": { "type": "int", "format": "decimalPattern" } } }, + "decisionBatteryReserveWithheld": "{withheld} W de surplus retenus par la réserve batterie ({soc} % sous le seuil de {seuil} %) — il y a du surplus, mais une règle interdit de le distribuer.", + "@decisionBatteryReserveWithheld": { + "description": "Variante NOMINALE : le plugin publie toujours les trois paramètres ensemble, et sa garde interne exige withheldW > 0 — un motif à 0 W dirait « une règle vous bloque » là où il n'y a simplement pas de surplus. Le nombre de watts retenus vient en TÊTE parce que c'est lui qui rend la cause lisible : « 3 200 W retenus » se comprend, « batterie à 47 % » se subit.", + "placeholders": { + "withheld": { "type": "int", "format": "decimalPattern" }, + "soc": { "type": "int" }, + "seuil": { "type": "int" } + } + }, "decisionBatteryReserve": "Budget annulé par la réserve batterie ({soc} % sous le seuil de {seuil} %) — il y a du surplus, mais une règle interdit de le distribuer.", "@decisionBatteryReserve": { - "description": "À NE PAS confondre avec « pas de surplus » : le surplus existe, une règle l'annule. Le client voit une installation qui ne pilote rien par grand soleil, sans erreur ni panne — la phrase doit donc nommer la cause, pas décrire l'effet.", + "description": "Repli si « withheldW » manque (box plus ancienne). À NE PAS confondre avec « pas de surplus » : le surplus existe, une règle l'annule. Le client voit une installation qui ne pilote rien par grand soleil, sans erreur ni panne — la phrase doit donc nommer la cause, pas décrire l'effet.", "placeholders": { "soc": { "type": "int" }, "seuil": { "type": "int" } diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 2728992..6cc5681 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -445,6 +445,48 @@ abstract class L10n { /// **'Alloué {watts} W'** String telemetryAllocated(int watts); + /// Remplace « Alloué X W » pour une BORNE. Tant que le lot plugin 3g-2 n'est pas livré, adjustEvChargers() re-décide après le waterfall : l'allocation publiée est une intention, pas un ordre. Le verbe doit donc nommer QUI a décidé, pas ce que la borne fait. + /// + /// In fr, this message translates to: + /// **'L\'arbitre a décidé {watts} W'** + String telemetryEvArbiterIntent(int watts); + + /// Ligne qui suit le motif d'une borne. Sans elle, un écran qui affiche « budget sous le plancher » à côté d'une borne qui charge à 6 A a l'air faux — et c'est le moteur qui se contredit, pas l'écran. Tombe avec 3g-2. + /// + /// In fr, this message translates to: + /// **'Décision de l\'arbitre — l\'état réel de la borne est ci-dessus.'** + String get telemetryEvIntentDisclaimer; + + /// ÉTAT RÉEL, lu dans mechanism.chargingEnabled / currentA / phaseCount — qui viennent du Thing, pas de l'arbitre. + /// + /// In fr, this message translates to: + /// **'Borne : en charge, {current} A sur {phases} phase(s)'** + String telemetryEvCharging(double current, int phases); + + /// No description provided for @telemetryEvIdle. + /// + /// In fr, this message translates to: + /// **'Borne : ne charge pas'** + String get telemetryEvIdle; + + /// No description provided for @telemetryEvUnplugged. + /// + /// In fr, this message translates to: + /// **'Borne : aucun véhicule branché'** + String get telemetryEvUnplugged; + + /// mechanism.chargingEnabled absent. Ne rien dire vaut mieux que déduire l'état du motif d'arbitrage, qui peut le contredire. + /// + /// In fr, this message translates to: + /// **'Borne : état non publié ce cycle'** + String get telemetryEvStateUnknown; + + /// funding == « grid » : l'allocation vit dans budget.evReservedW, pas dans budget.allocatedW. Sans cette mention, l'utilisateur voit une charge servie alors que le surplus est nul et croit à un défaut. + /// + /// In fr, this message translates to: + /// **'Financée au réseau (échéance ou tarif dynamique) — hors budget de surplus'** + String get telemetryFundedByGrid; + /// Charge présente dans GetLoadConfig mais absente de loads[] : elle est désactivée, jamais perdue /// /// In fr, this message translates to: @@ -619,7 +661,13 @@ abstract class L10n { /// **'Budget de {budget} W sous l\'état {state} ({min} W estimés) — maintien en état 2, le budget passe à la charge suivante.'** String decisionBelowMinPowerState(int budget, int state, int min); - /// À NE PAS confondre avec « pas de surplus » : le surplus existe, une règle l'annule. Le client voit une installation qui ne pilote rien par grand soleil, sans erreur ni panne — la phrase doit donc nommer la cause, pas décrire l'effet. + /// Variante NOMINALE : le plugin publie toujours les trois paramètres ensemble, et sa garde interne exige withheldW > 0 — un motif à 0 W dirait « une règle vous bloque » là où il n'y a simplement pas de surplus. Le nombre de watts retenus vient en TÊTE parce que c'est lui qui rend la cause lisible : « 3 200 W retenus » se comprend, « batterie à 47 % » se subit. + /// + /// In fr, this message translates to: + /// **'{withheld} W de surplus retenus par la réserve batterie ({soc} % sous le seuil de {seuil} %) — il y a du surplus, mais une règle interdit de le distribuer.'** + String decisionBatteryReserveWithheld(int withheld, int soc, int seuil); + + /// Repli si « withheldW » manque (box plus ancienne). À NE PAS confondre avec « pas de surplus » : le surplus existe, une règle l'annule. Le client voit une installation qui ne pilote rien par grand soleil, sans erreur ni panne — la phrase doit donc nommer la cause, pas décrire l'effet. /// /// In fr, this message translates to: /// **'Budget annulé par la réserve batterie ({soc} % sous le seuil de {seuil} %) — il y a du surplus, mais une règle interdit de le distribuer.'** diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index 087414f..605583d 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -241,6 +241,41 @@ class L10nFr extends L10n { return 'Alloué $wattsString W'; } + @override + String telemetryEvArbiterIntent(int watts) { + final intl.NumberFormat wattsNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String wattsString = wattsNumberFormat.format(watts); + + return 'L\'arbitre a décidé $wattsString W'; + } + + @override + String get telemetryEvIntentDisclaimer => + 'Décision de l\'arbitre — l\'état réel de la borne est ci-dessus.'; + + @override + String telemetryEvCharging(double current, int phases) { + final intl.NumberFormat currentNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String currentString = currentNumberFormat.format(current); + + return 'Borne : en charge, $currentString A sur $phases phase(s)'; + } + + @override + String get telemetryEvIdle => 'Borne : ne charge pas'; + + @override + String get telemetryEvUnplugged => 'Borne : aucun véhicule branché'; + + @override + String get telemetryEvStateUnknown => 'Borne : état non publié ce cycle'; + + @override + String get telemetryFundedByGrid => + 'Financée au réseau (échéance ou tarif dynamique) — hors budget de surplus'; + @override String get telemetryNotArbitrated => 'Non arbitrée — absente du dernier cycle publié'; @@ -477,6 +512,15 @@ class L10nFr extends L10n { return 'Budget de $budgetString W sous l\'état $state ($minString W estimés) — maintien en état 2, le budget passe à la charge suivante.'; } + @override + String decisionBatteryReserveWithheld(int withheld, int soc, int seuil) { + final intl.NumberFormat withheldNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String withheldString = withheldNumberFormat.format(withheld); + + return '$withheldString W de surplus retenus par la réserve batterie ($soc % sous le seuil de $seuil %) — il y a du surplus, mais une règle interdit de le distribuer.'; + } + @override String decisionBatteryReserve(int soc, int seuil) { return 'Budget annulé par la réserve batterie ($soc % sous le seuil de $seuil %) — il y a du surplus, mais une règle interdit de le distribuer.'; diff --git a/lib/models/load_telemetry.dart b/lib/models/load_telemetry.dart index 1632f42..6c5e109 100644 --- a/lib/models/load_telemetry.dart +++ b/lib/models/load_telemetry.dart @@ -112,8 +112,7 @@ class LoadLock { class LoadMechanism { final Map raw; - /// `relay` · `variable` · `sgReady` · `evcharger` (ce dernier réservé à l'étape 3g, - /// non émis en beta). + /// `relay` · `variable` · `sgReady` · `evcharger` — ce dernier émis depuis `+etm22`. final String kind; const LoadMechanism(this.raw, this.kind); @@ -164,7 +163,17 @@ class LoadMechanism { int? get state => (raw['state'] as num?)?.toInt(); double? get estimatedPowerW => _dn(raw['estimatedPowerW']); - // evcharger (3g) + // ── evcharger (3g) — l'ÉTAT RÉEL de la borne ──────────────────────────────── + // + // Ces quatre champs viennent du Thing, pas de l'arbitre. C'est la distinction qui + // compte tant que 3g-2 n'est pas livré : `adjustEvChargers()` re-décide le surplus + // APRÈS le waterfall et passe outre le refus de l'arbitre. Relevé sur `.75`, deux + // lignes du même cycle — l'arbitre publie `BELOW_MIN_POWER` et « 0 W », le proxy + // commande `ON` à 6 A une milliseconde plus tard. + // + // Donc : pour une borne, [LoadTelemetryEntry.allocatedW] et son motif sont une + // INTENTION, ces champs-ci sont ce que la borne fait. Les deux peuvent se contredire, + // et c'est l'écran qui aurait l'air faux. Cf. [LoadTelemetryEntry.allocationIsCommand]. bool? get chargingEnabled => raw['chargingEnabled'] as bool?; bool? get pluggedIn => raw['pluggedIn'] as bool?; double? get currentA => _dn(raw['currentA']); @@ -209,6 +218,24 @@ class LoadTelemetryEntry { /// Absent quand la charge est saine. final String? faultCode; + /// **Qui paie** cette allocation : `"surplus"` ou `"grid"`. Nouveau en `+etm22`. + /// + /// Il devient nécessaire du seul fait que les bornes sont entrées dans `loads[]` : deux + /// charges peuvent y figurer pour des raisons différentes. + /// + /// - `"surplus"` — la cascade PV. L'allocation est comptée dans `budget.allocatedW`. + /// - `"grid"` — le proxy (échéance de départ, tarif dynamique) qui **soutire au + /// réseau**. Elle vit dans `budget.evReservedW`, PAS dans `budget.allocatedW`. + /// + /// **Sommer les `allocatedW` sans filtrer donne un écart avec `budget.allocatedW` que + /// rien ne permet d'interpréter** : défaut du moteur, ou borne servie au réseau ? Cf. + /// [LoadTelemetry.surplusAllocatedW]. + /// + /// `null` = **omis**, jamais « zéro financement » : en mode dégradé il n'y a pas de + /// plan, donc pas de financement. Une box antérieure à `+etm22` ne le publie pas non + /// plus — d'où le repli explicite de [isSurplusFunded]. + final String? funding; + /// Puissance **MESURÉE** par le compteur rattaché à cette charge. `null` quand aucun /// compteur ne l'est. /// @@ -232,6 +259,7 @@ class LoadTelemetryEntry { required this.available, required this.faultCode, this.measuredW, + this.funding, required this.lock, required this.mechanism, required this.decision, @@ -250,6 +278,9 @@ class LoadTelemetryEntry { available: m['available'] as bool? ?? true, faultCode: (fault is String && fault.isNotEmpty) ? fault : null, measuredW: _dn(m['measuredW']), + funding: (m['funding'] is String && (m['funding'] as String).isNotEmpty) + ? m['funding'] as String + : null, lock: lock is Map ? LoadLock.fromJson(Map.from(lock)) : null, mechanism: mech is Map ? LoadMechanism.fromJson(Map.from(mech)) @@ -260,6 +291,32 @@ class LoadTelemetryEntry { } bool get inFault => !available; + + /// Vrai quand l'allocation est **financée par le surplus** — donc comptée dans + /// `budget.allocatedW`. + /// + /// `funding` absent (mode dégradé, ou box antérieure à `+etm22`) est traité comme + /// surplus : c'est le seul financement qui existait avant ce champ, et le compter + /// laisse l'identité vérifiable sur une box ancienne au lieu de la faire échouer. + bool get isSurplusFunded => funding != 'grid'; + + /// **Faux pour une borne : l'allocation publiée est une INTENTION, pas un ordre.** + /// + /// `adjustEvChargers()` re-décide le surplus après le waterfall et passe outre le refus + /// de l'arbitre — relevé sur `.75`, l'arbitre publie « 0 W / `BELOW_MIN_POWER` » et le + /// proxy commande `ON` à 6 A dans le même cycle. Pour une borne, [allocatedW] et + /// [decision] sont donc **la décision de l'arbitre**, et l'état réel se lit dans + /// `mechanism.chargingEnabled` / `mechanism.currentA`, qui viennent du Thing. + /// + /// Écrire « la borne ne charge pas parce que le budget est sous le plancher » à partir + /// du seul motif est faux aujourd'hui, et c'est l'écran qui aurait l'air faux. + /// + /// La réserve **ne vaut pas** pour l'ECS, la PAC et les autres charges pilotées : là, + /// l'allocation publiée **est** la commande. + /// + /// Elle tombe avec le lot plugin 3g-2 (« l'arbitre commande seul »). Le jour où il est + /// déployé, ce getter rend `true` partout et les branches qui le lisent disparaissent. + bool get allocationIsCommand => mechanism?.isEvCharger != true; } /// Instantané complet de l'arbitrage. @@ -276,10 +333,16 @@ class LoadTelemetry { /// `null` en mode dégradé — il n'existe alors aucun budget pour ce cycle. final TelemetryBudget? budget; - /// Uniquement les charges **arbitrées par le waterfall** (`relay-router`, - /// `etmvariableload`, `sg-ready`). Le VE n'y figure pas en beta : il est décidé en - /// amont et sa puissance est réservée dans `budget.evReservedW`, pas allouée depuis le - /// budget publié. + /// Les charges arbitrées, **bornes de recharge comprises depuis `+etm22`**. + /// + /// Jusque-là `buildTelemetry()` sautait les bornes : elles étaient arbitrées, leurs + /// décisions allaient au journal du plugin, et l'écran ne pouvait pas les voir. Deux + /// conséquences pour un client : + /// + /// - une borne peut être là au titre du surplus **ou** au titre du réseau (échéance, + /// tarif dynamique) — d'où [LoadTelemetryEntry.funding] ; + /// - pour une borne, l'allocation publiée n'est **pas** un ordre tant que 3g-2 n'est pas + /// livré — d'où [LoadTelemetryEntry.allocationIsCommand]. /// /// Depuis le lot plugin B-bis, tout `loadId` publié ici a une entrée dans /// `GetLoadConfig` — l'inverse n'est pas vrai : une charge configurée absente d'ici est @@ -317,6 +380,32 @@ class LoadTelemetry { /// `true` tant qu'aucun cycle d'arbitrage n'a eu lieu depuis le démarrage du service. bool get hasNeverRun => timestamp == null; + /// Somme des allocations **financées par le surplus**. + /// + /// C'est le seul terme comparable à `budget.allocatedW` : une borne servie au réseau + /// est comptée dans `budget.evReservedW`, et l'inclure ici creuserait un écart que rien + /// ne permettrait d'interpréter — défaut du moteur, ou borne au réseau ? + double get surplusAllocatedW { + var total = 0.0; + for (final e in loads) { + if (e.isSurplusFunded) total += e.allocatedW; + } + return total; + } + + /// L'identité que le plugin déclare vérifiable depuis `+etm22` : + /// **somme des `allocatedW` financés au surplus == `budget.allocatedW`**. + /// + /// `null` en mode dégradé — sans budget, il n'y a rien à réconcilier, et rendre `false` + /// se lirait « le moteur se contredit » là où il n'a simplement pas planifié. + /// + /// Tolérance 1 W : les valeurs voyagent en `Double`. + bool? get surplusIdentityHolds { + final b = budget; + if (b == null) return null; + return (surplusAllocatedW - b.allocatedW).abs() < 1.0; + } + LoadTelemetryEntry? entryFor(String loadId) { for (final e in loads) { if (e.loadId == loadId) return e; diff --git a/lib/services/telemetry_text.dart b/lib/services/telemetry_text.dart index 0484f95..fbe76f2 100644 --- a/lib/services/telemetry_text.dart +++ b/lib/services/telemetry_text.dart @@ -25,6 +25,13 @@ String decisionText(L10n t, LoadDecision d) { final p = d.params; switch (d.code) { + // `EV_SURPLUS`, `EV_ECO_MIN` et `EV_IDLE` ne sont **plus émis** depuis `+etm22` : une + // borne reçoit désormais les motifs communs à tous les mécanismes + // (`SURPLUS_SETPOINT`, `BELOW_MIN_POWER`, `SURPLUS_INSUFFICIENT`, + // `LOAD_UNAVAILABLE`). Les trois branches restent — retirer une phrase ne rendrait + // pas l'app plus juste, elle rendrait illisible un journal antérieur, et une box en + // retard sur l'app est un cas normal en parc déployé. `EV_SPOT_MARKET` et + // `EV_DEADLINE`, eux, subsistent : le proxy les produit toujours. case 'EV_SURPLUS': return t.decisionEvSurplus; case 'EV_SPOT_MARKET': @@ -105,6 +112,35 @@ String decisionText(L10n t, LoadDecision d) { if (budget == null) break; return t.decisionSgNormal(budget); + // ── Réserve batterie : branché le 2026-08-27 ──────────────────────────── + // + // Le code et ses paramètres sont désormais publiés : `BATTERY_RESERVE`, avec + // `socPercent`, `reservePercent`, `withheldW`. Les clés que ce fichier cherchait + // (`batteryLevel`/`soc`, `threshold`/`batteryLevelConsideration`) n'ont JAMAIS été + // celles du plugin — le motif sortait donc en repli, c'est-à-dire en anglais brut, + // sur le seul motif que le client ne peut déduire d'aucune mesure. + // + // Les trois paramètres sont des ENTIERS déjà en pourcentage (`qRound` côté plugin) : + // `reservePercent` vaut 40, pas 0,4 — à ne pas confondre avec + // `batteryLevelConsideration`, qui est la fraction 0…1 du réglage installateur. + // + // `withheldW` mène la phrase, et ce n'est pas un choix de style : le plugin en fait + // la condition d'émission du motif (garde `m_reserveWithheldW > 0`), parce qu'un + // motif à 0 W dirait « une règle vous bloque » là où la vérité est « il n'y a pas de + // surplus » — exactement la confusion que ce motif existe pour supprimer, retournée. + // Le chiffre retenu se comprend ; un pourcentage de SOC se subit. + // + // Les deux variantes plus pauvres restent des replis pour une box antérieure, pas des + // cas nominaux : les trois paramètres voyagent toujours ensemble. + case _ when kCodesReserveBatterie.contains(d.code): + final soc = _i(d, 'socPercent'); + final seuil = _i(d, 'reservePercent'); + final retenus = _w(d, 'withheldW'); + if (soc == null || seuil == null) return t.decisionBatteryReserveShort; + return retenus == null + ? t.decisionBatteryReserve(soc, seuil) + : t.decisionBatteryReserveWithheld(retenus, soc, seuil); + // « Il y a du surplus, mais pas au bon format. » // // Le sens est à distinguer soigneusement de SURPLUS_INSUFFICIENT et SG_NORMAL, qui ne @@ -116,27 +152,6 @@ String decisionText(L10n t, LoadDecision d) { // Deux variantes, selon que le mécanisme se commande en watts ou en états. La présence // de `state` les départage — et si elle manque sur un mécanisme à états, le repli // rendra le code et ses paramètres plutôt qu'une phrase fausse. - // ── Réserve batterie : préparé, pas encore branché ────────────────────── - // - // Le motif viendra quand la règle montera dans le budget. Son CODE n'est pas tranché - // côté plugin, donc le deviner reviendrait à écrire une branche morte qui ne - // s'activerait jamais — ou pire, à en activer une sur un code homonyme. - // - // Ce qui est prêt, c'est la PHRASE, et c'est la partie difficile : elle doit nommer la - // cause (« une règle interdit de distribuer ») et non décrire l'effet (« rien n'est - // piloté »), sans quoi elle se confond avec « pas de surplus ». Le jour où le code est - // publié, il n'y a qu'à l'ajouter à [kCodesReserveBatterie]. - // - // En attendant, le repli fait exactement son travail : un code inconnu sort en clair - // avec ses paramètres, ce qui ne peut PAS se lire comme « aucun surplus ». C'est - // vérifié par un test plutôt qu'espéré. - case _ when kCodesReserveBatterie.contains(d.code): - final soc = _i(d, 'batteryLevel') ?? _i(d, 'soc'); - final seuil = _i(d, 'threshold') ?? _i(d, 'batteryLevelConsideration'); - return (soc == null || seuil == null) - ? t.decisionBatteryReserveShort - : t.decisionBatteryReserve(soc, seuil); - case 'BELOW_MIN_POWER': final budget = _w(d, 'budgetW'); final min = _w(d, 'minPowerW'); @@ -151,13 +166,14 @@ String decisionText(L10n t, LoadDecision d) { return t.decisionUnknown(d.code.isEmpty ? '—' : d.code, formatParams(p)); } -/// Codes que le plugin publiera pour « budget annulé par la réserve batterie ». +/// Codes du plugin pour « budget annulé par la réserve batterie ». /// -/// **Volontairement vide.** Le nom n'est pas arrêté côté plugin ; y mettre une supposition -/// créerait soit une branche morte, soit — pire — une branche qui s'activerait sur un -/// homonyme. Le jour où le code est publié, une seule ligne suffit ici, et la phrase est -/// déjà écrite et relue. -const Set kCodesReserveBatterie = {}; +/// Un ensemble et non une constante : le motif a déjà porté un nom de travail, et une box +/// en parc peut être en retard sur l'app comme en avance. Ajouter un alias ici coûte une +/// ligne ; le manquer sort le motif en repli anglais. +/// +/// Source d'autorité : `etm/types/decisionreason.h`, `DecisionCode::BatteryReserve`. +const Set kCodesReserveBatterie = {'BATTERY_RESERVE'}; /// Rendu déterministe des paramètres bruts, pour le repli. /// diff --git a/lib/widgets/load_cards.dart b/lib/widgets/load_cards.dart index 99d05b4..b044d1a 100644 --- a/lib/widgets/load_cards.dart +++ b/lib/widgets/load_cards.dart @@ -132,9 +132,19 @@ class LoadLiveBlock extends StatelessWidget { Widget build(BuildContext context) { final t = context.l10n; final mech = live.mechanism; - final mechText = mech == null ? null : mechanismText(t, mech); final lock = live.lock; + // Une BORNE ne se lit pas comme les autres charges tant que 3g-2 n'est pas livré : + // `adjustEvChargers()` re-décide après le waterfall et peut commander l'inverse de ce + // que l'arbitre a publié. L'allocation et son motif restent affichés — ils disent ce + // que le moteur a VOULU, ce qui est une information — mais l'état réel du matériel + // passe DEVANT, et rien ne présente le motif comme la cause de ce que fait la borne. + final borne = !live.allocationIsCommand; + + // La ligne de mécanisme redirait le courant déjà donné par l'état réel d'une borne. + final mechText = + (mech == null || borne) ? null : mechanismText(t, mech); + // Les données anciennes restent affichées — les masquer priverait du seul état // connu — mais grisées : elles décrivent un cycle, pas l'instant présent. final tone = stale ? const Color(0xFF9CA3AF) : const Color(0xFF374151); @@ -142,12 +152,34 @@ class LoadLiveBlock extends StatelessWidget { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + // ── L'ÉTAT RÉEL de la borne, AVANT la décision de l'arbitre ───────── + // + // `chargingEnabled` / `currentA` viennent du Thing ; l'allocation et son motif + // viennent de l'arbitre. Les deux peuvent se contredire aujourd'hui, et l'ordre + // d'affichage est ce qui empêche l'écran d'avoir l'air faux : on montre d'abord + // ce que le matériel fait, ensuite ce que le moteur a décidé. + if (borne) ...[ + Row(children: [ + Icon(Icons.ev_station_rounded, size: 14, color: tone), + const SizedBox(width: 4), + Flexible( + child: Text(_etatBorne(t, mech), + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 12, fontWeight: FontWeight.w600, color: tone)), + ), + ]), + const SizedBox(height: 2), + ], Row(children: [ Icon(Icons.bolt_rounded, size: 14, color: stale ? const Color(0xFFB0B7BF) : const Color(0xFF2E7D32)), const SizedBox(width: 4), - Text(t.telemetryAllocated(live.allocatedW.round()), + Text( + borne + ? t.telemetryEvArbiterIntent(live.allocatedW.round()) + : t.telemetryAllocated(live.allocatedW.round()), style: TextStyle( fontSize: 12, fontWeight: FontWeight.w600, color: tone)), if (mechText != null) ...[ @@ -187,6 +219,30 @@ class LoadLiveBlock extends StatelessWidget { const SizedBox(height: 2), Text(decisionText(t, live.decision), style: TextStyle(fontSize: 11, color: tone)), + // Le motif d'une borne est la décision de l'arbitre, pas la cause de ce que la + // borne fait. Le dire explicitement coûte une ligne ; le taire fait passer une + // contradiction du moteur pour un défaut de l'écran. + if (borne) ...[ + const SizedBox(height: 2), + Text(t.telemetryEvIntentDisclaimer, + style: TextStyle( + fontSize: 11, fontStyle: FontStyle.italic, color: tone)), + ], + // Financement — une charge servie au RÉSEAU n'est pas comptée au budget de + // surplus. Sans cette mention, l'utilisateur voit une charge servie alors que le + // surplus est nul, et cherche un défaut. + if (live.funding == 'grid') ...[ + const SizedBox(height: 2), + Row(children: [ + Icon(Icons.power_rounded, size: 13, color: tone), + const SizedBox(width: 4), + Flexible( + child: Text(t.telemetryFundedByGrid, + overflow: TextOverflow.ellipsis, + style: TextStyle(fontSize: 11, color: tone)), + ), + ]), + ], if (lock != null) ...[ const SizedBox(height: 2), Row(children: [ @@ -459,6 +515,26 @@ class _Note extends StatelessWidget { } +/// État RÉEL d'une borne, lu dans le Thing — jamais déduit du motif d'arbitrage. +/// +/// L'ordre des tests n'est pas indifférent. « Débranchée » prime sur « ne charge pas » : +/// les deux sont vrais, mais le premier dit ce qu'il faut faire. Et `chargingEnabled` +/// absent ne devient PAS « ne charge pas » — le motif d'arbitrage pourrait le contredire, +/// et une absence présentée comme un état est exactement ce que ce bloc existe pour +/// éviter. +String _etatBorne(L10n t, LoadMechanism? mech) { + if (mech == null) return t.telemetryEvStateUnknown; + if (mech.pluggedIn == false) return t.telemetryEvUnplugged; + final on = mech.chargingEnabled; + if (on == null) return t.telemetryEvStateUnknown; + if (!on) return t.telemetryEvIdle; + final a = mech.currentA; + final ph = mech.phaseCount; + // Charge en cours sans courant publié : on dit qu'elle charge, sans inventer combien. + if (a == null || ph == null) return t.telemetryEvIdle; + return t.telemetryEvCharging(a, ph); +} + /// Commentaire d'écart entre commandé et mesuré, quand il y a de quoi le dire. /// /// Volontairement prudent : un écart de mesure **n'est pas** un défaut d'écriture. @@ -469,6 +545,10 @@ class _Note extends StatelessWidget { String _ecartMesure(LoadTelemetryEntry live) { final m = live.measuredW; if (m == null) return ''; + // Sur une borne, l'allocation n'est pas une consigne : « sous la consigne » y + // affirmerait un écart entre une commande et son effet là où il n'y a peut-être + // aucune commande. Muet tant que 3g-2 n'a pas rendu l'arbitre seul commandeur. + if (!live.allocationIsCommand) return ''; final a = live.allocatedW; if (a <= 0) return m > 20 ? '· alloué 0 W' : ''; final ecart = (m - a).abs(); diff --git a/test/load_telemetry_test.dart b/test/load_telemetry_test.dart index 5fc1598..5d63e8d 100644 --- a/test/load_telemetry_test.dart +++ b/test/load_telemetry_test.dart @@ -247,20 +247,52 @@ void main() { expect(phrase, contains('budgetW = 320')); }); - test('réserve batterie — le repli ne peut PAS se lire « aucun surplus »', () { - // Le motif viendra ; son code n'est pas tranché, donc rien ne l'attrape encore. Ce - // qui compte d'ici là est que le repli ne fabrique aucune confusion : le cas est - // « il y a du surplus, une règle l'interdit », et le lire comme « il n'y en a pas » - // enverrait chercher un défaut de production. - final repli = decisionText( + test('réserve batterie — la charge utile RÉELLE du plugin est rendue, pas le code', () { + // Le motif est publié depuis `+etm22` avec CES trois clés. Celles que l'app + // cherchait avant (`batteryLevel`, `threshold`) n'ont jamais existé côté plugin : + // le motif sortait donc en repli, c'est-à-dire en anglais brut, sur le seul cas que + // le client ne peut déduire d'aucune mesure. + final phrase = decisionText( t, const LoadDecision( - code: 'BATTERY_RESERVE_HOLD', - params: {'batteryLevel': 50, 'threshold': 90}), + code: 'BATTERY_RESERVE', + params: {'socPercent': 47, 'reservePercent': 90, 'withheldW': 3200}, + ), ); - expect(repli, contains('BATTERY_RESERVE_HOLD')); - expect(repli.toLowerCase(), isNot(contains('aucun surplus'))); - expect(repli, contains('batteryLevel = 50')); + expect(phrase, isNot(contains('BATTERY_RESERVE')), + reason: 'le motif tombe en repli : les clés cherchées ne sont pas les bonnes'); + expect(phrase, contains('47')); + expect(phrase, contains('90')); + // Le chiffre RETENU mène la phrase : c'est lui qui rend la cause lisible, là où un + // pourcentage de SOC se subit. Le plugin en fait la condition d'émission du motif. + expect(phrase, contains('3')); + expect(phrase.toLowerCase(), isNot(contains('aucun surplus'))); + }); + + test('réserve batterie — les clés d\'AVANT ne réactivent pas la phrase par accident', + () { + // Un motif portant les anciennes clés n'a pas de quoi composer : il doit sortir en + // repli, pas en phrase amputée qui aurait l'air d'une donnée. + final phrase = decisionText( + t, + const LoadDecision( + code: 'BATTERY_RESERVE', params: {'batteryLevel': 50, 'threshold': 90}), + ); + expect(phrase, contains('réserve batterie'), + reason: 'le code EST connu — c\'est la variante courte qui doit sortir'); + expect(phrase, isNot(contains('50'))); + expect(phrase, isNot(contains('90'))); + }); + + test('réserve batterie — une box sans `withheldW` garde une phrase entière', () { + final phrase = decisionText( + t, + const LoadDecision( + code: 'BATTERY_RESERVE', params: {'socPercent': 47, 'reservePercent': 90}), + ); + expect(phrase, contains('47')); + expect(phrase, contains('90')); + expect(phrase, isNot(contains('BATTERY_RESERVE'))); }); test('la phrase de la réserve nomme la CAUSE, pas l\'effet', () { @@ -274,10 +306,11 @@ void main() { expect(t.decisionBatteryReserveShort, contains('réserve batterie')); }); - test('le crochet du motif est VIDE tant que le code n\'est pas publié', () { - // Y mettre une supposition créerait une branche morte, ou pire une branche qui - // s'activerait sur un homonyme. - expect(kCodesReserveBatterie, isEmpty); + test('le crochet du motif porte le code RÉELLEMENT publié par le plugin', () { + // Source d'autorité : `etm/types/decisionreason.h`, `DecisionCode::BatteryReserve`. + // Un crochet vide ne plantait pas — il rendait le motif en anglais brut, ce qui est + // pire : ça a l'air d'un défaut de la box. + expect(kCodesReserveBatterie, contains('BATTERY_RESERVE')); }); test('un code absent de l\'ARB s\'affiche en repli lisible, sans plantage', () { @@ -325,6 +358,12 @@ void main() { 'SG_FORCED': {'budgetW': 1, 'estimatedW': 3000}, 'SG_RECOMMENDED': {'budgetW': 1, 'estimatedW': 1500}, 'SG_NORMAL': {'budgetW': 1}, + 'BELOW_MIN_POWER': {'budgetW': 149, 'minPowerW': 1500}, + 'BATTERY_RESERVE': { + 'socPercent': 47, + 'reservePercent': 90, + 'withheldW': 3200, + }, 'DEGRADED_L2': {}, 'SAFE_STATE_RELAY': {}, 'SAFE_STATE_SETPOINT': {}, @@ -355,4 +394,124 @@ void main() { contains('futureMechanism')); }); }); + + // ─────────────────────────────────────────────────────────────────────────── + group('3g-1 — `funding`, et les bornes entrent dans loads[]', () { + // Charge utile calquée sur `.75` en `+etm22` : une borne financée au surplus, une + // seconde financée au RÉSEAU par le proxy (échéance de départ). + Map payload() => { + 'degradedMode': false, + 'timestamp': '2026-08-27T05:23:00Z', + 'budget': { + 'surplusW': 2000.0, + 'evReservedW': 3680.0, + 'recreditedW': 0.0, + 'allocatedW': 2500.0, + 'remainingW': -500.0, + }, + 'loads': [ + { + 'loadId': 'chauffe-eau', + 'allocatedW': 2500.0, + 'available': true, + 'funding': 'surplus', + 'mechanism': {'kind': 'relay', 'stageW': 2500.0, 'maxStageW': 3500.0}, + 'decision': { + 'code': 'SURPLUS_SETPOINT', + 'params': {'budgetW': 2559, 'setpointW': 2500, 'stepped': true}, + }, + }, + { + 'loadId': 'borne-echeance', + 'allocatedW': 3680.0, + 'available': true, + 'funding': 'grid', + 'mechanism': { + 'kind': 'evcharger', + 'chargingEnabled': true, + 'currentA': 16.0, + 'phaseCount': 1, + 'pluggedIn': true, + }, + 'decision': {'code': 'EV_DEADLINE', 'params': {}}, + }, + ], + }; + + test('le financement se lit, et son absence n\'est pas un zéro', () { + final tel = LoadTelemetry.fromJson(payload()); + expect(tel.entryFor('chauffe-eau')!.funding, 'surplus'); + expect(tel.entryFor('borne-echeance')!.funding, 'grid'); + + // Omis en mode dégradé — sans plan, il n'y a pas de financement. + final sansPlan = LoadTelemetryEntry.fromJson(const { + 'loadId': 'x', + 'allocatedW': 0.0, + 'available': true, + }); + expect(sansPlan.funding, isNull); + // …et « omis » se replie sur surplus : c'est le seul financement qui existait avant + // ce champ, donc l'identité reste vérifiable sur une box antérieure. + expect(sansPlan.isSurplusFunded, isTrue); + }); + + test('sommer sans filtrer sur `funding` casse l\'identité du budget', () { + final tel = LoadTelemetry.fromJson(payload()); + + // La somme BRUTE compte la borne servie au réseau et ne retombe sur rien. + final brute = tel.loads.fold(0, (a, e) => a + e.allocatedW); + expect(brute, 6180); + expect(brute, isNot(closeTo(tel.budget!.allocatedW, 1))); + + // Filtrée, elle retombe exactement sur `budget.allocatedW` — et la puissance de la + // borne se retrouve, elle, dans `budget.evReservedW`. + expect(tel.surplusAllocatedW, 2500); + expect(tel.surplusIdentityHolds, isTrue); + expect(tel.budget!.evReservedW, 3680); + }); + + test('sans budget, l\'identité ne rend pas « faux » mais « rien à réconcilier »', () { + final degrade = LoadTelemetry.fromJson(const { + 'degradedMode': true, + 'loads': >[], + }); + expect(degrade.surplusIdentityHolds, isNull); + }); + + test('pour une BORNE, l\'allocation n\'est pas une commande — pour l\'ECS, si', () { + final tel = LoadTelemetry.fromJson(payload()); + // La réserve est ciblée : elle ne doit PAS déborder sur les charges pilotées, où + // l'allocation publiée est bien l'ordre émis. + expect(tel.entryFor('chauffe-eau')!.allocationIsCommand, isTrue); + expect(tel.entryFor('borne-echeance')!.allocationIsCommand, isFalse); + }); + + test('l\'état réel de la borne vient du Thing, pas du motif d\'arbitrage', () { + // Le cas relevé sur `.75` : l'arbitre refuse (« 0 W », BELOW_MIN_POWER) et le proxy + // commande ON à 6 A dans le même cycle. Les deux se contredisent, et c'est + // `mechanism` qui dit ce que la borne FAIT. + final borne = LoadTelemetryEntry.fromJson(const { + 'loadId': 'borne-surplus', + 'allocatedW': 0.0, + 'available': true, + 'funding': 'surplus', + 'mechanism': { + 'kind': 'evcharger', + 'chargingEnabled': true, + 'currentA': 6.0, + 'phaseCount': 1, + 'pluggedIn': true, + }, + 'decision': { + 'code': 'BELOW_MIN_POWER', + 'params': {'budgetW': 1096, 'minPowerW': 4140}, + }, + }); + expect(borne.allocatedW, 0); + expect(borne.mechanism!.chargingEnabled, isTrue); + expect(borne.mechanism!.currentA, 6); + expect(borne.allocationIsCommand, isFalse, + reason: 'écrire « la borne ne charge pas » ici serait faux'); + }); + }); }