etm-powersync-app/lib/models/load_telemetry.dart
Patrick Schurig dee20896c5 feat(télémétrie): lot B app — consommation de la télémétrie d'arbitrage
Le lot plugin B-bis (1.15.2+etm15) est sur .75 et GetLoadConfig/GetLoadTelemetry
concordent : les critères 4 à 8 du brief deviennent atteignables.

- models/load_telemetry.dart : vue en lecture. timestamp/budget/mechanism/lock/
  faultCode nullables — omis porte un sens que zéro ne porterait pas.
- services/telemetry_text.dart : rendu français des CODES (19 motifs, 3 défauts,
  3 verrous, 4 mécanismes). Repli lisible sur un code inconnu ET sur un code connu
  privé d'un paramètre : une phrase trouée a l'air d'une donnée, le code brut a
  l'air de ce qu'il est.
- providers/load_telemetry_provider.dart : instantané + LoadTelemetryChanged,
  fraîcheur à trois signaux (âge du timestamp, silence des trames, timestamp qui
  cesse d'avancer), machine à états de ClearLoadFault qui ne conclut JAMAIS sur
  l'acquittement du RPC.
- load_config_provider : assignDomain() en brouillon, énumération fermée.
- load_order_screen : budget, fraîcheur, mode dégradé, bloc par charge, encart
  défaut, menu de domaine.
- nymea_service : getLoadTelemetry(), clearLoadFault(), aiguillage de la
  notification. Une box sans la méthode s'annonce comme telle, pas comme en panne.

Seuil de fraîcheur à 180 s : le battement de cœur du plugin est de 60 s et le
timestamp avance d'un cycle par minute. Un seuil plus court crierait « données
anciennes » sur une installation vivante.

Fixtures v3 prises sur .75 après déploiement, dont un cycle porteur d'un verrou —
« lock » étant optionnel, un dump pris au mauvais moment ne l'éprouverait pas.

57 tests (30 avant), flutter analyze inchangé à 25 issues.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XPUo3RMr8SzK6qbFtfBm8H
2026-08-25 19:20:30 +02:00

303 lines
11 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/// 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 réservé à l'étape 3g,
/// non émis en beta).
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']);
// 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<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;
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<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,
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;
}
/// 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;
/// 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<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;
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();