etm-powersync-app/lib/services/telemetry_text.dart
Patrick Schurig dee20896c5 feat(télémétrie): lot B app — consommation de la télémétrie d'arbitrage
Le lot plugin B-bis (1.15.2+etm15) est sur .75 et GetLoadConfig/GetLoadTelemetry
concordent : les critères 4 à 8 du brief deviennent atteignables.

- models/load_telemetry.dart : vue en lecture. timestamp/budget/mechanism/lock/
  faultCode nullables — omis porte un sens que zéro ne porterait pas.
- services/telemetry_text.dart : rendu français des CODES (19 motifs, 3 défauts,
  3 verrous, 4 mécanismes). Repli lisible sur un code inconnu ET sur un code connu
  privé d'un paramètre : une phrase trouée a l'air d'une donnée, le code brut a
  l'air de ce qu'il est.
- providers/load_telemetry_provider.dart : instantané + LoadTelemetryChanged,
  fraîcheur à trois signaux (âge du timestamp, silence des trames, timestamp qui
  cesse d'avancer), machine à états de ClearLoadFault qui ne conclut JAMAIS sur
  l'acquittement du RPC.
- load_config_provider : assignDomain() en brouillon, énumération fermée.
- load_order_screen : budget, fraîcheur, mode dégradé, bloc par charge, encart
  défaut, menu de domaine.
- nymea_service : getLoadTelemetry(), clearLoadFault(), aiguillage de la
  notification. Une box sans la méthode s'annonce comme telle, pas comme en panne.

Seuil de fraîcheur à 180 s : le battement de cœur du plugin est de 60 s et le
timestamp avance d'un cycle par minute. Un seuil plus court crierait « données
anciennes » sur une installation vivante.

Fixtures v3 prises sur .75 après déploiement, dont un cycle porteur d'un verrou —
« lock » étant optionnel, un dump pris au mauvais moment ne l'éprouverait pas.

57 tests (30 avant), flutter analyze inchangé à 25 issues.

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

179 lines
6.9 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) {
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);
}
// Code inconnu, ou code connu dont un paramètre attendu manque.
return t.decisionUnknown(d.code.isEmpty ? '—' : d.code, formatParams(p));
}
/// 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();