feat(3g): BATTERY_RESERVE branché, funding lu, et l'état d'une borne passe devant

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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016vrTVifar2GN5rtUh89Wkq
This commit is contained in:
Patrick Schurig 2026-08-27 07:38:48 +02:00
parent e84f59d04a
commit a1455290c9
7 changed files with 525 additions and 53 deletions

View File

@ -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" }

View File

@ -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.'**

View File

@ -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.';

View File

@ -112,8 +112,7 @@ class LoadLock {
class LoadMechanism {
final Map<String, dynamic> 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<String, dynamic>.from(lock)) : null,
mechanism: mech is Map
? LoadMechanism.fromJson(Map<String, dynamic>.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;

View File

@ -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<String> kCodesReserveBatterie = <String>{};
/// 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<String> kCodesReserveBatterie = <String>{'BATTERY_RESERVE'};
/// Rendu déterministe des paramètres bruts, pour le repli.
///

View File

@ -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();

View File

@ -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<String, dynamic> 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<double>(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': <Map<String, dynamic>>[],
});
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');
});
});
}