/// Vue **en lecture** au-dessus de la charge utile de `NymeaEnergy.GetLoadTelemetry`. /// /// ## Ce que ce type n'est pas /// /// Ce n'est **pas** un état que l'app entretient. C'est l'instantané du dernier cycle /// d'arbitrage **publié par la box**, transporté tel quel. Rien n'y est extrapolé entre /// deux trames : ni les secondes restantes d'un verrou (elles décroissent sur la box, et /// le plugin les exclut explicitement de sa détection de changement), ni une allocation /// qui « devrait » avoir bougé. Une valeur affichée ici a été dite par la box. /// /// ## Omis, jamais nul /// /// Trois champs sont optionnels au schéma, et leur **absence porte un sens** que zéro ne /// porterait pas : /// /// - `timestamp` absent = **aucun cycle d'arbitrage depuis le démarrage du service**. En /// forger un se lirait « cycle exécuté, rien à allouer ». /// - `budget` absent = **mode dégradé L2** : la planification est suspendue, il n'existe /// aucun budget pour ce cycle. Publier des zéros se lirait « arbitrage exécuté, rien à /// allouer ». /// - `mechanism` absent = la charge n'a pas publié de charge utile de mécanisme ce /// cycle-là. /// /// D'où l'usage systématique de types nullables plutôt que de valeurs par défaut. /// /// ## Codes, jamais des phrases /// /// `decision.code` et `faultCode` sont des **codes à catalogue fermé** accompagnés de /// paramètres numériques. La box ne compose aucune phrase : la locale nymea est par /// connexion, et `NymeaEnergyJsonHandler` est un `JsonHandler` sans `pluginId`, donc /// structurellement hors du catalogue de traduction. Le rendu est à la charge de l'app /// (`services/telemetry_text.dart`). library; /// Seuil de fraîcheur — au-delà, l'instantané est présenté comme **ancien**. /// /// Calibré sur le battement de cœur du plugin : `LoadTelemetryChanged` est émise à /// chaque changement significatif **et** périodiquement toutes les 60 s même sans /// changement, et le `timestamp` avance d'un cycle par minute (vérifié sur `.75` : /// 12:37 → 12:38 → 12:39). Un seuil ≤ 60 s crierait donc « données anciennes » sur une /// installation parfaitement vivante, au premier battement un peu tardif. /// /// Trois cycles laissent passer un battement manqué et un aller-retour réseau lent, sans /// laisser un arbitre réellement figé passer pour une installation stable plus de trois /// minutes. const Duration kTelemetryStaleAfter = Duration(seconds: 180); /// Décomposition du budget de surplus du cycle. Absent en mode dégradé. class TelemetryBudget { /// Budget entrant du waterfall non-EV : net signé − réservation EV. final double surplusW; /// Puissance EV commandée ce cycle, pas encore vue au compteur. final double evReservedW; /// Recrédits anti-clignotement rendus aux charges avant arrondi. final double recreditedW; /// Somme des allocations du cycle. final double allocatedW; /// Résidu de fin de cascade. final double remainingW; const TelemetryBudget({ required this.surplusW, required this.evReservedW, required this.recreditedW, required this.allocatedW, required this.remainingW, }); factory TelemetryBudget.fromJson(Map m) => TelemetryBudget( surplusW: _d(m['surplusW']), evReservedW: _d(m['evReservedW']), recreditedW: _d(m['recreditedW']), allocatedW: _d(m['allocatedW']), remainingW: _d(m['remainingW']), ); /// Identité que le plugin déclare vérifiable : /// `surplusW + recreditedW − allocatedW == remainingW`. /// /// `recreditedW` n'est pas décoratif : le waterfall rend à chaque charge sa propre /// consommation de début de cycle avant d'arrondir. Sans ce terme, la somme serait /// fausse dès qu'une charge consomme déjà — c'est-à-dire dans le cas normal. /// /// Tolérance 1 W : les valeurs voyagent en `Double`. bool get identityHolds => (surplusW + recreditedW - allocatedW - remainingW).abs() < 1.0; } /// Verrou actif sur une charge. Absent quand aucun verrou ne mord. class LoadLock { /// `minOn` · `minOff` · `minStateHold`. final String kind; /// Secondes restantes **au moment du cycle publié** — jamais décomptées côté app. final int remainingS; const LoadLock({required this.kind, required this.remainingS}); factory LoadLock.fromJson(Map m) => LoadLock( kind: m['kind'] as String? ?? '', remainingS: (m['remainingS'] as num?)?.toInt() ?? 0, ); } /// Union discriminée par `kind` — chaque variante porte sa charge utile, et seulement la /// sienne. Les clés sont toutes optionnelles au schéma (règle LM-302 du plugin) : une /// variante ajoutée par une box plus récente ne doit pas faire échouer la lecture. class LoadMechanism { final Map raw; /// `relay` · `variable` · `sgReady` · `evcharger` — ce dernier émis depuis `+etm22`. final String kind; const LoadMechanism(this.raw, this.kind); factory LoadMechanism.fromJson(Map m) => LoadMechanism(m, m['kind'] as String? ?? ''); bool get isRelay => kind == 'relay'; bool get isVariable => kind == 'variable'; bool get isSgReady => kind == 'sgReady'; bool get isEvCharger => kind == 'evcharger'; // relay double? get stageW => _dn(raw['stageW']); double? get maxStageW => _dn(raw['maxStageW']); /// Plancher de modulation — la consigne non nulle la plus basse que la charge accepte. /// /// **Publié en télémétrie avant d'être configurable** : le champ de configuration /// correspondant n'est pas encore au schéma de `SetLoadConfig`. On le LIT donc, sans /// prétendre le régler — cf. le bloc « Plancher » de l'écran de mécanisme, grisé tant /// que le champ d'écriture n'existe pas. double? get minPowerW => _dn(raw['minPowerW']); /// Paliers atteignables, **publiés par la box** depuis `+etm16`. /// /// Une seule fonction moteur (`RelayRouter::deriveStages`) sert la construction du /// routeur, la comparaison matérielle et cette publication : il n'y a donc plus de /// combinatoire à refaire côté app, ni de divergence possible. /// /// **Elle est en télémétrie, pas dans `GetLoadConfig`** — précisément pour qu'elle ne /// parte jamais dans une écriture : ce sont les relais qui se déclarent, les paliers /// s'en déduisent. /// /// `null` sur une box antérieure, ou sur un mécanisme qui n'en produit pas. List? get stagesW { final v = raw['stagesW']; if (v is! List) return null; return [for (final x in v) if (x is num) x.toInt()]; } // variable double? get setpointW => _dn(raw['setpointW']); double? get percent => _dn(raw['percent']); double? get maxPowerW => _dn(raw['maxPowerW']); // sgReady — `estimatedPowerW` est une ESTIMATION déclarée, jamais un engagement. int? get state => (raw['state'] as num?)?.toInt(); double? get estimatedPowerW => _dn(raw['estimatedPowerW']); // ── evcharger (3g) — l'ÉTAT RÉEL de la borne ──────────────────────────────── // // Ces quatre champs viennent du Thing, pas de l'arbitre. C'est la distinction qui // compte tant que 3g-2 n'est pas livré : `adjustEvChargers()` re-décide le surplus // APRÈS le waterfall et passe outre le refus de l'arbitre. Relevé sur `.75`, deux // lignes du même cycle — l'arbitre publie `BELOW_MIN_POWER` et « 0 W », le proxy // commande `ON` à 6 A une milliseconde plus tard. // // Donc : pour une borne, [LoadTelemetryEntry.allocatedW] et son motif sont une // INTENTION, ces champs-ci sont ce que la borne fait. Les deux peuvent se contredire, // et c'est l'écran qui aurait l'air faux. Cf. [LoadTelemetryEntry.allocationIsCommand]. bool? get chargingEnabled => raw['chargingEnabled'] as bool?; bool? get pluggedIn => raw['pluggedIn'] as bool?; double? get currentA => _dn(raw['currentA']); int? get phaseCount => (raw['phaseCount'] as num?)?.toInt(); } /// Motif de décision : un code du catalogue fermé, et ses paramètres numériques. /// /// `params` ne contient que des nombres et des booléens ; le libellé de la charge n'y /// figure jamais — le client le résout via `GetLoadConfig`. class LoadDecision { final String code; final Map params; const LoadDecision({required this.code, required this.params}); factory LoadDecision.fromJson(Map? m) { if (m == null) return const LoadDecision(code: '', params: {}); final p = m['params']; return LoadDecision( code: m['code'] as String? ?? '', params: p is Map ? Map.from(p) : const {}, ); } double? numParam(String key) => _dn(params[key]); bool? boolParam(String key) => params[key] as bool?; } /// État runtime d'**une** charge arbitrée par le waterfall. class LoadTelemetryEntry { final Map raw; final String loadId; /// Ce que le waterfall a **alloué** — pas une mesure de compteur. final double allocatedW; /// Faux = charge en défaut : aucune commande n'est émise, et le verrou ne se lève pas /// tout seul (`ClearLoadFault` est une action délibérée d'exploitant). final bool available; /// Absent quand la charge est saine. final String? faultCode; /// **Qui paie** cette allocation : `"surplus"` ou `"grid"`. Nouveau en `+etm22`. /// /// Il devient nécessaire du seul fait que les bornes sont entrées dans `loads[]` : deux /// charges peuvent y figurer pour des raisons différentes. /// /// - `"surplus"` — la cascade PV. L'allocation est comptée dans `budget.allocatedW`. /// - `"grid"` — le proxy (échéance de départ, tarif dynamique) qui **soutire au /// réseau**. Elle vit dans `budget.evReservedW`, PAS dans `budget.allocatedW`. /// /// **Sommer les `allocatedW` sans filtrer donne un écart avec `budget.allocatedW` que /// rien ne permet d'interpréter** : défaut du moteur, ou borne servie au réseau ? Cf. /// [LoadTelemetry.surplusAllocatedW]. /// /// `null` = **omis**, jamais « zéro financement » : en mode dégradé il n'y a pas de /// plan, donc pas de financement. Une box antérieure à `+etm22` ne le publie pas non /// plus — d'où le repli explicite de [isSurplusFunded]. final String? funding; /// Puissance **MESURÉE** par le compteur rattaché à cette charge. `null` quand aucun /// compteur ne l'est. /// /// Elle ne remplace pas [allocatedW] et ne s'y substitue jamais : l'une est ce que le /// waterfall a **décidé**, l'autre ce qui **passe réellement**. Les afficher côte à côte /// est tout l'intérêt du rattachement — commandé 3 000 W, mesuré 0 W, la charge ne fait /// pas ce qu'on lui demande, et c'est la seule chose que la mesure autorise à conclure. /// /// **`null` n'est pas 0.** Une charge sans compteur ne publie rien, et afficher un zéro /// à la place ferait passer une absence de mesure pour une absence de consommation. final double? measuredW; final LoadLock? lock; final LoadMechanism? mechanism; final LoadDecision decision; const LoadTelemetryEntry({ required this.raw, required this.loadId, required this.allocatedW, required this.available, required this.faultCode, this.measuredW, this.funding, required this.lock, required this.mechanism, required this.decision, }); factory LoadTelemetryEntry.fromJson(Map m) { final lock = m['lock']; final mech = m['mechanism']; final fault = m['faultCode']; return LoadTelemetryEntry( raw: m, loadId: m['loadId'] as String? ?? '', allocatedW: _d(m['allocatedW']), // Absent au schéma ne peut pas arriver (champ requis) ; en cas de box exotique, // « disponible » est le repli qui n'invente pas un défaut. available: m['available'] as bool? ?? true, faultCode: (fault is String && fault.isNotEmpty) ? fault : null, measuredW: _dn(m['measuredW']), funding: (m['funding'] is String && (m['funding'] as String).isNotEmpty) ? m['funding'] as String : null, lock: lock is Map ? LoadLock.fromJson(Map.from(lock)) : null, mechanism: mech is Map ? LoadMechanism.fromJson(Map.from(mech)) : null, decision: LoadDecision.fromJson( m['decision'] is Map ? Map.from(m['decision'] as Map) : null), ); } bool get inFault => !available; /// Vrai quand l'allocation est **financée par le surplus** — donc comptée dans /// `budget.allocatedW`. /// /// `funding` absent (mode dégradé, ou box antérieure à `+etm22`) est traité comme /// surplus : c'est le seul financement qui existait avant ce champ, et le compter /// laisse l'identité vérifiable sur une box ancienne au lieu de la faire échouer. bool get isSurplusFunded => funding != 'grid'; /// **Faux pour une borne : l'allocation publiée est une INTENTION, pas un ordre.** /// /// `adjustEvChargers()` re-décide le surplus après le waterfall et passe outre le refus /// de l'arbitre — relevé sur `.75`, l'arbitre publie « 0 W / `BELOW_MIN_POWER` » et le /// proxy commande `ON` à 6 A dans le même cycle. Pour une borne, [allocatedW] et /// [decision] sont donc **la décision de l'arbitre**, et l'état réel se lit dans /// `mechanism.chargingEnabled` / `mechanism.currentA`, qui viennent du Thing. /// /// Écrire « la borne ne charge pas parce que le budget est sous le plancher » à partir /// du seul motif est faux aujourd'hui, et c'est l'écran qui aurait l'air faux. /// /// La réserve **ne vaut pas** pour l'ECS, la PAC et les autres charges pilotées : là, /// l'allocation publiée **est** la commande. /// /// Elle tombe avec le lot plugin 3g-2 (« l'arbitre commande seul »). Le jour où il est /// déployé, ce getter rend `true` partout et les branches qui le lisent disparaissent. bool get allocationIsCommand => mechanism?.isEvCharger != true; } /// Instantané complet de l'arbitrage. class LoadTelemetry { final Map raw; /// `true` = watchdog L2 en repli (compteur muet > 90 s, planification suspendue). final bool degradedMode; /// Fin du **dernier cycle d'arbitrage**. `null` = aucun cycle depuis le démarrage du /// service : c'est la preuve de fraîcheur, et son absence est une information. final DateTime? timestamp; /// `null` en mode dégradé — il n'existe alors aucun budget pour ce cycle. final TelemetryBudget? budget; /// Les charges arbitrées, **bornes de recharge comprises depuis `+etm22`**. /// /// Jusque-là `buildTelemetry()` sautait les bornes : elles étaient arbitrées, leurs /// décisions allaient au journal du plugin, et l'écran ne pouvait pas les voir. Deux /// conséquences pour un client : /// /// - une borne peut être là au titre du surplus **ou** au titre du réseau (échéance, /// tarif dynamique) — d'où [LoadTelemetryEntry.funding] ; /// - pour une borne, l'allocation publiée n'est **pas** un ordre tant que 3g-2 n'est pas /// livré — d'où [LoadTelemetryEntry.allocationIsCommand]. /// /// Depuis le lot plugin B-bis, tout `loadId` publié ici a une entrée dans /// `GetLoadConfig` — l'inverse n'est pas vrai : une charge configurée absente d'ici est /// **désactivée**, jamais perdue. final List loads; const LoadTelemetry({ required this.raw, required this.degradedMode, required this.timestamp, required this.budget, required this.loads, }); factory LoadTelemetry.fromJson(Map m) { final ts = m['timestamp']; final budget = m['budget']; final loads = m['loads']; return LoadTelemetry( raw: m, degradedMode: m['degradedMode'] as bool? ?? false, timestamp: (ts is String && ts.isNotEmpty) ? DateTime.tryParse(ts) : null, budget: budget is Map ? TelemetryBudget.fromJson(Map.from(budget)) : null, loads: loads is List ? [ for (final l in loads.whereType()) LoadTelemetryEntry.fromJson(Map.from(l)), ] : const [], ); } /// `true` tant qu'aucun cycle d'arbitrage n'a eu lieu depuis le démarrage du service. bool get hasNeverRun => timestamp == null; /// Somme des allocations **financées par le surplus**. /// /// C'est le seul terme comparable à `budget.allocatedW` : une borne servie au réseau /// est comptée dans `budget.evReservedW`, et l'inclure ici creuserait un écart que rien /// ne permettrait d'interpréter — défaut du moteur, ou borne au réseau ? double get surplusAllocatedW { var total = 0.0; for (final e in loads) { if (e.isSurplusFunded) total += e.allocatedW; } return total; } /// L'identité que le plugin déclare vérifiable depuis `+etm22` : /// **somme des `allocatedW` financés au surplus == `budget.allocatedW`**. /// /// `null` en mode dégradé — sans budget, il n'y a rien à réconcilier, et rendre `false` /// se lirait « le moteur se contredit » là où il n'a simplement pas planifié. /// /// Tolérance 1 W : les valeurs voyagent en `Double`. bool? get surplusIdentityHolds { final b = budget; if (b == null) return null; return (surplusAllocatedW - b.allocatedW).abs() < 1.0; } LoadTelemetryEntry? entryFor(String loadId) { for (final e in loads) { if (e.loadId == loadId) return e; } return null; } /// Âge du dernier cycle publié, mesuré contre l'horloge locale. /// /// `null` si aucun cycle n'a eu lieu. **Négatif ramené à zéro** : l'horloge de la box /// et celle du téléphone ne sont pas synchronisées, et un âge négatif se lirait comme /// une donnée du futur. Duration? ageAt(DateTime now) { final ts = timestamp; if (ts == null) return null; final d = now.toUtc().difference(ts.toUtc()); return d.isNegative ? Duration.zero : d; } } double _d(Object? v) => (v as num?)?.toDouble() ?? 0.0; double? _dn(Object? v) => (v as num?)?.toDouble();