etm-powersync-app/lib/models/load_telemetry.dart
Patrick Schurig 58bf57e67f feat(§10): la carte de charge porte ses deux niveaux — une commande, deux lignes
Premier lot d'écran, sur des champs tous posés (+etm55) et tous vus à la sonde.
L'allocation reste le parent ; les niveaux en sont la décomposition. Une charge
reçoit UNE commande par cycle quelles que soient les passes (LM-1003) : deux
lignes décrivent une seule décision.

- `EcoProgress` — regime, targetWh, deliveredWh (absent sous `unmeasurable`,
  jamais 0), purchasedWh (publié dans TOUS les régimes : c'est la commande du
  moteur, donc un fait connu sans mesure), measuredSince, measuredGaps ;
- `LoadLevel` gagne `decision` et `progress` : chaque passe porte son motif.
  Lire la racine seule afficherait « rien fait » sur une charge qui achète —
  relevé du 2026-09-01 05:47, `SURPLUS_INSUFFICIENT` en racine et 767 W achetés
  au niveau éco ;
- `ecoDecisionText` / `ecoProgressText` : les phrases des trois motifs éco et de
  leurs quatre causes. Aucun nom de paramètre n'est deviné — `cause`,
  `missingWh`, les quatre valeurs et `measuredGaps` sont vérifiés dans le binaire
  posé. Seule la FORME de `measuredGaps` reste non observée, donc on n'en lit que
  la présence ;
- garde R6 à l'écran : `ECO_FLOOR_MET` sous `unmeasurable` est DÉNONCÉ comme une
  incohérence de la box, pas rendu. Servie oui, tenue jamais.

Deux interdits écrits dans le code, pas seulement dans la maquette : sous
`unmeasurable` on n'annonce pas que l'achat va s'intensifier (le taux est
constant depuis +etm55), et `atMaximum: false` n'y veut pas dire « on n'est pas
au plafond » mais « il n'y a aucun étalement, donc rien qui puisse cesser ». Un
booléen à false se lit spontanément comme rassurant.

Les charges utiles des tests viennent de `.75` — le cas éco-servi/confort-en-
attente du 2026-09-01, le cas mesuré du 2026-09-04 — pas d'une lecture de
contrat.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GKM8FDNHZWogAsjjfB8pSw
2026-09-05 07:54:22 +02:00

