Elle reste : la retirer aurait laissé un trou dans une liste que le client a composée lui-même. Elle affiche maintenant ce que la box publie, et là où il n'y a rien à lire, elle le dit. Ce qui est lu, et sa source · branché / en charge ← états pluggedIn, charging du Thing · puissance ← état currentPower du Thing · mode ← GetChargingInfos + notification ChargingInfoChanged Absent = « non publié », jamais zéro. Un mode que l'app ne connaît pas se lit « mode non reconnu (X) » plutôt que d'être deviné, et aucun bouton n'est allumé tant que la box n'a pas dit ce qu'elle porte. L'écriture restitue le refus, sinon elle n'existe pas. Les pastilles de 7 à 10 px qui avalaient les codes d'erreur de setChargingInfo passent à 44 px, montrent l'attente, et affichent le refus tel que la box le nomme. Et l'app ne tient plus d'état de mode local : après une écriture acceptée, elle RELIT GetChargingInfos. Elle se souvenait de ce qu'elle avait demandé, ce qui n'est pas ce que la borne porte. La fiction perd sa porte d'entrée. chargingMode, chargingPower (3,6 kW) et solarSourcePercent (82 %) sont SUPPRIMÉS de EnergyData : trois valeurs par défaut que _updateEnergyData ne touchait jamais. Ce que la box publie sur une borne vit désormais dans EvChargerLive, champ par champ, nullable. ⚠️ Et le Sankey du tableau de bord en tirait un flux « Voiture » permanent à 3,6 kW, voiture débranchée. Il est branché sur la mesure de la borne — « non lu » quand il n'y en a pas. Mais la même vue invente encore PAC = 38 % de la maison, Eau chaude = 18 %, Autres = 22 %, dont un seul marqué « estimé » : entrée 🔴 au TODO, avec la source réelle (GetLoadTelemetry publie measuredW par charge : 1500 W et 800 W ce midi). Brancher le Sankey dessus est une refonte de l'écran d'accueil, pas un correctif — à décider. Deux attentes tombent, une reste (relevé Integrations.GetThings sur .75, cet après-midi) · currentL1/L2/L3 est DÉPLOYÉ (5,86 / 5,94 / 6,02 A) — powerL1/L2/L3 a disparu · phaseCount EXISTE sur la Trydan (3) — la note qui le disait absent était en retard · sessionEnergy existe (4,229 kWh) MAIS vaut exactement chargeEnergy : c'est ce qu'on attend d'une session sans interruption, donc ça ne prouve rien. LM-1009 §C1 reste entière tant qu'une remise à zéro de chargeEnergy n'a pas été vue avec un sessionEnergy qui continue. Le brief moteur dit désormais du masquage WRITE_FAILED que ce n'est pas « ne se produit pas » mais « ne s'est pas encore produit » : il attend une charge variable qui perd son Thing, et le banc n'a pas eu ce cas. 154 tests (8 nouveaux sur la tuile, dont les cibles tactiles et la restitution du refus), flutter analyze à 27 remarques, aucune nouvelle. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SQbZKrWqsMFP1Lh2jjjd9f
495 lines
22 KiB
Dart
495 lines
22 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. **Tant qu'une remise à zéro de
|
||
/// `chargeEnergy` n'a pas été observée avec un `sessionEnergy` qui, lui, continue,
|
||
/// la condition C1 de LM-1009 reste entière** : ne pas afficher d'avancement.
|
||
|
||
// ── 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();
|