Geste de mise en service, signalé par le moteur : toute borne détectée reçoit d'office ChargingModeNormal, c'est-à-dire pleine puissance HORS arbitrage. Une borne neuve n'est donc jamais pilotée tant que personne n'a posé un mode éco, et rien ne le disait — l'installateur pose une borne, elle charge à fond, et il cherche du côté du HEMS un défaut qui n'existe pas. La note apparaît aux deux endroits qui comptent, mot pour mot (kEvUnmanagedNote) : · la carte de configuration, celle qu'on ouvre à la mise en service ; · la tuile des favoris, où les trois boutons de mode sont juste à côté. ⚠️ Elle nomme l'ÉTAT, pas une négligence. La box publie le même chargingMode pour un boost délibéré et pour une borne jamais réglée : l'app ne peut pas les distinguer, donc elle dit « pleine puissance, hors arbitrage » et rappelle que c'est aussi le mode d'office. Même règle que la note de rangs ex æquo — reproductible n'est pas choisi, et on ne fait pas dire à un écran ce que la donnée ne porte pas. Un mode NON LU ne déclenche rien non plus : on ne conclut pas d'une absence. Le piège qui aurait rendu la note invisible est couvert par un test : GetLoadConfig rend l'id de charge AVEC accolades ({4d1adafd-…}), GetChargingInfos rend l'evChargerId SANS. Comparer les deux tels quels ne rend jamais rien — en silence : l'écran aurait conclu « mode non lu » sur une borne parfaitement lue. chargingModeOf() réconcilie les deux. Six tests montés (trois par écran), dont les deux contrôles négatifs. Et la réserve sessionEnergy est documentée à sa juste place : le champ est CONSTRUIT par le plugin V2C 1.15.2+etm13 — il accumule les incréments de chargeEnergy, détecte les remises à zéro, ne repart qu'à l'ouverture d'une session — donc l'égalité observée hier s'explique d'elle-même. La réserve tient jusqu'à la trace que la campagne produira (chargeEnergy à zéro, sessionEnergy qui continue) : aucun avancement affiché d'ici là. Noté aussi, pour la suite : sessionEnergy n'est PAS historisé (suggestLogging écrasé par things.conf sur un Thing déjà appairé ; la V2C n'y a que connected, charging, power). La valeur courante est juste, il n'y aura pas de courbe — le choix des états enregistrés est côté app, entrée au TODO. 164 tests, flutter analyze inchangé à 27 remarques. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SQbZKrWqsMFP1Lh2jjjd9f
510 lines
23 KiB
Dart
510 lines
23 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é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 quand même, jusqu'à la trace.** La campagne de validation en cours
|
||
/// produit exactement le cas qui la lève — `chargeEnergy` à zéro, `sessionEnergy` qui
|
||
/// continue. Tant qu'elle n'est pas au dossier, **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?;
|
||
}
|
||
|
||
/// É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"`. Nouveau en `+etm22`.
|
||
///
|
||
/// 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 vit dans `budget.evReservedW`, PAS dans `budget.allocatedW`.
|
||
///
|
||
/// **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;
|
||
|
||
/// Puissance **MESURÉE** par le compteur rattaché à cette charge. `null` quand aucun
|
||
/// compteur ne l'est.
|
||
///
|
||
/// Elle ne remplace pas [allocatedW] et ne s'y substitue jamais : l'une est ce que le
|
||
/// waterfall a **décidé**, l'autre ce qui **passe réellement**. Les afficher côte à côte
|
||
/// est tout l'intérêt du rattachement — commandé 3 000 W, mesuré 0 W, la charge ne fait
|
||
/// pas ce qu'on lui demande, et c'est la seule chose que la mesure autorise à conclure.
|
||
///
|
||
/// **`null` n'est pas 0.** Une charge sans compteur ne publie rien, et afficher un zéro
|
||
/// à la place ferait passer une absence de mesure pour une absence de consommation.
|
||
final double? measuredW;
|
||
|
||
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.measuredW,
|
||
this.funding,
|
||
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,
|
||
measuredW: _dn(m['measuredW']),
|
||
funding: (m['funding'] is String && (m['funding'] as String).isNotEmpty)
|
||
? m['funding'] as String
|
||
: null,
|
||
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 => funding != '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é.
|
||
double get surplusShareW {
|
||
if (isSurplusFunded) return allocatedW;
|
||
if (decision.code == 'EV_GRID_START') return decision.numParam('budgetW') ?? 0.0;
|
||
return 0.0;
|
||
}
|
||
|
||
/// **[allocatedW] est ce qui a été COMMANDÉ. Ce n'est pas ce que la charge tire.**
|
||
///
|
||
/// La distinction 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
|
||
/// [measuredW] quand un compteur est rattaché, 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 borne servie au réseau
|
||
/// est comptée dans `budget.evReservedW`, et l'inclure ici creuserait un écart que rien
|
||
/// ne permettrait d'interpréter — défaut du moteur, ou borne au réseau ?
|
||
///
|
||
/// 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.
|
||
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();
|