Premier lot d'écran, sur des champs tous posés (+etm55) et tous vus à la sonde. L'allocation reste le parent ; les niveaux en sont la décomposition. Une charge reçoit UNE commande par cycle quelles que soient les passes (LM-1003) : deux lignes décrivent une seule décision. - `EcoProgress` — regime, targetWh, deliveredWh (absent sous `unmeasurable`, jamais 0), purchasedWh (publié dans TOUS les régimes : c'est la commande du moteur, donc un fait connu sans mesure), measuredSince, measuredGaps ; - `LoadLevel` gagne `decision` et `progress` : chaque passe porte son motif. Lire la racine seule afficherait « rien fait » sur une charge qui achète — relevé du 2026-09-01 05:47, `SURPLUS_INSUFFICIENT` en racine et 767 W achetés au niveau éco ; - `ecoDecisionText` / `ecoProgressText` : les phrases des trois motifs éco et de leurs quatre causes. Aucun nom de paramètre n'est deviné — `cause`, `missingWh`, les quatre valeurs et `measuredGaps` sont vérifiés dans le binaire posé. Seule la FORME de `measuredGaps` reste non observée, donc on n'en lit que la présence ; - garde R6 à l'écran : `ECO_FLOOR_MET` sous `unmeasurable` est DÉNONCÉ comme une incohérence de la box, pas rendu. Servie oui, tenue jamais. Deux interdits écrits dans le code, pas seulement dans la maquette : sous `unmeasurable` on n'annonce pas que l'achat va s'intensifier (le taux est constant depuis +etm55), et `atMaximum: false` n'y veut pas dire « on n'est pas au plafond » mais « il n'y a aucun étalement, donc rien qui puisse cesser ». Un booléen à false se lit spontanément comme rassurant. Les charges utiles des tests viennent de `.75` — le cas éco-servi/confort-en- attente du 2026-09-01, le cas mesuré du 2026-09-04 — pas d'une lecture de contrat. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GKM8FDNHZWogAsjjfB8pSw
363 lines
17 KiB
Dart
363 lines
17 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;
|
|
final base = t.ecoAvancementMesure(energieCourte(livre), energieCourte(p.targetWh));
|
|
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.
|
|
return '$base · ${p.parIntermittence ? t.ecoMesureIntermittente(h) : t.ecoMesureDepuis(h)}';
|
|
}
|