1020 lines
47 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/// 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<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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<String, dynamic> raw;
/// `relay` · `variable` · `sgReady` · `evcharger` — ce dernier émis depuis `+etm22`.
final String kind;
const LoadMechanism(this.raw, this.kind);
factory LoadMechanism.fromJson(Map<String, dynamic> 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<int>? 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<String, dynamic> params;
const LoadDecision({required this.code, required this.params});
factory LoadDecision.fromJson(Map<String, dynamic>? 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<String, dynamic>.from(p) : const {},
);
}
double? numParam(String key) => _dn(params[key]);
bool? boolParam(String key) => params[key] as bool?;
}
/// **L'avancement d'une obligation éco** — le bloc `progress` d'un niveau, depuis `+etm52`.
///
/// Trois régimes qui coexistent dans le même cycle sur des charges différentes, d'où un champ
/// publié plutôt qu'un motif (LM-1009 C2).
class EcoProgress {
/// `"measured"` · `"estimated"` · `"unmeasurable"`.
final String regime;
/// L'obligation déclarée, en Wh.
final double targetWh;
/// Énergie livrée depuis l'ouverture. **Absent sous `unmeasurable`** — jamais zéro : une
/// dégradation ne doit pas se lire comme un avancement nul.
final double? deliveredWh;
/// **Ce que le moteur a ACHETÉ pour cette obligation**, publié dans **tous** les régimes.
///
/// C'est sa propre commande, donc un fait qu'il connaît sans mesure — à la différence de
/// [deliveredWh], qui est une observation qu'il ne peut pas toujours faire. C'est pourquoi
/// la paire n'est pas redondante : elle ne coïncide que quand tout va bien, et c'est sa
/// **divergence** qui informe. Relevé le 2026-09-04 sur charge réelle : 236 Wh achetés pour
/// 750 Wh livrés, et l'acheté recoupe `gridW × temps` à 0,3 %.
final double purchasedWh;
/// Depuis quand on mesure. Après une bascule `unmeasurable → measured` en cours de période,
/// [deliveredWh] ne compte que depuis cette date **pendant que [targetWh] reste la cible
/// entière** : la paire cesse d'être une fraction, et c'est cette date qui l'explique. Le
/// moteur a refusé de rebaser la cible — on perdrait l'obligation réelle.
final DateTime? measuredSince;
/// Les trous de mesure de la période, publiés **seulement s'il y en a**.
///
/// ⚠️ **Forme non observée** : `strings` sur le binaire posé confirme la clé, pas son
/// contenu (`from`/`to` n'y sont pas). On n'en lit donc que la **présence** — « mesuré par
/// intermittence » — et rien de son détail tant qu'une sonde ne l'a pas vu sortir.
final List<dynamic>? measuredGaps;
const EcoProgress({
required this.regime,
required this.targetWh,
this.deliveredWh,
this.purchasedWh = 0,
this.measuredSince,
this.measuredGaps,
});
factory EcoProgress.fromJson(Map<String, dynamic> m) {
final since = m['measuredSince'];
final gaps = m['measuredGaps'];
return EcoProgress(
regime: m['regime'] as String? ?? '',
targetWh: _d(m['targetWh']),
deliveredWh: _dn(m['deliveredWh']),
purchasedWh: _d(m['purchasedWh']),
measuredSince:
(since is String && since.isNotEmpty) ? DateTime.tryParse(since) : null,
measuredGaps: gaps is List && gaps.isNotEmpty ? gaps : null,
);
}
bool get mesure => regime == 'measured';
bool get nonMesurable => regime == 'unmeasurable';
bool get estime => regime == 'estimated';
/// La mesure a connu des trous. Vrai seulement si la box l'a dit.
bool get parIntermittence => measuredGaps != null;
/// **Une obligation non mesurable ne peut jamais se dire TENUE** (R6, armée des deux
/// côtés). Si `ECO_FLOOR_MET` sortait sous ce régime, ce serait une incohérence de la box.
bool get peutEtreTenue => !nonMesurable;
}
/// 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<String> 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<String, double>? counts;
const LoadLevel({
required this.level,
required this.targetW,
this.funding,
this.counts,
this.decision,
this.progress,
});
/// Le plancher — l'obligation qu'on sert « quoi qu'il arrive », au réseau s'il le faut.
bool get estEco => level == 'eco';
factory LoadLevel.fromJson(Map<String, dynamic> 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,
decision: m['decision'] is Map
? LoadDecision.fromJson(Map<String, dynamic>.from(m['decision'] as Map))
: null,
progress: m['progress'] is Map
? EcoProgress.fromJson(Map<String, dynamic>.from(m['progress'] as Map))
: 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 <String, double>{}).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;
/// Le motif **de cette passe** — chaque niveau porte le sien depuis `+etm43`.
///
/// À ne pas confondre avec `LoadTelemetryEntry.decision`, qui n'en reflète qu'**un** et dit
/// lequel par sa clé `level`. Le 2026-09-01 à 05:47, le motif racine valait
/// `SURPLUS_INSUFFICIENT` pendant que ce niveau-ci achetait 767 W : la vue racine n'est pas
/// incomplète, elle est **fausse** si on la lit seule.
final LoadDecision? decision;
/// L'avancement de l'obligation — **niveau `eco` seulement**. Voir [EcoProgress].
final EcoProgress? progress;
/// 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<String, dynamic> 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<LoadLevel> 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<String, dynamic> 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<String, dynamic>.from(l))
]
: const [],
lock: lock is Map ? LoadLock.fromJson(Map<String, dynamic>.from(lock)) : null,
mechanism: mech is Map
? LoadMechanism.fromJson(Map<String, dynamic>.from(mech))
: null,
decision: LoadDecision.fromJson(
m['decision'] is Map ? Map<String, dynamic>.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 = <String>{};
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<String> 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<String, dynamic> 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<LoadTelemetryEntry> 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<String, dynamic> 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<String, dynamic>.from(budget))
: null,
loads: loads is List
? [
for (final l in loads.whereType<Map>())
LoadTelemetryEntry.fromJson(Map<String, dynamic>.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<String> 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<String> 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();