/// 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']); /// ⚠️ **Ne PAS lire `chargeEnergy` comme une énergie de session.** /// /// Ce champ n'est pas au contrat de télémétrie — il vit sur le Thing — et cette note est /// ici pour qu'on ne soit pas tenté de l'y faire entrer. Mesuré sur la V2C Trydan de /// `.75` le 2026-08-28, cycle complet : la valeur est **exacte pendant la charge** (sa /// croissance recoupe `currentPower` à mieux d'un pour cent, deux mesures de suite), /// mais elle est **remise à zéro à l'ARRÊT de la charge** — pas au débranchement. /// /// Relevé : 0,3456 kWh → 0 dès que `charging` passe à faux, **119 secondes avant** que le /// câble ne bouge. Sur du pilotage par surplus, la charge s'interrompt à chaque nuage : /// le compteur repartirait de zéro plusieurs fois par jour, et après coup une nuit /// entière vaudrait 0. /// /// Afficher ce zéro dirait « rien livré » là où la vérité est « pas mesurable » — les /// deux états que la condition C1 de LM-1009 exige de distinguer. **Le plugin V2C /// publiera un `sessionEnergy` construit ; attendre celui-là**, et n'accumuler rien /// côté app en attendant. /// /// **Relevé de suivi, 2026-08-28 après-midi** — deux des trois attentes sont levées, la /// troisième ne l'est pas : /// /// - le renommage **est déployé** : la V2C publie `currentL1/L2/L3` (5,86 / 5,94 / /// 6,02 A), et `powerL1/L2/L3` a disparu. Il n'y a donc plus de conversion à ne pas /// écrire — mais toujours aucune raison d'afficher des ampères par phase avant qu'un /// écran ne les demande ; /// - `phaseCount` **existe** sur la Trydan (valeur 3) : la note qui le disait absent de /// la carte Modbus était en retard sur la machine ; /// - `sessionEnergy` **existe aussi** (4,229 kWh) — mais il valait **exactement** /// `chargeEnergy` au moment du relevé. L'égalité ne prouve rien : c'est justement ce /// qu'on attend d'une session sans interruption. **Tant qu'une remise à zéro de /// `chargeEnergy` n'a pas été observée avec un `sessionEnergy` qui, lui, continue, /// la condition C1 de LM-1009 reste entière** : ne pas afficher d'avancement. // ── evcharger (3g) — l'ÉTAT RÉEL de la borne ──────────────────────────────── // // Ces quatre champs viennent du Thing ; `allocatedW` vient de l'arbitre. Depuis `+etm23` // l'arbitre est le seul à commander la borne (le double commandeur de `+etm22` est // supprimé), mais **commander n'est pas obtenir** : un plafond matériel, un véhicule qui // refuse, un câble débranché font diverger les deux. C'est ici que l'écart se lit, pas // dans l'allocation. 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'; /// Part de [allocatedW] qui a été **payée par le surplus**, donc comptée dans /// `budget.allocatedW`. /// /// Presque toujours tout ou rien. **Une seule décision partage une allocation entre les /// deux compteurs du budget** : `EV_GRID_START` — la borne démarre alors que le surplus /// ne paie pas son plancher, et `acquisitionTolerance` autorise l'appoint. Le motif /// porte `budgetW` (venu du surplus) et `gridW` (acheté au réseau), et le plugin /// garantit `budgetW + gridW == allocatedW`. /// /// Compter cette ligne comme entièrement « réseau » creuserait un trou de la taille de /// `budgetW` dans la réconciliation ; la compter entièrement « surplus » attribuerait au /// soleil une puissance achetée. Ni l'un ni l'autre ne se voit à l'œil : c'est une /// réconciliation qui échoue sans dire pourquoi. /// /// Si le motif est là mais `budgetW` absent, on retombe sur zéro plutôt que sur une /// part devinée — un chiffre faux est pire qu'un chiffre manquant dans un contrôle /// d'identité. double get surplusShareW { if (isSurplusFunded) return allocatedW; if (decision.code == 'EV_GRID_START') return decision.numParam('budgetW') ?? 0.0; return 0.0; } /// **[allocatedW] est ce qui a été COMMANDÉ. Ce n'est pas ce que la charge tire.** /// /// La distinction a changé de nature avec le lot plugin 3g-2, et il faut la reprendre /// exactement, sans l'élargir. /// /// Jusqu'à `+etm22`, une borne avait **deux commandeurs** : l'arbitre décidait et /// publiait, puis `adjustEvChargers()` re-décidait derrière lui et commandait autre /// chose. Ce que la télémétrie publiait n'atteignait pas le matériel. `+etm23` ferme /// ça — une charge, un commandeur ; mesuré sur machine, 9 commandes émises à la borne, /// 9 par l'arbitre, 0 par le proxy. /// /// **Ce qui devient vrai, c'est que personne d'autre ne commande. Pas que la charge /// fait ce qu'on lui dit.** Un plafond matériel, un véhicule qui refuse, un câble /// débranché feront toujours diverger la commande et la réalité — pour une borne comme /// pour un chauffe-eau dont la résistance a lâché. /// /// L'écart commande/réalité ne se lit donc **jamais** dans [allocatedW] : il se lit dans /// [measuredW] quand un compteur est rattaché, et dans la charge utile [mechanism] — /// pour une borne, `chargingEnabled`, `currentA`, `phaseCount`, `pluggedIn`. /// /// Ce getter n'existe plus : il portait la réserve « pour une borne, ce n'est même pas /// une commande », qui est levée. Sa disparition est délibérée — le garder à `true` /// partout inviterait à lire « commande » comme « réalité ». } /// 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] ; /// - une borne **configurée mais absente d'ici** n'est pas perdue : elle est *hors /// arbitrage en ce moment* — pas de véhicule branché (depuis `+etm24`), pas de voiture /// assignée, ou mode manuel. L'inclusion `loads[] ⊆ GetLoadConfig` tient dans ce sens /// seulement. /// /// 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 ? /// /// Somme des [LoadTelemetryEntry.surplusShareW], et non des `allocatedW` filtrés : le /// démarrage soutiré (`EV_GRID_START`) partage une même allocation entre les deux /// compteurs du budget. double get surplusAllocatedW { var total = 0.0; for (final e in loads) { total += e.surplusShareW; } 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();