/// 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 kCodesReserveBatterie = {'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 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();