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 _clearStates = {}; final Map _clearErrors = {}; final Map _clearDeadlines = {}; final Map _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 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 payload) { _supported = true; _adopt(payload); _startTicker(); notifyListeners(); } /// Injecte une charge utile de télémétrie sans transport — **tests uniquement**. /// /// Même porte que `LoadConfigProvider.adoptForTest` : éprouver l'affichage sur un dump /// RÉEL de la box sans monter un faux WebSocket. @visibleForTesting void adoptForTest(Map payload) { _adopt(payload); notifyListeners(); } void _adopt(Map 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 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(); } }