etm-powersync-app/lib/services/telemetry_text.dart
Patrick Schurig 42eef05c3b fix(carte): le parent domine, et la phrase éco cesse de se répéter
Deuxième passage sur appareil, écran des charges. Trois défauts, dont deux de
notre fait et un que les fixtures ne pouvaient pas produire.

1. LA FRACTION ÉTAIT ÉCRITE DEUX FOIS — `ECO_FLOOR_MET` porte déjà livré et
   cible dans son motif, et la ligne d'avancement les réécrivait dessous :
   « Plancher atteint — 29,9 kWh sur 3,0 kWh livrés » puis « 29,9 kWh sur 3,0
   kWh livrés · mesuré depuis 10:00 ». C'est le défaut de la ligne des ratios de
   la veille, refait à l'identique : déclencher sur ce qu'on SAIT plutôt que sur
   ce qui se VOIT. La ligne ne garde plus que ce que le motif ne dit pas.

2. « X SUR Y » N'EST PAS UNE FRACTION une fois le plancher dépassé — le compteur
   continue de courir après que l'obligation est tenue, et le numérateur finit à
   dix fois le dénominateur. Aucune fixture ne le montrait : toutes avaient un
   livré inférieur à la cible. Reformulé « 3,0 kWh dus, 30,2 kWh livrés ».

3. LE PARENT NE DOMINAIT PAS — à taille et graisse égales, l'allocation et les
   deux niveaux se lisaient comme trois lignes sœurs, et les pastilles colorées
   tiraient l'œil davantage que la commande qu'elles expliquent : le contresens
   exact que la forme doit empêcher (LM-1003). L'allocation passe en 13,5 gras,
   les pastilles perdent leur fond, et un filet vertical rattache chaque niveau
   à la commande.

Vérifié à l'écran après correction, et le repli l'était déjà : V2C et Simulated
wallbox tiennent sur une seule ligne, sans étiquette orpheline ni vide résiduel.

Et le signe du solaire est CONFIRMÉ, ce qui n'avait pas pu l'être hier faute de
production : la box publie -4 376 W, l'écran affiche « 4.4 kW ».

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GKM8FDNHZWogAsjjfB8pSw
2026-09-06 08:11:22 +02:00

379 lines
18 KiB
Dart

