Consigne app §G (décision Patrick du 2026-10-16) et motifs etm70 (§H). G — la carte « Héos pilote en ce moment » affichait des décisions inventées (HeosDecision.demo : voiture 3,6 kW, batterie en charge sur un banc sans batterie). Remplacée par EnCeMomentCard : une ligne par charge de GetLoadTelemetry — libellé de la configuration, decision.code + params rendus par decisionText, puissance MESURÉE seulement (jamais l'allocation à sa place). Titre en clé l10n. Rien sur « optimiseur actif » tant que l'objet de capacités n'existe pas. Marque retirée des identifiants (HeosDecisionCard, HeosDecision, HeosAction, _HeosChip, _HeosBadgeSmall, heosActive → pilotageActif…), des fichiers (heos_decision_card.dart, dashboard_models.dart supprimés), des textes affichés (hub du flux, écran A/C) et des commentaires : grep -ri héos/heos vide sur lib/, test/, assets/ et les .arb. H — contre INTERFACE.md local de la branche moteur, etm70 pas encore sur le banc : - motifs éco publiés à la racine (R7, part majoritaire) rendus en clair ; ventilation des niveaux affichée (« 83 W éco + 0 W confort », vu sur la trame réelle etm69) ; - ECO_FLOOR_MISSED retenu par charge (publié un seul cycle), y compris pour une charge sortie de loads[] ; « manque non chiffrable » quand missingWh est absent, jamais 0 Wh ; - PRICE_SLOT et OPTIMIZER_DECISION en FR / DE / EN (app_en.arb et app_de.arb créés pour ces seules clés ; le reste retombe sur le français, listé dans l10n_untranslated.json). Code inconnu : repli générique avec le code. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
336 lines
12 KiB
Dart
336 lines
12 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;
|
||
|
||
/// **Le dernier objectif éco manqué, par charge** (`ECO_FLOOR_MISSED`, etm70).
|
||
///
|
||
/// Le moteur ne le publie qu'UN cycle — celui de la bascule — puis la charge reprend son
|
||
/// motif ordinaire. Lu à l'instant, il serait presque toujours invisible : l'app le
|
||
/// RETIENT. Clé = identifiant de charge normalisé (sans accolades, minuscules), y compris
|
||
/// pour une charge qui a quitté `loads[]` depuis (borne débranchée).
|
||
Map<String, ({LoadDecision decision, DateTime at})> get derniersManques =>
|
||
Map.unmodifiable(_derniersManques);
|
||
final _derniersManques = <String, ({LoadDecision decision, DateTime at})>{};
|
||
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();
|
||
for (final e in t.loads) {
|
||
// En tête de `decision` (R7), ou dans son niveau éco : les deux se lisent.
|
||
final candidats = [e.decision, for (final l in e.levels) ?l.decision];
|
||
for (final d in candidats) {
|
||
if (d.code != 'ECO_FLOOR_MISSED') continue;
|
||
_derniersManques[e.loadId.replaceAll(RegExp(r'[{}]'), '').toLowerCase()] =
|
||
(decision: d, at: t.timestamp?.toLocal() ?? now);
|
||
break;
|
||
}
|
||
}
|
||
_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;
|
||
_derniersManques.clear();
|
||
_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();
|
||
}
|
||
}
|