Payé le 2026-08-30 en cherchant à provoquer EV_GRID_START. Trois erreurs, aucune n'a produit de code d'erreur : - `connect` bascule `connected` (la liaison du Thing), pas `pluggedIn` — lecture seule, qu'aucune action n'écrit. La borne n'est jamais entrée dans `loads[]` ; - le `finally` défaisait le GESTE, pas l'ÉTAT : `connected` valait déjà `true` avant, donc la « restauration » a laissé la borne déconnectée dix minutes ; - `ThingErrorNoError` est un VRAI succès, et c'est ce qui rend le cas plus vicieux que `status: "success"` : l'action a réussi, elle ne faisait pas ce qu'on croyait. Aucun code ne dira « tu t'es trompé d'action » — le seul verdict est l'état relu. La règle que `fault_probe.dart` tenait déjà pour la configuration est généralisée à `ExecuteAction` : capturer avant, restaurer VERS la capture, comparer état par état. Et le fait mesuré, pour qui réessaiera : EV_GRID_START n'est pas provocable depuis la frontière RPC (pas de prise véhicule exposée, pas d'`assignedCarId`). La fenêtre, elle, s'ouvre seule — 07:46, surplus 905 W, sous le minimum de la borne. La preuve du partage viendra donc de la suite de simulation du moteur ; le critère ne bouge pas, et la branche reste jusqu'à sa trace. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GKM8FDNHZWogAsjjfB8pSw
761 lines
35 KiB
Dart
761 lines
35 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?;
|
||
}
|
||
|
||
/// **Une passe du waterfall, pour une charge** — publié dans `levels[]` depuis `+etm30`.
|
||
///
|
||
/// ## Ce que ce type acte, et ce qu'il n'anticipe pas
|
||
///
|
||
/// `+etm30` déplace `funding` de la charge vers le niveau (R2 du brief du 2026-08-29).
|
||
/// C'est une **rupture de forme**, pas un ajout : une charge de `+etm30` ne porte plus
|
||
/// aucun `funding` à sa racine, et un écran qui l'y cherche ne trouve rien — sans erreur.
|
||
///
|
||
/// Le déplacement était nécessaire **avant** le §10 : la même charge y sera financée **au
|
||
/// réseau pour son plancher éco et au surplus pour son confort dans le même cycle**. Un
|
||
/// financement par charge y deviendrait indécidable.
|
||
///
|
||
/// Aujourd'hui une seule passe est publiée, `"comfort"`, et ce n'est pas un rebaptisage :
|
||
/// c'est bien celle qui, au §10, servira le confort depuis le surplus. La forme est donc
|
||
/// juste dès maintenant, sans que rien du §10 soit implémenté.
|
||
///
|
||
/// **Ce type ne fait que lire.** Aucun écran à deux niveaux n'existe : la liste des
|
||
/// charges reste ordonnée par rang, une entrée par charge, et le reste attend R1 et
|
||
/// R3 à R8.
|
||
class LoadLevel {
|
||
/// `"eco"` (plancher, §10 — jamais publié à ce jour) ou `"comfort"` (le surplus).
|
||
final String level;
|
||
|
||
/// Ce que la passe a **décidé** pour cette charge.
|
||
///
|
||
/// C'est du décidé, comme [LoadTelemetryEntry.allocatedW] — `buildTelemetry()` publie
|
||
/// le plan, l'adaptateur arrondit après. D'où l'identité exacte
|
||
/// `Σ targetW == allocatedW`, et **non** un écart d'arrondi : le chercher là revient à
|
||
/// comparer deux valeurs égales par construction, donc à ne jamais rien montrer.
|
||
/// L'arrondi réellement appliqué se lit dans [LoadTelemetryEntry.mechanism].
|
||
final double targetW;
|
||
|
||
/// `"surplus"` ou `"grid"` — **qui paie cette passe**.
|
||
final String? funding;
|
||
|
||
const LoadLevel({required this.level, required this.targetW, this.funding});
|
||
|
||
factory LoadLevel.fromJson(Map<String, dynamic> m) => LoadLevel(
|
||
level: m['level'] as String? ?? '',
|
||
targetW: _d(m['targetW']),
|
||
funding: (m['funding'] is String && (m['funding'] as String).isNotEmpty)
|
||
? m['funding'] as String
|
||
: null,
|
||
);
|
||
|
||
/// Financement omis = surplus : le seul qui existait avant que le champ n'existe.
|
||
bool get surplusFinance => funding != 'grid';
|
||
}
|
||
|
||
/// État runtime d'**une** charge arbitrée par le waterfall.
|
||
class LoadTelemetryEntry {
|
||
final Map<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` est toujours là, et son retrait attend une MESURE.**
|
||
///
|
||
/// Sa contribution au surplus n'est pas `targetW` mais `decision.params.budgetW` : le
|
||
/// motif partage une allocation entre deux compteurs, donc il viole l'identité
|
||
/// `Σ targetW(surplus) == budget.allocatedW`. C'était le cas fondateur de R3.
|
||
///
|
||
/// `+etm31` est censé y mettre fin — la part surplus dans `budget.allocatedW`, la part
|
||
/// réseau dans le nouveau `draw.committedW`, deux compteurs de même nature. La sonde du
|
||
/// 2026-08-30 confirme `+etm31` posé et `draw.committedW` publié, **mais n'a pas pu voir
|
||
/// le motif** : les deux bornes du banc sont déclarées et absentes de `loads[]` (hors
|
||
/// arbitrage, rien de branché), donc `EV_GRID_START` y est structurellement impossible.
|
||
///
|
||
/// **Cette branche tient jusqu'à l'observation d'un `EV_GRID_START` réel.** Retirer sur
|
||
/// la foi de l'annonce ferait exactement ce que la mesure a déjà démenti une fois cette
|
||
/// semaine.
|
||
///
|
||
/// **Et la trace ne viendra pas du banc** : mesuré le 2026-08-30, le motif n'y est pas
|
||
/// provocable — aucune action n'expose la prise véhicule, et sans `assignedCarId` la borne
|
||
/// reste hors `loads[]` même branchée (`tools/rpc/README.md`). La preuve viendra de la
|
||
/// suite de simulation du moteur, qui assère le partage depuis `+etm31` ; le critère, lui,
|
||
/// ne bouge pas : `params.budgetW` dans `budget.allocatedW` **et** `params.gridW` dans
|
||
/// `draw.committedW`.
|
||
double get surplusShareW {
|
||
// L'exception passe avant tout : elle vaut pour les deux formes.
|
||
if (decision.code == 'EV_GRID_START' && !isSurplusFunded) {
|
||
return decision.numParam('budgetW') ?? 0.0;
|
||
}
|
||
if (levels.isEmpty) return isSurplusFunded ? allocatedW : 0.0; // avant +etm30
|
||
var total = 0.0;
|
||
for (final l in levels) {
|
||
if (l.surplusFinance) total += l.targetW;
|
||
}
|
||
return total;
|
||
}
|
||
|
||
/// **[allocatedW] est ce qui a été DÉCIDÉ — ni le commandé, ni ce que la charge tire.**
|
||
///
|
||
/// Corrigé le 2026-08-29 : `buildTelemetry()` publie le **plan**,
|
||
/// `applyActionsToAdapters()` reçoit le slot en `const`, et rien n'est réécrit après le
|
||
/// dispatch — un test du moteur attend `allocatedW == 3000` sur une borne dont le
|
||
/// courant commandé donne 2 990 W. D'où `Σ levels[].targetW == allocatedW`, une
|
||
/// **identité** et non un écart d'arrondi : l'arrondi vit après, dans l'adaptateur, et
|
||
/// se lit dans [mechanism].
|
||
///
|
||
/// La distinction avec ce que la charge TIRE a changé de nature avec le lot plugin
|
||
/// 3g-2, et il faut la reprendre exactement, sans l'élargir.
|
||
///
|
||
/// Jusqu'à `+etm22`, une borne avait **deux commandeurs** : l'arbitre décidait et
|
||
/// publiait, puis `adjustEvChargers()` re-décidait derrière lui et commandait autre
|
||
/// chose. Ce que la télémétrie publiait n'atteignait pas le matériel. `+etm23` ferme
|
||
/// ça — une charge, un commandeur ; mesuré sur machine, 9 commandes émises à la borne,
|
||
/// 9 par l'arbitre, 0 par le proxy.
|
||
///
|
||
/// **Ce qui devient vrai, c'est que personne d'autre ne commande. Pas que la charge
|
||
/// fait ce qu'on lui dit.** Un plafond matériel, un véhicule qui refuse, un câble
|
||
/// débranché feront toujours diverger la commande et la réalité — pour une borne comme
|
||
/// pour un chauffe-eau dont la résistance a lâché.
|
||
///
|
||
/// L'écart commande/réalité ne se lit donc **jamais** dans [allocatedW] : il se lit dans
|
||
/// [LoadTelemetryEntry.measurement] — dont la `source` dit ce que l'écart prouve — et
|
||
/// dans la charge utile [mechanism] : pour une borne, `chargingEnabled`, `currentA`,
|
||
/// `phaseCount`, `pluggedIn`.
|
||
///
|
||
/// Ce getter n'existe plus : il portait la réserve « pour une borne, ce n'est même pas
|
||
/// une commande », qui est levée. Sa disparition est délibérée — le garder à `true`
|
||
/// partout inviterait à lire « commande » comme « réalité ».
|
||
}
|
||
|
||
/// Instantané complet de l'arbitrage.
|
||
class LoadTelemetry {
|
||
final Map<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;
|
||
|
||
const LoadTelemetry({
|
||
required this.raw,
|
||
required this.degradedMode,
|
||
required this.timestamp,
|
||
required this.budget,
|
||
required this.loads,
|
||
});
|
||
|
||
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 [],
|
||
);
|
||
}
|
||
|
||
/// `true` tant qu'aucun cycle d'arbitrage n'a eu lieu depuis le démarrage du service.
|
||
bool get hasNeverRun => timestamp == null;
|
||
|
||
/// Somme des allocations **financées par le surplus**.
|
||
///
|
||
/// C'est le seul terme comparable à `budget.allocatedW` : une charge servie au réseau
|
||
/// n'entre pas dans ce compteur, et l'inclure ici creuserait un écart que rien ne
|
||
/// permettrait d'interpréter — défaut du moteur, ou charge au réseau ?
|
||
///
|
||
/// **Et c'est la seule identité qui existe** : il n'y en a aucune du côté réseau tant que
|
||
/// `draw.committedW` n'est pas publié. `budget.evReservedW` ne peut pas en tenir lieu.
|
||
///
|
||
/// Somme des [LoadTelemetryEntry.surplusShareW], et non des `allocatedW` filtrés : le
|
||
/// démarrage soutiré (`EV_GRID_START`) partage une même allocation entre les deux
|
||
/// compteurs du budget.
|
||
///
|
||
/// Depuis `+etm30` le terme sommé est le `targetW` des **niveaux** financés au surplus.
|
||
/// Vérifié sur `.75` le 2026-08-30, `1.15.2+etm30` : 3 500 + 3 000 == 6 500 ==
|
||
/// `budget.allocatedW`.
|
||
double get surplusAllocatedW {
|
||
var total = 0.0;
|
||
for (final e in loads) {
|
||
total += e.surplusShareW;
|
||
}
|
||
return total;
|
||
}
|
||
|
||
/// L'identité que le plugin déclare vérifiable depuis `+etm22` :
|
||
/// **somme des `allocatedW` financés au surplus == `budget.allocatedW`**.
|
||
///
|
||
/// `null` en mode dégradé — sans budget, il n'y a rien à réconcilier, et rendre `false`
|
||
/// se lirait « le moteur se contredit » là où il n'a simplement pas planifié.
|
||
///
|
||
/// Tolérance 1 W : les valeurs voyagent en `Double`.
|
||
bool? get surplusIdentityHolds {
|
||
final b = budget;
|
||
if (b == null) return null;
|
||
return (surplusAllocatedW - b.allocatedW).abs() < 1.0;
|
||
}
|
||
|
||
LoadTelemetryEntry? entryFor(String loadId) {
|
||
for (final e in loads) {
|
||
if (e.loadId == loadId) return e;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/// Âge du dernier cycle publié, mesuré contre l'horloge locale.
|
||
///
|
||
/// `null` si aucun cycle n'a eu lieu. **Négatif ramené à zéro** : l'horloge de la box
|
||
/// et celle du téléphone ne sont pas synchronisées, et un âge négatif se lirait comme
|
||
/// une donnée du futur.
|
||
Duration? ageAt(DateTime now) {
|
||
final ts = timestamp;
|
||
if (ts == null) return null;
|
||
final d = now.toUtc().difference(ts.toUtc());
|
||
return d.isNegative ? Duration.zero : d;
|
||
}
|
||
}
|
||
|
||
double _d(Object? v) => (v as num?)?.toDouble() ?? 0.0;
|
||
double? _dn(Object? v) => (v as num?)?.toDouble();
|