/// Rendu en français des **codes** de la télémétrie d'arbitrage.
///
/// La box transporte un code et des paramètres numériques ; la phrase est fabriquée ici.
/// Ce n'est pas une préférence de style : la locale nymea est **par connexion** (deux
/// utilisateurs d'une même box peuvent avoir deux langues), et le handler qui émet ces
/// codes est un `JsonHandler` sans `pluginId` — structurellement hors du catalogue de
/// traduction nymea. Une phrase composée en C++ ne pourrait servir personne correctement.
///
/// ## La règle qui gouverne ce fichier
///
/// **Un code que cette version d'app ne connaît pas s'affiche en repli lisible — jamais
/// une phrase vide, jamais un plantage.** Une box en avance sur l'app est un cas normal
/// en parc déployé : le paquet du plugin se met à jour par `apt`, l'app par le magasin.
///
/// Le repli vaut aussi pour un code **connu dont un paramètre manque** : composer
/// « Consigne de W servie par le surplus » serait pire qu'afficher le code brut, parce
/// que la phrase trouée a l'air d'une donnée alors que le repli a l'air de ce qu'il est.
library;
import '../l10n/app_localizations.dart';
import '../models/load_telemetry.dart';
/// Phrase de décision, ou repli explicite.
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':
return t.decisionEvSpotMarket;
case 'EV_DEADLINE':
return t.decisionEvDeadline;
case 'EV_ECO_MIN':
return t.decisionEvEcoMin;
case 'EV_IDLE':
return t.decisionEvIdle;
case 'DEGRADED_L2':
return t.decisionDegradedL2;
case 'SAFE_STATE_RELAY':
return t.decisionSafeStateRelay;
case 'SAFE_STATE_SETPOINT':
return t.decisionSafeStateSetpoint;
case 'SAFE_STATE_SG_READY':
return t.decisionSafeStateSgReady;
case 'LOAD_UNAVAILABLE':
final frozen = _w(d, 'frozenW');
if (frozen == null) break;
return t.decisionLoadUnavailable(frozen);
case 'LOCK_MIN_ON':
final applied = _w(d, 'appliedW');
final budget = _w(d, 'budgetW');
if (applied == null || budget == null) break;
return t.decisionLockMinOn(applied, budget);
case 'LOCK_MIN_OFF':
final requested = _w(d, 'requestedW');
final budget = _w(d, 'budgetW');
if (requested == null || budget == null) break;
return t.decisionLockMinOff(requested, budget);
case 'LOCK_MIN_OFF_CAPPED':
final applied = _w(d, 'appliedW');
final requested = _w(d, 'requestedW');
final budget = _w(d, 'budgetW');
if (applied == null || requested == null || budget == null) break;
return t.decisionLockMinOffCapped(applied, requested, budget);
case 'SURPLUS_SETPOINT':
final setpoint = _w(d, 'setpointW');
final budget = _w(d, 'budgetW');
if (setpoint == null || budget == null) break;
// `stepped` distingue une consigne servie telle quelle d'une consigne arrondie sur
// un palier déclaré. Absent, on ne prétend ni l'un ni l'autre : la variante neutre.
return d.boolParam('stepped') == true
? t.decisionSurplusSetpointStepped(setpoint, budget)
: t.decisionSurplusSetpoint(setpoint, budget);
case 'SURPLUS_INSUFFICIENT':
final budget = _w(d, 'budgetW');
if (budget == null) break;
return t.decisionSurplusInsufficient(budget);
case 'LOCK_MIN_STATE_HOLD':
final state = _i(d, 'state');
if (state == null) break;
return t.decisionLockMinStateHold(state);
case 'SG_FORCED':
final estimated = _w(d, 'estimatedW');
final budget = _w(d, 'budgetW');
if (estimated == null || budget == null) break;
return t.decisionSgForced(estimated, budget);
case 'SG_RECOMMENDED':
final estimated = _w(d, 'estimatedW');
final budget = _w(d, 'budgetW');
if (estimated == null || budget == null) break;
return t.decisionSgRecommended(estimated, budget);
case 'SG_NORMAL':
final budget = _w(d, 'budgetW');
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
// sortent désormais que pour un budget ≤ 0 — « il n'y a rien ». Ici il y a de quoi,
// simplement pas assez pour le plus petit cran commandable : la charge ne prend rien
// et le budget file à la suivante. Les confondre ferait chercher un défaut de
// production là où il n'y a qu'une granularité.
//
// 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.
// ── Démarrage soutiré (3g-2) ────────────────────────────────────────────
//
// « Il y a du surplus, pas assez pour le plancher, et un réglage autorise à acheter
// la différence. » C'est une décision, pas un effet de bord — d'où `gridW` en tête de
// phrase : c'est le nombre de watts ACHETÉS, le seul qui distingue cette ligne d'un
// démarrage ordinaire.
//
// C'est aussi la seule ligne dont l'allocation se partage entre les deux compteurs du
// budget : `budgetW` au surplus, `gridW` au réseau. Cf. `surplusShareW`.
//
// **Jamais vu sur machine au 2026-08-27** — guetté six cycles au banc, le budget est
// passé de 385 W à 2 350 W sans s'arrêter dans la fenêtre de tolérance. La clé est
// prête ; rien n'est bâti autour.
case 'EV_GRID_START':
final grid = _w(d, 'gridW');
final budget = _w(d, 'budgetW');
final floor = _w(d, 'floorW');
if (grid == null || budget == null || floor == null) break;
return t.decisionEvGridStart(grid, budget, floor);
// C'est la LIMITE DE PHASE qui interdit, pas le budget. Deux causes, deux gestes chez
// le client : l'une se règle en délestant ou en revoyant l'abonnement, l'autre
// s'attend. Les confondre envoie chercher du soleil là où il faut lire un compteur.
case 'PHASE_LIMIT':
final limit = _w(d, 'limitW');
final requis = _w(d, 'requiredW');
if (limit == null || requis == null) break;
return t.decisionPhaseLimit(limit, requis);
case 'BELOW_MIN_POWER':
final budget = _w(d, 'budgetW');
final min = _w(d, 'minPowerW');
if (budget == null || min == null) break;
final state = _i(d, 'state');
return state == null
? t.decisionBelowMinPower(budget, min)
: t.decisionBelowMinPowerState(budget, state, min);
}
// Code inconnu, ou code connu dont un paramètre attendu manque.
return t.decisionUnknown(d.code.isEmpty ? '—' : d.code, formatParams(p));
}
/// Codes du plugin pour « budget annulé par la réserve batterie ».
///
/// 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.
///
/// Trié par clé : deux affichages du même motif doivent être identiques, sinon l'écran
/// « bouge » d'une trame à l'autre sans qu'il se soit rien passé.
String formatParams(Map<String, dynamic> params) {
if (params.isEmpty) return '';
final keys = params.keys.toList()..sort();
final body = [for (final k in keys) '$k = ${params[k]}'].join(', ');
return ' ($body)';
}
/// Phrase de défaut, ou repli sur le code brut.
String faultText(L10n t, String code) => switch (code) {
'WRITE_FAILED' => t.faultWriteFailed,
'THING_MISSING' => t.faultThingMissing,
'UNUSABLE_ENCODING' => t.faultUnusableEncoding,
_ => t.faultUnknown(code),
};
/// Verrou actif. Les secondes affichées sont celles **du cycle publié** : l'app ne les
/// décompte pas localement, parce que le plugin exclut volontairement ce champ de sa
/// détection de changement — un décompte local serait une extrapolation présentée comme
/// une mesure.
String lockText(L10n t, LoadLock lock) => switch (lock.kind) {
'minOn' => t.lockMinOn(lock.remainingS),
'minOff' => t.lockMinOff(lock.remainingS),
'minStateHold' => t.lockMinStateHold(lock.remainingS),
_ => t.lockUnknown(lock.kind, lock.remainingS),
};
/// État du mécanisme. `null` quand la variante ne porte pas de quoi composer une phrase —
/// l'appelant n'affiche alors rien plutôt qu'une ligne creuse.
String? mechanismText(L10n t, LoadMechanism m) {
switch (m.kind) {
case 'relay':
final stage = m.stageW;
final max = m.maxStageW;
if (stage == null || max == null) break;
return t.mechanismRelayStage(stage.round(), max.round());
case 'variable':
final setpoint = m.setpointW;
final percent = m.percent;
if (setpoint == null || percent == null) break;
return t.mechanismVariableSetpoint(setpoint.round(), percent.round());
case 'sgReady':
final state = m.state;
final estimated = m.estimatedPowerW;
if (state == null || estimated == null) break;
return t.mechanismSgReadyState(state, estimated.round());
case 'evcharger':
final current = m.currentA;
final phases = m.phaseCount;
if (current == null || phases == null) break;
return t.mechanismEvCharger(current, phases);
}
return m.kind.isEmpty ? null : t.mechanismUnknown(m.kind);
}
/// Paramètre en watts, arrondi. `null` si absent ou non numérique — c'est ce qui
/// déclenche le repli plutôt qu'une phrase trouée.
int? _w(LoadDecision d, String key) => d.numParam(key)?.round();
int? _i(LoadDecision d, String key) => d.numParam(key)?.toInt();
// ═══════════════════════════════════════════════════════════════════════════════
// NIVEAUX ÉCO / CONFORT — §10
// ═══════════════════════════════════════════════════════════════════════════════
/// Wh → « 2,1 kWh », ou « 850 Wh » sous le kilo.
///
/// Une obligation se déclare en kWh et se sert en centaines de watts : afficher 2 100 Wh
/// ferait compter les zéros à chaque lecture.
String energieCourte(double wh) {
if (wh.abs() < 1000) return '${wh.round()} Wh';
return '${(wh / 1000).toStringAsFixed(1).replaceAll('.', ',')} kWh';
}
/// La phrase d'un **niveau éco**. Rendue depuis le motif du NIVEAU, jamais celui de la charge.
///
/// Le 2026-09-01 à 05:47, le motif racine valait `SURPLUS_INSUFFICIENT` pendant que le niveau
/// éco achetait 767 W au réseau : lire la racine ici afficherait « rien fait » sur une charge
/// en train d'acheter.
String ecoDecisionText(L10n t, LoadDecision d) {
switch (d.code) {
case 'ECO_FLOOR_GRID':
final grid = _w(d, 'gridW');
if (grid == null) break;
return t.decisionEcoFloorGrid(grid);
// Le seul état qui FERME l'obligation — et R6 l'interdit sous `unmeasurable`, des deux
// côtés de la frontière. S'il sortait là, ce serait une incohérence de la box, pas un
// avancement : c'est pourquoi l'appelant vérifie le régime avant de croire cette ligne.
case 'ECO_FLOOR_MET':
final livre = d.numParam('deliveredWh');
final cible = d.numParam('targetWh');
if (livre == null || cible == null) break;
return t.decisionEcoFloorMet(energieCourte(livre), energieCourte(cible));
// Quatre causes, quatre gestes. Pas de « surplus insuffisant » : l'éco achète au réseau,
// donc le surplus ne peut jamais être ce qui l'empêche.
case 'ECO_FLOOR_MISSED':
final raison = switch (d.params['cause']) {
'drawCap' => t.ecoCauseDrawCap,
'loadCapacity' => t.ecoCauseLoadCapacity,
'unmeasurable' => t.ecoCauseUnmeasurable,
'unavailable' => t.ecoCauseUnavailable,
_ => null,
};
if (raison == null) break;
// `missingWh` est ABSENT sous `unmeasurable`, et c'est voulu : on ne sait pas ce qui a
// été livré, donc pas ce qui manquait. Ne rien ajouter alors.
final manque = d.numParam('missingWh');
return t.decisionEcoFloorMissed(raison) +
(manque == null ? '' : t.ecoManque(energieCourte(manque)));
}
return t.decisionUnknown(d.code.isEmpty ? '—' : d.code, formatParams(d.params));
}
/// La ligne d'avancement d'un niveau éco — ce qui a été livré, ou pourquoi on ne le sait pas.
///
/// ⚠️ **Sous `unmeasurable`, deux choses ne doivent PAS être dites**, et elles sont au
/// contrat depuis `+etm55` :
///
/// 1. **que l'achat va s'intensifier** — il n'y a plus d'escalade, le taux est constant
/// (cible ÷ jour, LM-1013-b-v) ;
/// 2. **que `atMaximum: false` veut dire « on n'est pas au plafond »** — il veut dire qu'il
/// n'y a **aucun étalement**, donc rien qui puisse cesser. Un booléen à `false` se lit
/// spontanément comme rassurant : c'est la nuance qu'un lecteur pressé inversera, et c'est
/// pour ça qu'aucune phrase d'ici ne le rend tel quel.
String? ecoProgressText(L10n t, LoadLevel niveau) {
final p = niveau.progress;
if (p == null) return null;
final d = niveau.decision;
if (p.nonMesurable) {
// `remainingWh == targetWh` sous ce régime : rien n'est soustrait. « 4,0 sur 4,0 » se lit
// « rien de MESURABLE », jamais « rien livré » — d'où une phrase et non une fraction.
final reste = d?.numParam('remainingWh') ?? p.targetWh;
return '${t.ecoAvancementNonVerifiable(energieCourte(reste))} · ${t.ecoTauxConstant}';
}
final livre = p.deliveredWh;
if (livre == null) return null;
// ⚠️ **`ECO_FLOOR_MET` porte DÉJÀ le livré et la cible dans son motif.** Répéter la même
// phrase juste dessous donnait, sur appareil le 2026-09-06 :
//
// ÉCO Plancher atteint — 29,9 kWh sur 3,0 kWh livrés
// 29,9 kWh sur 3,0 kWh livrés · mesuré depuis 10:00
//
// Deux fois les mêmes chiffres, et le lecteur cherche ce que la seconde ligne ajoute.
// C'est le même défaut que la ligne des ratios instantanés du 2026-09-05 : je déclenche
// sur ce que je SAIS (il y a un avancement) plutôt que sur ce qui se VOIT (est-il déjà
// dit ?). La ligne ne garde donc que ce que le motif ne dit pas — l'origine de la mesure.
final dejaDit = d?.code == 'ECO_FLOOR_MET';
final base = dejaDit
? null
: t.ecoAvancementMesure(energieCourte(livre), energieCourte(p.targetWh));
final depuis = p.measuredSince;
if (depuis == null) return base;
final h = '${depuis.hour.toString().padLeft(2, '0')}:'
'${depuis.minute.toString().padLeft(2, '0')}';
// La date reste le PREMIER instant mesuré ; l'intermittence se dit à part. Après une
// bascule en cours de période, `deliveredWh` ne compte que depuis elle pendant que la cible
// reste entière — la paire n'est donc plus une fraction, et c'est cette date qui l'explique.
final origine =
p.parIntermittence ? t.ecoMesureIntermittente(h) : t.ecoMesureDepuis(h);
return base == null ? origine : '$base · $origine';
}