etm-powersync-app/lib/providers/load_telemetry_provider.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

305 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.

import 'dart:async';
import 'package:flutter/foundation.dart';
import '../models/load_telemetry.dart';
import '../services/nymea_service.dart';
/// Où en est une levée de défaut demandée par l'exploitant.
enum FaultClearState {
/// Rien en cours.
idle,
/// `ClearLoadFault` acquitté — **rien n'est encore prouvé**. On attend que la
/// télémétrie relue montre `available: true`.
pending,
/// Constaté sur la télémétrie : la charge est redevenue disponible.
cleared,
/// Deux cycles d'arbitrage ont passé et la charge est toujours en défaut.
stillFaulty,
/// La box a refusé l'appel (`loadId` inconnu, transport).
rejected,
}
/// Télémétrie d'arbitrage — instantané, mise à jour poussée, fraîcheur, levée de défaut.
///
/// ## Aucun état entretenu localement
///
/// Ce provider ne calcule pas d'arbitrage, ne décompte pas les verrous, ne reconstruit
/// rien depuis `Integrations.*`. Il transporte ce que la box publie. Le palier d'une
/// charge relais serait reconstructible depuis l'état `power` des Things × les puissances
/// de `LoadConfig` — mais cette reconstruction ne dit ni pourquoi, ni si un verrou agit,
/// ni si la charge est en défaut, et elle diverge de la vérité au premier écart.
///
/// ## Fraîcheur — trois signaux, pas un
///
/// Un arbitre figé ne doit pas ressembler à une installation stable. Trois indices
/// concourent, et aucun ne suffit seul :
///
/// 1. **L'âge du `timestamp`** — le seul qui vaille à l'instant du premier instantané :
/// un `Get` rend le dernier cycle **publié**, qui peut dater d'une heure.
/// 2. **Le temps depuis la dernière trame reçue** — le battement de cœur du plugin est de
/// 60 s ; son silence est un signal, et il est immunisé contre l'écart d'horloge entre
/// la box et le téléphone.
/// 3. **Le temps depuis que le `timestamp` a cessé d'avancer** — c'est le cas que le
/// battement de cœur existe pour rendre détectable : les trames continuent d'arriver
/// tandis que le moteur ne tourne plus.
class LoadTelemetryProvider extends ChangeNotifier {
NymeaService? _service;
void Function()? _unsubscribe;
LoadTelemetry? _telemetry;
bool _supported = true;
bool _loading = false;
String? _error;
/// Horloge **locale** de réception de la dernière trame (instantané ou notification).
DateTime? _lastFrameAt;
/// Horloge locale à laquelle le `timestamp` courant a été vu pour la première fois.
DateTime? _cycleFirstSeenAt;
DateTime? _seenCycle;
Timer? _ticker;
final Map<String, FaultClearState> _clearStates = {};
final Map<String, String> _clearErrors = {};
final Map<String, Timer> _clearDeadlines = {};
final Map<String, Timer> _clearPolls = {};
/// Deux cycles d'arbitrage plus une marge : au-delà, une charge toujours en défaut
/// l'est vraiment. Conclure plus tôt reprocherait à la box de n'avoir pas encore
/// exécuté le cycle suivant.
static const Duration _clearDeadline = Duration(seconds: 130);
static const Duration _clearPollInterval = Duration(seconds: 15);
// ── Lecture ────────────────────────────────────────────────────────────────
LoadTelemetry? get telemetry => _telemetry;
bool get loading => _loading;
String? get error => _error;
/// Faux quand **cette box** n'expose pas `GetLoadTelemetry` (plugin antérieur au lot
/// télémétrie). Ce n'est pas une panne : c'est une version, et elle s'annonce comme
/// telle.
bool get supported => _supported;
/// Vrai tant qu'aucun cycle d'arbitrage n'a eu lieu depuis le démarrage du service.
bool get hasNeverRun => _telemetry?.hasNeverRun ?? false;
/// Vrai en repli L2 : compteur muet, planification suspendue, aucun budget publié.
bool get degradedMode => _telemetry?.degradedMode ?? false;
LoadTelemetryEntry? entryFor(String loadId) => _telemetry?.entryFor(loadId);
FaultClearState clearStateOf(String loadId) =>
_clearStates[loadId] ?? FaultClearState.idle;
String? clearErrorOf(String loadId) => _clearErrors[loadId];
/// Âge du dernier cycle publié, tel que la box le date. Sensible à l'écart d'horloge —
/// d'où les deux autres signaux.
Duration? get cycleAge => _telemetry?.ageAt(DateTime.now());
/// Temps écoulé depuis la dernière trame reçue, horloge locale.
Duration? get sinceLastFrame =>
_lastFrameAt == null ? null : DateTime.now().difference(_lastFrameAt!);
/// Temps écoulé depuis que le `timestamp` a cessé d'avancer, horloge locale.
Duration? get sinceCycleAdvanced => _cycleFirstSeenAt == null
? null
: DateTime.now().difference(_cycleFirstSeenAt!);
/// Âge à afficher : le plus défavorable des signaux disponibles. Annoncer le plus
/// flatteur reviendrait à choisir la version rassurante d'une donnée douteuse.
Duration? get displayedAge {
final candidates = [cycleAge, sinceCycleAdvanced].nonNulls.toList();
if (candidates.isEmpty) return null;
return candidates.reduce((a, b) => a > b ? a : b);
}
/// Vrai quand l'instantané ne peut plus être présenté comme l'état courant.
bool get stale {
if (_telemetry == null || hasNeverRun) return false;
final signals = [cycleAge, sinceLastFrame, sinceCycleAdvanced].nonNulls;
return signals.any((d) => d > kTelemetryStaleAfter);
}
// ── Câblage ────────────────────────────────────────────────────────────────
void attach(NymeaService service) {
if (identical(_service, service)) return;
_unsubscribe?.call();
_service = service;
_unsubscribe = service.onLoadTelemetryChanged(_onFrame);
}
/// Instantané au chargement de l'écran. La suite arrive en notification.
Future<void> load() async {
final service = _service;
if (service == null) return;
_loading = true;
_error = null;
notifyListeners();
try {
final payload = await service.getLoadTelemetry();
if (payload == null) {
// Distinguer les deux « pas de données » : une box sans la méthode n'est pas
// une box en panne.
_supported = service.loadTelemetrySupported;
_telemetry = null;
} else {
_supported = true;
_adopt(payload);
}
} catch (e) {
_error = e.toString();
} finally {
_loading = false;
_startTicker();
notifyListeners();
}
}
void _onFrame(Map<String, dynamic> payload) {
_supported = true;
_adopt(payload);
_startTicker();
notifyListeners();
}
void _adopt(Map<String, dynamic> payload) {
final t = LoadTelemetry.fromJson(payload);
final now = DateTime.now();
_telemetry = t;
_lastFrameAt = now;
_error = null;
// Le cycle a-t-il avancé ? C'est cette question, et non l'arrivée d'une trame, qui
// dit si le moteur tourne encore.
if (t.timestamp != _seenCycle) {
_seenCycle = t.timestamp;
_cycleFirstSeenAt = now;
}
_evaluatePendingClears();
}
// ── Fraîcheur : faire vieillir l'affichage sans nouvelle trame ─────────────
void _startTicker() {
_ticker ??= Timer.periodic(const Duration(seconds: 5), (_) {
if (_telemetry == null) return;
notifyListeners();
});
}
// ── Levée de défaut ────────────────────────────────────────────────────────
/// Demande la levée du verrou de défaut d'une charge.
///
/// **Le résultat n'est jamais annoncé sur le retour du RPC.** Vérifié sur `.75` :
/// appelée sur une charge saine, `ClearLoadFault` répond `EnergyErrorNoError`. Le seul
/// constat recevable est `available` repassé à vrai dans la télémétrie relue — d'où
/// l'état [FaultClearState.pending] maintenu jusqu'à la trame qui le montre.
Future<void> clearFault(String loadId) async {
final service = _service;
if (service == null) return;
final entry = entryFor(loadId);
// Le bouton n'est proposé que sur une charge en défaut ; cette garde couvre la
// course où la charge se relève entre l'affichage et le geste.
if (entry == null || entry.available) return;
_clearStates[loadId] = FaultClearState.pending;
_clearErrors.remove(loadId);
notifyListeners();
final err = await service.clearLoadFault(loadId);
if (err != null) {
_clearStates[loadId] = FaultClearState.rejected;
_clearErrors[loadId] = err;
notifyListeners();
return;
}
// Relecture périodique en plus des notifications : si le poussé ne fonctionne pas,
// l'exploitant doit quand même obtenir un verdict, jamais un sablier éternel.
_clearPolls[loadId]?.cancel();
_clearPolls[loadId] = Timer.periodic(_clearPollInterval, (_) => load());
_clearDeadlines[loadId]?.cancel();
_clearDeadlines[loadId] = Timer(_clearDeadline, () {
if (_clearStates[loadId] != FaultClearState.pending) return;
_clearStates[loadId] = FaultClearState.stillFaulty;
_stopClearWatch(loadId);
notifyListeners();
});
}
void _evaluatePendingClears() {
for (final loadId in _clearStates.keys.toList()) {
if (_clearStates[loadId] != FaultClearState.pending) continue;
final entry = entryFor(loadId);
// Charge disparue de `loads[]` : elle a été désactivée entre-temps. Ce n'est ni
// une levée ni un échec — on cesse d'attendre plutôt que de trancher à sa place.
if (entry == null) {
_clearStates[loadId] = FaultClearState.idle;
_stopClearWatch(loadId);
continue;
}
if (entry.available) {
_clearStates[loadId] = FaultClearState.cleared;
_stopClearWatch(loadId);
}
}
}
void _stopClearWatch(String loadId) {
_clearDeadlines.remove(loadId)?.cancel();
_clearPolls.remove(loadId)?.cancel();
}
/// Efface un verdict affiché (l'exploitant a pris connaissance du résultat).
void acknowledgeClear(String loadId) {
_clearStates.remove(loadId);
_clearErrors.remove(loadId);
notifyListeners();
}
/// Vide l'état au switch d'installation — cette télémétrie appartient à la box quittée.
void clearForSwitch() {
_telemetry = null;
_error = null;
_lastFrameAt = null;
_cycleFirstSeenAt = null;
_seenCycle = null;
_supported = true;
for (final t in _clearDeadlines.values) {
t.cancel();
}
for (final t in _clearPolls.values) {
t.cancel();
}
_clearDeadlines.clear();
_clearPolls.clear();
_clearStates.clear();
_clearErrors.clear();
_ticker?.cancel();
_ticker = null;
notifyListeners();
}
@override
void dispose() {
_ticker?.cancel();
for (final t in _clearDeadlines.values) {
t.cancel();
}
for (final t in _clearPolls.values) {
t.cancel();
}
_unsubscribe?.call();
super.dispose();
}
}