etm-powersync-app/lib/services/telemetry_text.dart
Patrick Schurig a1455290c9 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
2026-08-27 07:38:48 +02:00

244 lines
11 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.
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();