`batteryLevelConsideration` ne gouverne aujourd'hui que la recharge du véhicule. Une fois la règle montée dans le budget, il gouvernera TOUTE l'installation : sous le seuil le budget de surplus est annulé — pas réduit — donc ni ECS, ni PAC, ni bornes. Le défaut d'usine est 0,9. Le banc `.75` est exactement dans ce cas : seuil 0,9, batterie à 50 %. Le symptôme chez un client serait le pire qui soit — un HEMS qui ne pilote plus rien par grand soleil, sans erreur, sans panne, rien à lire dans un journal. Un paramètre qui peut produire ça n'est pas un paramètre d'usine. - `BatteryReserveCard` lit et écrit le réglage, et ALERTE quand le seuil dépasse le SOC réellement atteint — le cas silencieux. Elle n'invente aucune valeur par défaut : si la box ne l'expose pas, la carte se tait plutôt que d'afficher un curseur mort. - La portée future est dite sur la carte : régler ce seuil « pour la voiture » couperait le reste demain. - Clé ARB `decisionBatteryReserve` posée, et `kCodesReserveBatterie` volontairement VIDE côté `telemetry_text` : le crochet attend le code que publiera le moteur, on ne devine pas son nom. Vérifié sur appareil contre `.75` : réglage lu (0.9 / SOC 50 %), carte affichée, campagne verte. L'assertion des cartes de charge fait désormais DÉFILER avant de compter — dans un `ListView` ce qui n'est pas à l'écran n'est pas construit, et chercher sans défiler ne prouvait que la hauteur de l'écran. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HajXLUczEyZd22JeewRfff
228 lines
9.7 KiB
Dart
228 lines
9.7 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);
|
|
|
|
// « 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.
|
|
// ── Réserve batterie : préparé, pas encore branché ──────────────────────
|
|
//
|
|
// Le motif viendra quand la règle montera dans le budget. Son CODE n'est pas tranché
|
|
// côté plugin, donc le deviner reviendrait à écrire une branche morte qui ne
|
|
// s'activerait jamais — ou pire, à en activer une sur un code homonyme.
|
|
//
|
|
// Ce qui est prêt, c'est la PHRASE, et c'est la partie difficile : elle doit nommer la
|
|
// cause (« une règle interdit de distribuer ») et non décrire l'effet (« rien n'est
|
|
// piloté »), sans quoi elle se confond avec « pas de surplus ». Le jour où le code est
|
|
// publié, il n'y a qu'à l'ajouter à [kCodesReserveBatterie].
|
|
//
|
|
// En attendant, le repli fait exactement son travail : un code inconnu sort en clair
|
|
// avec ses paramètres, ce qui ne peut PAS se lire comme « aucun surplus ». C'est
|
|
// vérifié par un test plutôt qu'espéré.
|
|
case _ when kCodesReserveBatterie.contains(d.code):
|
|
final soc = _i(d, 'batteryLevel') ?? _i(d, 'soc');
|
|
final seuil = _i(d, 'threshold') ?? _i(d, 'batteryLevelConsideration');
|
|
return (soc == null || seuil == null)
|
|
? t.decisionBatteryReserveShort
|
|
: t.decisionBatteryReserve(soc, seuil);
|
|
|
|
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 que le plugin publiera pour « budget annulé par la réserve batterie ».
|
|
///
|
|
/// **Volontairement vide.** Le nom n'est pas arrêté côté plugin ; y mettre une supposition
|
|
/// créerait soit une branche morte, soit — pire — une branche qui s'activerait sur un
|
|
/// homonyme. Le jour où le code est publié, une seule ligne suffit ici, et la phrase est
|
|
/// déjà écrite et relue.
|
|
const Set<String> kCodesReserveBatterie = <String>{};
|
|
|
|
/// 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();
|