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
305 lines
11 KiB
Dart
305 lines
11 KiB
Dart
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();
|
||
}
|
||
}
|