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
179 lines
6.9 KiB
Dart
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();
|