/// 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'où vient la mesure d'une charge** — publié par le moteur, jamais déduit ici. /// /// ## Pourquoi un régime publié plutôt qu'une valeur /// /// `measuredW` portait trois régimes sous un seul nom, et jetait la distinction : une borne /// rendait une mesure ; un routeur de relais rendait une mesure **ou son palier nominal** /// selon que ses relais mesurent ; un SG-Ready rendait **toujours** une puissance déclarée, /// jamais mesurée. Rien ne permettait de savoir laquelle on regardait — c'est la condition /// C2 de LM-1009, mot pour mot. /// /// Depuis `+etm26`, le régime se publie : /// `measurement: {source: "meter" | "device" | "none", powerW: 3980.0}`. /// /// ## Les trois sources, et ce qu'elles autorisent à conclure d'un écart /// /// | source | un écart commandé ↔ mesuré dit | /// |---|---| /// | `device` | **la charge n'obéit pas** — véhicule qui refuse, thermostat ouvert, plafond interne | /// | `meter` | **la charge, le compteur ou le câblage** — trois hypothèses, l'écart n'en tranche aucune | /// | `none` | **rien**, et surtout pas « 0 W d'écart » | /// /// ⚠️ **L'asymétrie qui compte pour l'écran** : sous `device`, une **concordance** ne prouve /// rien — plusieurs wallbox renvoient leur consigne en guise de puissance. Sous `meter`, /// elle est une **preuve**. `device` détecte la désobéissance ; `meter` seul peut détecter /// un mensonge. /// /// ## `none` est un état POSITIF /// /// « Cette charge n'est pas mesurable », et non « la mesure manque ». C'est lui qui autorise /// à **masquer** le bloc plutôt qu'à afficher un vide, et qui sépare enfin « pas mesurable » /// de « compteur en panne ». /// /// Deux cas de terrain qu'il couvre, et qu'aucune déduction côté app n'aurait trouvés : /// un **SG-Ready** est `none` par construction — son contact commande un signal, la PAC est /// alimentée ailleurs, et un contact qui mesurerait quelque chose mesurerait sa propre /// bobine (LM-1105-b) ; une **topologie de relais mixte** est `none` aussi, parce qu'une /// somme partielle annoncerait 500 W là où 1 500 W coulent. /// /// ## `{source: "device", powerW: 0}` est une MESURE /// /// Une borne branchée qui ne charge pas mesure zéro watt, et c'est une information. La /// source ne bascule pas vers `none` quand la charge s'éteint — sinon le bloc de mesure /// apparaîtrait et disparaîtrait au rythme du thermostat. enum MeasurementSource { /// Un compteur a été désigné exprès (`meterThingId`). meter, /// L'appareil publie lui-même sa puissance. device, /// Pas mesurable — état positif, pas une absence. none, /// Une source qu'**aucune version de cette app ne connaît** — box plus récente. /// /// Ce n'est pas l'ancien repli `measuredW`, retiré le 2026-08-28 avec le déploiement de /// `+etm26` : c'est la règle qui vaut partout ailleurs dans ce fichier — un code inconnu /// tombe sur un repli lisible, jamais sur une phrase inventée. On affiche la valeur, on /// ne prétend rien sur ce qu'un écart prouverait. inconnue, } class LoadMeasurement { final MeasurementSource source; /// Absent sous [MeasurementSource.none] — et `0` n'en est pas l'équivalent. final double? powerW; const LoadMeasurement(this.source, this.powerW); static const LoadMeasurement aucune = LoadMeasurement(MeasurementSource.none, null); /// Vrai quand il y a une puissance à afficher. bool get mesuree => source != MeasurementSource.none && powerW != null; /// Vrai quand une **concordance** entre commande et mesure prouve quelque chose. /// /// Faux sous `device` : plusieurs wallbox renvoient leur consigne, donc une concordance /// peut n'être qu'un écho. L'écart, lui, reste informatif dans les deux cas. bool get concordanceProbante => source == MeasurementSource.meter; /// Lit `measurement` — **et rien d'autre**. /// /// L'alias temporaire vers `measuredW` a été retiré le 2026-08-28 : `1.15.2+etm26` est /// déployé sur `.75`, et `measuredW` a bien disparu de la charge utile (vérifié). Il n'a /// existé que pour couvrir l'intervalle entre le livrable de l'app et celui du moteur. /// Le moteur ne double jamais une clé — deux noms pour une chose divergent — et l'app ne /// les double pas plus longtemps que nécessaire. /// /// **La garantie du contrat, celle qui rend cette lecture sûre** : `measurement` est /// présent pour **toute** charge de `loads[]`, et pour elle seule. Son absence ici ne /// peut donc pas vouloir dire « pas mesurable » — ça, c'est [MeasurementSource.none], /// qui est un état **publié**. Elle ne peut vouloir dire que « cette charge n'est pas /// dans `loads[]` », et là on ne conclut rien, on n'affiche rien qui affirme. factory LoadMeasurement.fromJson(Map m) { final brut = m['measurement']; if (brut is! Map) return LoadMeasurement.aucune; final source = switch (brut['source'] as String?) { 'meter' => MeasurementSource.meter, 'device' => MeasurementSource.device, 'none' => MeasurementSource.none, _ => MeasurementSource.inconnue, }; final w = _dn(brut['powerW']); // `none` avec une puissance ne devrait pas arriver ; si ça arrive, `none` gagne — // c'est le régime qui est publié, pas la valeur. return LoadMeasurement(source, source == MeasurementSource.none ? null : w); } } /// 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. /// /// **Ce que le plugin en dit** (V2C `1.15.2+etm13`, déployé le 28 à midi) : ce /// `sessionEnergy` est **construit**, pas un alias du registre `0x0BC4`. Il accumule les /// incréments de `chargeEnergy`, détecte les remises à zéro, et ne repart qu'à /// l'ouverture d'une session. L'égalité observée s'explique donc d'elle-même. /// /// **La réserve tient, et elle tiendra un moment : la trace ne viendra pas de cette /// borne.** La campagne V2C est arrêtée le 2026-08-28. La Trydan **non-Pro** est en WiFi /// ESP32 sans repli filaire, et le lien s'est révélé inexploitable à deux mètres du point /// d'accès : 70 % de scrutations avortées, 12 bascules en injoignable en vingt minutes. /// Le modèle sort du catalogue ETM ; seule la **Trydan Pro**, qui a un port Ethernet, y /// reste. C'est sur elle que le cas se verra. /// /// Donc : `chargeEnergy` à zéro **avec** un `sessionEnergy` qui continue **n'a pas été /// observé**, et **aucun avancement de charge n'est affiché** — la condition C1 de /// LM-1009 reste entière. Ce n'est pas de la défiance envers le plugin, c'est la règle /// qui a évité d'afficher `chargeEnergy` lui-même. /// /// > ⚠️ **`sessionEnergy` ne sera pas historisé.** `suggestLogging` est écrasé par les /// > préférences persistées de `things.conf` sur un Thing déjà appairé, et la V2C n'y a /// > que `connected`, `charging`, `power`. La **valeur courante** est juste — « la nuit /// > relue à 7 h » fonctionne — mais il n'y aura **pas de courbe** tant que les états /// > enregistrés n'auront pas été choisis côté app. Cf. `TODO.md`. // ── 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?; } /// **Une passe du waterfall, pour une charge** — publié dans `levels[]` depuis `+etm30`. /// /// ## Ce que ce type acte, et ce qu'il n'anticipe pas /// /// `+etm30` déplace `funding` de la charge vers le niveau (R2 du brief du 2026-08-29). /// C'est une **rupture de forme**, pas un ajout : une charge de `+etm30` ne porte plus /// aucun `funding` à sa racine, et un écran qui l'y cherche ne trouve rien — sans erreur. /// /// Le déplacement était nécessaire **avant** le §10 : la même charge y sera financée **au /// réseau pour son plancher éco et au surplus pour son confort dans le même cycle**. Un /// financement par charge y deviendrait indécidable. /// /// Aujourd'hui une seule passe est publiée, `"comfort"`, et ce n'est pas un rebaptisage : /// c'est bien celle qui, au §10, servira le confort depuis le surplus. La forme est donc /// juste dès maintenant, sans que rien du §10 soit implémenté. /// /// **Ce type ne fait que lire.** Aucun écran à deux niveaux n'existe : la liste des /// charges reste ordonnée par rang, une entrée par charge, et le reste attend R1 et /// R3 à R8. class LoadLevel { /// `"eco"` (plancher, §10 — jamais publié à ce jour) ou `"comfort"` (le surplus). final String level; /// Ce que la passe a **décidé** pour cette charge. /// /// C'est du décidé, comme [LoadTelemetryEntry.allocatedW] — `buildTelemetry()` publie /// le plan, l'adaptateur arrondit après. D'où l'identité exacte /// `Σ targetW == allocatedW`, et **non** un écart d'arrondi : le chercher là revient à /// comparer deux valeurs égales par construction, donc à ne jamais rien montrer. /// L'arrondi réellement appliqué se lit dans [LoadTelemetryEntry.mechanism]. final double targetW; /// `"surplus"` ou `"grid"` — **qui paie cette passe**. final String? funding; const LoadLevel({required this.level, required this.targetW, this.funding}); factory LoadLevel.fromJson(Map m) => LoadLevel( level: m['level'] as String? ?? '', targetW: _d(m['targetW']), funding: (m['funding'] is String && (m['funding'] as String).isNotEmpty) ? m['funding'] as String : null, ); /// Financement omis = surplus : le seul qui existait avant que le champ n'existe. bool get surplusFinance => funding != 'grid'; } /// É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"`. `+etm22` → `+etm27`. /// /// ⚠️ **Forme abandonnée en `+etm30`** : le financement vit désormais dans [levels], /// un par passe. Sur une box à jour ce champ est **toujours `null`** — ne pas le lire /// directement, passer par [resolvedFunding] ou [isGridFunded], qui résolvent les deux /// formes. Il n'est conservé que pour les box antérieures. /// /// 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 n'est PAS dans `budget.allocatedW` — et elle n'est nulle part /// ailleurs non plus : `budget.evReservedW` est un **terme de correction** du budget, /// pas un registre d'allocation (constaté par le moteur le 2026-08-30 en câblant R3 — /// une borne au réseau déjà mesurée y contribue 0 pour un `targetW` de 7 000 W). Les /// watts achetés n'auront de registre qu'avec `draw.committedW`. /// /// **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; /// Les passes du waterfall qui ont parcouru cette charge — voir [LoadLevel]. /// /// **Vide = box antérieure à `+etm30`**, et non « aucune passe » : le repli se fait /// alors sur [funding] et [allocatedW], la forme d'avant. final List levels; /// D'où vient la mesure, et ce qu'elle autorise à conclure. Voir [LoadMeasurement]. final LoadMeasurement measurement; 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.measurement = LoadMeasurement.aucune, this.funding, this.levels = const [], 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, measurement: LoadMeasurement.fromJson(m), funding: (m['funding'] is String && (m['funding'] as String).isNotEmpty) ? m['funding'] as String : null, levels: m['levels'] is List ? [ for (final l in m['levels'] as List) if (l is Map) LoadLevel.fromJson(Map.from(l)) ] : const [], 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 => resolvedFunding != 'grid'; /// **Le financement, résolu quelle que soit la forme publiée.** /// /// `+etm30` le porte par niveau, avant il était sur la charge. `null` = omis (mode /// dégradé : sans plan, pas de financement). /// /// Rend `'mixed'` si les niveaux ne s'accordent pas — **impossible aujourd'hui**, une /// seule passe étant publiée, et rendu possible par le §10 (plancher au réseau, confort /// au surplus, même cycle). Le nommer plutôt que le replier sur l'un des deux évite /// qu'un écran affirme un financement unique là où il y en aura deux. String? get resolvedFunding { if (levels.isEmpty) return funding; final vus = {}; for (final l in levels) { if (l.funding != null) vus.add(l.funding!); } if (vus.isEmpty) return null; if (vus.length > 1) return 'mixed'; return vus.first; } /// Vrai quand **tout** ce qui est servi à cette charge est acheté au réseau — donc /// **hors** de `budget.allocatedW`, et sans registre où le retrouver (voir [funding]). /// /// Volontairement faux sur `'mixed'` : la mention « servie au réseau » serait alors une /// demi-vérité, et c'est un écran à deux niveaux qui la remplacera (R1, R3 à R8). bool get isGridFunded => resolvedFunding == '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é. /// ⚠️ **L'exception `EV_GRID_START` survit à `+etm30`, et elle est ÉCRITE au contrat** /// (`INTERFACE.md`). Sa contribution au surplus n'est pas `targetW` mais /// `decision.params.budgetW` : le motif partage une allocation entre les deux compteurs /// du budget, donc il viole l'identité `Σ targetW(surplus) == budget.allocatedW`. C'est /// le cas fondateur de R3, **documenté et non résolu** — à traiter comme tel, jamais /// comme une divergence à diagnostiquer, tant que R3 n'est pas livrée. double get surplusShareW { // L'exception passe avant tout : elle vaut pour les deux formes. if (decision.code == 'EV_GRID_START' && !isSurplusFunded) { return decision.numParam('budgetW') ?? 0.0; } if (levels.isEmpty) return isSurplusFunded ? allocatedW : 0.0; // avant +etm30 var total = 0.0; for (final l in levels) { if (l.surplusFinance) total += l.targetW; } return total; } /// **[allocatedW] est ce qui a été DÉCIDÉ — ni le commandé, ni ce que la charge tire.** /// /// Corrigé le 2026-08-29 : `buildTelemetry()` publie le **plan**, /// `applyActionsToAdapters()` reçoit le slot en `const`, et rien n'est réécrit après le /// dispatch — un test du moteur attend `allocatedW == 3000` sur une borne dont le /// courant commandé donne 2 990 W. D'où `Σ levels[].targetW == allocatedW`, une /// **identité** et non un écart d'arrondi : l'arrondi vit après, dans l'adaptateur, et /// se lit dans [mechanism]. /// /// La distinction avec ce que la charge TIRE 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 /// [LoadTelemetryEntry.measurement] — dont la `source` dit ce que l'écart prouve — 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 charge servie au réseau /// n'entre pas dans ce compteur, et l'inclure ici creuserait un écart que rien ne /// permettrait d'interpréter — défaut du moteur, ou charge au réseau ? /// /// **Et c'est la seule identité qui existe** : il n'y en a aucune du côté réseau tant que /// `draw.committedW` n'est pas publié. `budget.evReservedW` ne peut pas en tenir lieu. /// /// 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. /// /// Depuis `+etm30` le terme sommé est le `targetW` des **niveaux** financés au surplus. /// Vérifié sur `.75` le 2026-08-30, `1.15.2+etm30` : 3 500 + 3 000 == 6 500 == /// `budget.allocatedW`. 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();