etm-powersync-app/lib/services/telemetry_text.dart
Patrick Schurig 24822ce30b feat(3g-2): la mise en garde borne tombe, sans devenir une certitude trop large
+etm23 supprime le second commandeur : adjustEvChargers() ne commande plus, l'arbitre est
seul (9 commandes à la borne, 9 par l'arbitre, 0 par le proxy). allocationIsCommand est donc
retiré, avec les branches qui en dépendaient — l'étiquette « l'arbitre a décidé X W » et sa
ligne d'avertissement redeviennent « Alloué X W », et l'écart mesuré/commandé se dit à
nouveau sur une borne.

Ce que le getter est remplacé par : rien, délibérément. Le garder à true partout inviterait
à lire « commande » comme « réalité ». Car ce qui devient vrai, c'est que personne d'autre
ne commande — pas que la borne fait ce qu'on lui dit. Un plafond matériel, un véhicule qui
refuse, un câble débranché feront toujours diverger les deux. La borne garde donc sa ligne
d'état matériel, non plus parce qu'un autre commandeur la contredirait, mais parce que
mechanism (chargingEnabled, currentA, pluggedIn) est le seul endroit où cet écart se lit.

Deux motifs neufs : EV_GRID_START {budgetW, floorW, gridW} et PHASE_LIMIT {limitW,
requiredW}. Le premier mène par les watts ACHETÉS — c'est ce chiffre qui fait de la ligne
une décision et non un effet de bord. Le second doit écarter le surplus explicitement :
deux causes, deux gestes, et confondre les deux envoie chercher du soleil là où il faut
lire un compteur. EV_GRID_START n'a JAMAIS été vu sur machine : la clé est prête, rien
n'est bâti autour.

Et une correction que le brief signale en passant, qui aurait cassé en silence la
réconciliation posée hier : EV_GRID_START est le seul motif dont l'allocation se PARTAGE
entre les deux compteurs du budget — budgetW au surplus, gridW au réseau, budgetW + gridW
== allocatedW. Filtrer sur funding == "surplus" comptait cette ligne pour zéro et creusait
un trou de la taille de budgetW. surplusShareW la traite à part ; sans budgetW il rend zéro
plutôt qu'une part devinée, un chiffre faux étant pire qu'un chiffre manquant dans un
contrôle d'identité.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016vrTVifar2GN5rtUh89Wkq
2026-08-27 14:33:25 +02:00

273 lines
12 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();