/// 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 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 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 raw; /// `relay` · `variable` · `sgReady` · `evcharger` (ce dernier réservé à l'étape 3g, /// non émis en beta). final String kind; const LoadMechanism(this.raw, this.kind); factory LoadMechanism.fromJson(Map 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']); // 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']); // evcharger (3g) 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 params; const LoadDecision({required this.code, required this.params}); factory LoadDecision.fromJson(Map? 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.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 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; 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, required this.lock, required this.mechanism, required this.decision, }); factory LoadTelemetryEntry.fromJson(Map 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, lock: lock is Map ? LoadLock.fromJson(Map.from(lock)) : null, mechanism: mech is Map ? LoadMechanism.fromJson(Map.from(mech)) : null, decision: LoadDecision.fromJson( m['decision'] is Map ? Map.from(m['decision'] as Map) : null), ); } bool get inFault => !available; } /// Instantané complet de l'arbitrage. class LoadTelemetry { final Map 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; /// Uniquement les charges **arbitrées par le waterfall** (`relay-router`, /// `etmvariableload`, `sg-ready`). Le VE n'y figure pas en beta : il est décidé en /// amont et sa puissance est réservée dans `budget.evReservedW`, pas allouée depuis le /// budget publié. /// /// 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 loads; const LoadTelemetry({ required this.raw, required this.degradedMode, required this.timestamp, required this.budget, required this.loads, }); factory LoadTelemetry.fromJson(Map 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.from(budget)) : null, loads: loads is List ? [ for (final l in loads.whereType()) LoadTelemetryEntry.fromJson(Map.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; 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();