FUSION. L'écran « Ordre de service » est supprimé ; sa route redirige. C'est son CHEMIN DE DONNÉES (LoadConfigProvider) qui a migré dans la présentation de « Rôles & appareils », jamais l'inverse. EmsRole fige six rôles dont dhw ET heatPump séparés — or LM-201 dit qu'une PAC sans ballon séparé est UNE charge : le modèle des rôles aurait exigé deux charges sur un canal unique, ce que validateSet() refuse à raison. Les charges viennent donc de GetLoadConfig. Les compteurs restent sur EnergySetupProvider (autre contrat, SetRootMeter). La note « le pilote peut bousculer l'ordre les jours Tempo rouge ou à l'approche d'une échéance » est retirée : aucun de ces comportements n'existe dans le moteur. ANNONCE AVANT ÉCRITURE. Miroir exact de EnergyArbitrator::sameHardware(). Le piège, vérifié sur .75 : la comparaison des relais est INDEXÉE, donc réordonner relays[] sans rien changer d'autre reconstruit l'adaptateur — contacts ouverts (applySafeState, force) et verrous réarmés à froid. Un glisser-déposer dans la liste des contacteurs est un geste matériel. L'écran le nomme, avec la durée de réarmement. GRISAGE PAR INTROSPECTION. RpcSchema lit JSONRPC.Introspect à la connexion ; trois états et non deux — écrivable, r: (mesure, pas fonction manquante), absent — plus « inconnu » quand le schéma n'a pas pu être lu. Chaque champ grisé porte sa raison et le nom du champ manquant. La version affichée est celle de l'API d'expérience (NymeaEnergy 0.8) : le paquet +etm15 n'est exposé par aucune méthode. CORRIGÉ AU PASSAGE — les modes de recharge étaient INOPÉRANTS. setChargingInfo() appelait EnergyPlugin.SetChargingInfo (namespace inexistant) avec mode/targetSoc/endTime/minCurrent au lieu de chargingMode/targetPercentage/endDateTime, et minCurrent n'existe dans aucun champ. Vérifié par appel réel : « Missing required key: chargingMode ». Le refus était de plus avalé et l'état local mis à jour quand même. Désormais l'état ne suit que si la box accepte, et le refus remonte sans en inventer la cause. ZONES — constaté, pas corrigé. L'hypothèse « champs grisés » est fausse : AirConditioning publie 8 méthodes toutes en écriture. ac_screen.dart n'en appelle aucune, ses 4 pièces sont des constantes de maquette, et .75 déclare zéro zone. getZones() ajouté + bandeau qui lit le vrai compte et annonce la maquette. Le câblage est un lot en soi, non exerçable sans zone. Tests 80/80, analyze 0 erreur. Relevé : docs/RELEVE_LOTC.md — dont §6, ce qui n'a PAS pu être vérifié : l'IU n'a pas pu être pilotée sur l'appareil, MIUI refusant INJECT_EVENTS. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HajXLUczEyZd22JeewRfff
315 lines
11 KiB
Dart
315 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();
|
||
}
|
||
|
||
/// 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<String, dynamic> payload) {
|
||
_adopt(payload);
|
||
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();
|
||
}
|
||
}
|