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
1020 lines
47 KiB
Dart
1020 lines
47 KiB
Dart
/// 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();
|