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