etm-powersync-app/lib/services/telemetry_text.dart
Patrick Schurig a430fb00c8 fix(instants): un seul lecteur d'horodatage de box — et ma cause d'hier était FAUSSE
Le contrat porte désormais la règle : tout horodatage publié est en UTC avec son
marqueur, ou en epoch. Trois champs, deux formats, rien ne l'exigeait.

`instantDeLaBox` (lib/models/box_instant.dart) devient l'unique lecteur pour ce
que la box publie — epoch ou ISO, marqué ou nu — et requalifie la forme nue en
UTC avant de rendre l'heure du lecteur. Politique valable pour la BOX seule :
`Installation.lastSeen` est écrite en local sans marqueur et relue pareil, donc
la lire en UTC la décalerait. Deux héritages opposés sous une même forme, d'où
l'absence délibérée d'un « parser une date » unique.

Le `.toLocal()` posé hier dans telemetry_text est retiré : le modèle est seul
propriétaire de la conversion. Le rejouer serait sans effet mais laisserait
croire qu'elle peut manquer ailleurs.

CORRECTION. J'avais écrit que le défaut avait survécu « parce que box et
téléphone partageaient l'heure ». C'est faux, et la fausseté est utile : l'écart
est entre UTC et l'APPAREIL, donc visible sur tout téléphone hors UTC+0 quel que
soit le fuseau de la box. Mesuré ce matin avec les deux en Europe/Paris — la box
publie 04:33:28Z, l'APK d'hier affiche « 04:33 », il fallait lire 06:33. La
bascule en Europe/London n'a pas donné la visibilité, elle a donné le MOTIF de
regarder. La version fausse fait conclure « les fuseaux coïncident, donc c'est
réglé » et laisse le défaut en place.

`GetVariableLoadState` n'existe pas sur .75 (absente d'Introspect) et l'app ne
lit aucun `since` : rien à corriger de ce côté. Le champ existe en revanche dans
GetLoadTelemetry — `loads[].command {divergent, expectedW, observedW, since}` —
et il porte bien le Z, vérifié à la sonde. Aucun écran ne le consomme encore.

Contrôle négatif de la ligne d'horloge : même binaire qu'hier, box repassée en
Europe/Paris, la ligne a disparu sous le champ d'échéance.
docs/captures/2026-09-07_fuseau-box-paris-ligne-absente.png

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

387 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));
// `measuredSince` arrive en UTC et le MODÈLE le rend déjà dans le fuseau du lecteur —
// `instantDeLaBox`, dans `box_instant.dart`, qui porte l'histoire du défaut et la
// politique sur les chaînes sans marqueur. Un seul propriétaire de la conversion : la
// rejouer ici serait sans effet, mais laisserait croire qu'elle peut manquer ailleurs.
//
// Un INSTANT se rend dans le fuseau du lecteur — contrairement à l'échéance
// d'obligation, qui est un réglage que la box interprète et porte donc sa mention de
// fuseau (cf. `obligationEcheanceHorloge`).
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';
}