etm-powersync-app/lib/providers/load_telemetry_provider.dart
Patrick Schurig 2a8ad95120 feat(config): lot C — un seul écran de configuration, et le grisage vient du schéma
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
2026-08-25 22:17:16 +02:00

315 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();
}
/// 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();
}
}