/// 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?; } /// Le compteur du **surplus** : `budget.allocatedW`. Clé de `LoadLevel.counts`. const String kCompteurSurplus = 'budget.allocatedW'; /// Le compteur des **watts achetés** : `draw.committedW`, né en `+etm31`. const String kCompteurSoutirage = 'draw.committedW'; /// Les destinations que **cette version** sait attribuer. /// /// `budget.evReservedW` n'y figure pas, et c'est délibéré : ce n'est **pas un registre /// d'allocation** mais un terme de correction du budget (« commandé, pas encore mesuré »). /// Depuis `+etm32` il ne reçoit plus aucune tranche — `evReservedW: 0` pendant qu'un /// `EV_GRID_START` partage entre les deux vrais compteurs. const Set kDestinationsConnues = {kCompteurSurplus, kCompteurSoutirage}; /// **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; /// **L'ORIGINE** — `"surplus"` ou `"grid"` : « d'où viennent ces watts ». /// /// Voisin du motif, et **ne se dérive pas** de [counts]. Sur un niveau dont l'allocation /// est partagée il vaut `"grid"` : la règle publiée est *grid l'emporte dès qu'un watt /// est acheté* — déterministe, indépendante des proportions, alignée sur le fait /// actionnable. Il dit **s'il faut en parler** ; [counts] dit combien. final String? funding; /// **LE REGISTRE** — où ces watts sont comptés, et combien dans chacun. Depuis `+etm32`. /// /// `{"budget.allocatedW": 900, "draw.committedW": 480}` : les clés nomment un compteur du /// cycle, et **`Σ counts == targetW` toujours**. C'est ce qui a supprimé l'exception /// `EV_GRID_START` — un motif qui partage une allocation entre deux compteurs n'est plus /// une entorse, mais une ligne à deux clés. /// /// ⚠️ **Les clés sont des identifiants OPAQUES, jamais des chemins à parcourir.** Une clé /// inconnue de cette version n'est pas perdue : [countsTotalW] et [countsCoherent] restent /// vrais sans connaître son sens, et [LoadTelemetryEntry.destinationsInconnues] la nomme /// pour que l'écran dise « destination inconnue de cette version » — **jamais zéro**. /// /// `null` = box antérieure à `+etm32`. **Publié même à zéro, avec sa destination** /// (`{"budget.allocatedW": 0}` sur une charge qui n'a rien reçu, mesuré sur `.75` le /// 2026-08-30) : un `{}` aurait satisfait l'identité en perdant la destination, sur le cas /// le plus fréquent. final Map? counts; const LoadLevel({ required this.level, required this.targetW, this.funding, this.counts, }); factory LoadLevel.fromJson(Map m) { final c = m['counts']; return 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, counts: c is Map ? { for (final e in c.entries) if (e.value is num) e.key.toString(): (e.value as num).toDouble(), } : null, ); } /// Financement omis = surplus : le seul qui existait avant que le champ n'existe. bool get surplusFinance => funding != 'grid'; /// Ce que [counts] totalise — comparable à [targetW] **sans connaître le sens des clés**. double get countsTotalW { var t = 0.0; for (final v in (counts ?? const {}).values) { t += v; } return t; } /// L'identité publiée `Σ counts == targetW`. `null` quand [counts] est absent. /// Tolérance 1 W : les valeurs voyagent en `Double`. bool? get countsCoherent => counts == null ? null : (countsTotalW - targetW).abs() < 1.0; /// Part de ce niveau comptée dans `budget.allocatedW`, **lue et non déduite**. double get partSurplusW => counts != null ? (counts![kCompteurSurplus] ?? 0.0) : (surplusFinance ? targetW : 0.0); } /// É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` a été RETIRÉE le 2026-08-30 /// /// Elle valait ceci : le motif partage une allocation entre deux compteurs, donc sa part /// de surplus n'était pas son allocation mais `decision.params.budgetW`, et l'identité /// `Σ(surplus) == budget.allocatedW` ne tenait qu'en traitant cette ligne à part. /// /// `+etm32` publie [LoadLevel.counts] et **il n'y a plus rien à interpréter** : la ligne /// porte ses deux moitiés en clair. Trace de la suite de simulation du moteur — /// `params.budgetW` 900 → `counts["budget.allocatedW"]` 900 → `budget.allocatedW` 900 ; /// `params.gridW` 480 → `counts["draw.committedW"]` 480 → `draw.committedW` 480 ; /// `Σ counts` 1 380 == `targetW` == `allocatedW`. Forme confirmée sur `.75` le 2026-08-30 /// en `1.15.2+etm32` : `counts` sur tous les niveaux, destination publiée **même à zéro**, /// et les deux identités vérifiées. /// /// **Ce que ce getter fait désormais : il LIT.** Trois formes, et une seule interprète /// encore quelque chose : /// /// | Forme | Part de surplus | /// |---|---| /// | `counts` publié (`+etm32`) | `counts["budget.allocatedW"]`, **lu** | /// | niveaux sans `counts` (`+etm30`/`+etm31`) | `Σ targetW` des niveaux au surplus, **exception au motif** | /// | un financement par charge (avant `+etm30`) | `allocatedW`, **exception au motif** | /// /// **L'exception survit exactement là où la box ne sait pas dire le partage autrement**, et /// c'est la bonne frontière : elle n'est pas retirée parce qu'une version l'a annoncé, mais /// parce qu'une charge utile la rend inutile. Sur une installation déjà déployée, la /// retirer casserait la réconciliation. double get surplusShareW { // `counts` publié : la ligne porte ses parts en clair, il n'y a rien à interpréter. if (levels.any((l) => l.counts != null)) { var total = 0.0; for (final l in levels) { total += l.partSurplusW; } return total; } // Sans `counts`, le partage d'`EV_GRID_START` n'est dit que par le motif. if (decision.code == 'EV_GRID_START' && !isSurplusFunded) { return decision.numParam('budgetW') ?? 0.0; } if (levels.isEmpty) return isSurplusFunded ? allocatedW : 0.0; var total = 0.0; for (final l in levels) { if (l.surplusFinance) total += l.targetW; } return total; } /// Part de cette charge comptée dans `draw.committedW` — les **watts achetés**. /// /// `null` avant `+etm32` : sans [LoadLevel.counts] il n'existe aucun registre du /// soutirage, et zéro s'y lirait « rien acheté » là où la vérité est « on ne sait pas ». double? get soutirageShareW { if (!levels.any((l) => l.counts != null)) return null; var total = 0.0; for (final l in levels) { total += l.counts?[kCompteurSoutirage] ?? 0.0; } return total; } /// Les destinations de [LoadLevel.counts] que cette version ne sait pas attribuer. /// /// Vide dans le cas normal. Non vide = la box compte dans un registre né après cette app : /// l'identité `Σ counts == targetW` reste vérifiable, mais l'écran doit dire /// **« destination inconnue de cette version »**, et surtout pas compter zéro. Set get destinationsInconnues => { for (final l in levels) ...?l.counts?.keys.where((k) => !kDestinationsConnues.contains(k)), }; /// **[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; /// `draw.committedW` — **les watts achetés du cycle**, né en `+etm31`. /// /// Publié dans la même trame que [budget], et **seul** : `authorisedW`, `remainingW`, /// `binding` et `perPhaseBound` (le plafond de soutirage du §10) sont **absents, pas à /// zéro** — un `authorisedW: 0` afficherait un plafond qui n'existe pas, avec sa source. /// /// `null` = **box antérieure à `+etm31`**, jamais « rien acheté ce cycle » : l'objet est /// publié à zéro avec sa clé quand rien n'est acheté (mesuré sur `.75`, deux cycles). final double? drawCommittedW; const LoadTelemetry({ required this.raw, required this.degradedMode, required this.timestamp, required this.budget, required this.loads, this.drawCommittedW, }); 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 [], drawCommittedW: m['draw'] is Map ? _dn((m['draw'] as Map)['committedW']) : null, ); } /// `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 `+etm32` le terme sommé est **lu** : `counts["budget.allocatedW"]` de chaque /// niveau, sans exception ni interprétation. Vérifié sur `.75` le 2026-08-30 en /// `1.15.2+etm32` — et en `+etm30` avec les `targetW`, 3 500 + 3 000 == 6 500. 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; } /// Somme des parts comptées dans `draw.committedW`. `null` avant `+etm32`. double? get soutirageAllocatedW { var vu = false; var total = 0.0; for (final e in loads) { final p = e.soutirageShareW; if (p != null) { vu = true; total += p; } } return vu ? total : null; } /// **La seconde identité — elle n'existait pas avant `+etm32`** : /// *somme des parts comptées au soutirage == `draw.committedW`*. /// /// Jusqu'ici les watts achetés n'avaient **aucun registre** : `budget.evReservedW` est un /// terme de correction, pas une destination, et rien ne permettait de réconcilier ce /// côté-là. `draw.committedW` (`+etm31`) lui donne un compteur, [LoadLevel.counts] /// (`+etm32`) lui donne ses parts, et le côté réseau devient vérifiable comme l'autre. /// /// `null` = un des deux termes manque (box antérieure) — jamais `false`, qui se lirait /// « le moteur se contredit » là où il n'a simplement rien publié. bool? get soutirageIdentityHolds { final total = soutirageAllocatedW; final draw = drawCommittedW; if (total == null || draw == null) return null; return (total - draw).abs() < 1.0; } /// Les niveaux dont `Σ counts != targetW` — une **incohérence de la box**, pas un arrondi. /// /// L'identité est publiée par le moteur et vraie par construction ; si elle tombe, c'est /// la charge utile qui se contredit, et l'écran doit le dire au lieu de sommer quand même. List get niveauxIncoherents => [ for (final e in loads) for (final l in e.levels) if (l.countsCoherent == false) '${e.loadId}/${l.level}', ]; /// Union des destinations inconnues de cette version, toutes charges confondues. Set get destinationsInconnues => { for (final e in loads) ...e.destinationsInconnues, }; 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();