import 'dart:async'; import 'package:flutter/foundation.dart'; import '../models/load_config_entry.dart'; import '../services/load_hardware.dart'; import '../services/load_priority.dart'; import '../services/nymea_service.dart'; /// État de l'écriture en cours — l'acquittement d'un RPC ne vaut PAS application. enum LoadSaveState { /// Rien en cours. idle, /// `SetLoadConfig` envoyé, pas encore confirmé par `LoadConfigChanged`. pending, /// La box a confirmé, et ce qu'elle a confirmé correspond à ce qui a été demandé. confirmed, /// La box a confirmé, mais l'état reçu diffère de celui demandé. /// L'affichage montre alors ce que la box dit, pas ce que l'utilisateur voulait. diverged, /// La box a refusé (ou le transport a échoué). rejected, } /// Configuration des charges pilotables — lecture, réordonnancement, écriture. /// /// **Aucune persistance côté app.** La source de vérité est /// `/var/lib/nymea/energy-load-configuration.json` sur la box ; une copie côté téléphone /// créerait deux configurations divergentes. class LoadConfigProvider extends ChangeNotifier { NymeaService? _service; void Function()? _unsubscribe; List _entries = const []; GroupedOrder? _grouped; /// Ordre en cours d'édition. `null` tant que l'installateur n'a rien déplacé — /// c'est ce qui garantit qu'aucune écriture ne peut partir sans geste explicite. List? _draft; bool _loading = false; String? _error; LoadSaveState _saveState = LoadSaveState.idle; String? _saveError; LoadSetConflict? _conflict; /// Signature de ce qui a été envoyé, pour comparer à ce que la box confirme. String? _expectedSignature; Timer? _confirmTimeout; // ── Lecture ──────────────────────────────────────────────────────────────── List get entries => _entries; GroupedOrder? get grouped => _grouped; bool get loading => _loading; String? get error => _error; LoadSaveState get saveState => _saveState; String? get saveError => _saveError; LoadSetConflict? get conflict => _conflict; /// Groupes tels qu'affichés : le brouillon s'il existe, sinon l'état de la box. List get groups => _draft ?? _grouped?.groups ?? const []; /// Vrai si l'ordre en place sur la box n'est pas groupé par domaine. bool get isNotGrouped => _grouped != null && !_grouped!.isContiguous; List get interleavedDomains => _grouped?.interleavedDomains ?? const []; /// Vrai s'il y a un changement non enregistré. bool get hasPendingChanges => _draft != null; void attach(NymeaService service) { if (identical(_service, service)) return; _unsubscribe?.call(); _service = service; _unsubscribe = service.onLoadConfigChanged(_onBoxConfirmed); } Future load() async { final service = _service; if (service == null) return; _loading = true; _error = null; notifyListeners(); try { final raw = await service.getLoadConfig(); _adopt(raw); } catch (e) { _error = e.toString(); } finally { _loading = false; notifyListeners(); } } void _adopt(List> raw) { _entries = raw.map(LoadConfigEntry.new).toList(); _grouped = groupByDomain(_entries); _draft = null; _conflict = null; } /// Injecte un état de box sans transport — **tests uniquement**. /// /// Le chemin normal est `load()` ou la notification `LoadConfigChanged` ; cette porte /// existe pour éprouver l'édition sans monter un faux WebSocket. @visibleForTesting void adoptForTest(List> raw) { _adopt(raw); notifyListeners(); } // ── Édition (brouillon, aucune écriture) ─────────────────────────────────── /// Déplace une charge à l'intérieur de son domaine. void reorderWithinDomain(String domain, int oldIndex, int newIndex) { final current = [...groups]; final gi = current.indexWhere((g) => g.domain == domain); if (gi < 0) return; final loads = [...current[gi].loads]; if (oldIndex < 0 || oldIndex >= loads.length) return; if (newIndex > oldIndex) newIndex--; final item = loads.removeAt(oldIndex); loads.insert(newIndex.clamp(0, loads.length), item); current[gi] = current[gi].copyWith(loads: loads); _draft = current; notifyListeners(); } /// Déplace un domaine entier dans l'ordre des domaines. void reorderDomains(int oldIndex, int newIndex) { final current = [...groups]; if (oldIndex < 0 || oldIndex >= current.length) return; if (newIndex > oldIndex) newIndex--; final item = current.removeAt(oldIndex); current.insert(newIndex.clamp(0, current.length), item); _draft = current; notifyListeners(); } /// Classe une charge dans un domaine — ou l'en retire (`domain` vide). /// /// Modifie le **brouillon** : rien ne part avant [save]. La charge quitte son groupe /// et rejoint la fin du groupe cible, parce que l'insérer au milieu déciderait à la /// place de l'installateur d'un rang qu'il n'a pas choisi. /// /// Le domaine est une **intention déclarée**, pure métadonnée : l'arbitre ne la lit /// pas. Elle ne peut donc jamais être déduite du mécanisme — un relais pilote un /// chauffe-eau comme une prise commandée de recharge. void assignDomain(String loadId, String domain) { final current = [for (final g in groups) g.copyWith(loads: [...g.loads])]; LoadConfigEntry? moved; for (final g in current) { final i = g.loads.indexWhere((e) => e.id == loadId); if (i >= 0) { moved = g.loads.removeAt(i); break; } } if (moved == null) return; if ((moved.domain ?? kUnclassifiedDomain) == domain) return; // La charge utile repart de la map d'origine : seule la clé `domain` est réécrite. final patched = LoadConfigEntry(moved.patched(domain: domain)); final gi = current.indexWhere((g) => g.domain == domain); if (gi >= 0) { current[gi] = current[gi].copyWith(loads: [...current[gi].loads, patched]); } else { current.add(DomainGroup(domain: domain, loads: [patched])); } current.removeWhere((g) => g.loads.isEmpty); _draft = current; notifyListeners(); } /// Renomme une charge. **Brouillon** : rien ne part avant [save]. /// /// Le libellé n'est pas du matériel : le moteur ne le compare pas dans /// `sameHardware()`, donc renommer ne reconstruit rien et ne réarme aucun verrou. /// Conséquence mesurée sur `.75` le 2026-08-25, et qui surprend : l'adaptateur est /// **réutilisé tel quel**, si bien que le journal de la box continue d'afficher /// l'ANCIEN libellé jusqu'à la prochaine reconstruction. `GetLoadConfig`, lui, rend /// le nouveau immédiatement — c'est ce que l'app montre, et c'est le bon. void setLabel(String loadId, String label) { _patchEntry(loadId, (e) => e.patched(label: label)); } /// Fait entrer ou sortir une charge de l'arbitrage. **Brouillon**. /// /// `enabled: false` n'est pas un sectionnement, et l'UI ne doit pas le présenter /// ainsi. Ce qui met le matériel en sécurité, c'est le **retrait de l'adaptateur** /// (ECS-413) : la charge sort d'une configuration vivante, passe par /// `applySafeState()`, puis est détruite. Vérifié sur `.75` le 2026-08-25 — PAC en /// état 4, les deux contacts fermés, `enabled: false` posé : **les contacts se sont /// ouverts**. La nuance vaut quand même d'être tenue : le drapeau ne protège rien par /// lui-même, c'est le chemin de retrait qui agit, et il n'existe pas au redémarrage /// du service. void setEnabled(String loadId, bool enabled) { _patchEntry(loadId, (e) => e.patched(enabled: enabled)); } /// Remplace la charge utile d'une charge — sortie des écrans de mécanisme. /// /// [raw] doit venir de `LoadConfigEntry.patched()`, donc de la map reçue de la box. void replaceEntry(String loadId, Map raw) { _patchEntry(loadId, (_) => raw); } /// Applique un patch à une charge du brouillon, en préservant sa place. void _patchEntry(String loadId, Map Function(LoadConfigEntry) f) { final current = [for (final g in groups) g.copyWith(loads: [...g.loads])]; var touched = false; for (final g in current) { final i = g.loads.indexWhere((e) => e.id == loadId); if (i < 0) continue; g.loads[i] = LoadConfigEntry(f(g.loads[i])); touched = true; break; } if (!touched) return; _draft = current; notifyListeners(); } /// Ce que l'enregistrement en brouillon va provoquer, charge par charge. /// /// **À montrer AVANT d'écrire.** Une reconstruction ouvre les contacts et réarme les /// verrous à froid ; sur une PAC à `minStateHoldS` de 900 s, l'installateur qui /// corrige une ligne fige sa machine un quart d'heure. Le cas le moins intuitif est le /// simple réordonnancement de `relays[]` — la comparaison du moteur est indexée. List pendingImpact() { final draft = _draft; if (draft == null) return const []; return impactOfSave( avant: _entries, apres: [for (final g in draft) ...g.loads], ); } /// Vrai si l'enregistrement en brouillon touche au matériel d'au moins une charge. bool get pendingNeedsWarning => pendingImpact().any((i) => i.needsWarning); /// Prépare une normalisation — **ne l'écrit pas**. L'installateur doit confirmer. void prepareNormalization() { final g = _grouped; if (g == null) return; _draft = applyDefaultDomainOrder(g.groups); notifyListeners(); } /// Abandonne le brouillon et revient à l'état de la box. void discardDraft() { _draft = null; _conflict = null; notifyListeners(); } // ── Écriture ─────────────────────────────────────────────────────────────── /// Envoie l'ordre en brouillon. Ne fait rien s'il n'y a pas de brouillon — /// aucune écriture ne peut donc partir d'un simple affichage. Future save() async { final service = _service; final draft = _draft; if (service == null || draft == null) return; // Miroir client de validateSet() : produire un message utile plutôt qu'un rejet sec. // La box reste l'autorité — ceci n'est qu'un confort d'affichage. final flat = [for (final g in draft) ...g.loads]; final conflict = validateSet(flat); if (conflict != null) { _conflict = conflict; _saveState = LoadSaveState.rejected; notifyListeners(); return; } final payload = flattenToPayload(draft); _expectedSignature = _signatureOfPayload(payload); _conflict = null; _saveError = null; _saveState = LoadSaveState.pending; notifyListeners(); final err = await service.setLoadConfig(payload); if (err != null) { _saveState = LoadSaveState.rejected; _saveError = err; _expectedSignature = null; notifyListeners(); return; } // Acquittement reçu — on reste en « en cours » jusqu'à LoadConfigChanged. // Si la notification n'arrive pas, on relit plutôt que d'annoncer un succès. _confirmTimeout?.cancel(); _confirmTimeout = Timer(const Duration(seconds: 6), () async { if (_saveState != LoadSaveState.pending) return; await load(); _reconcile(); }); } void _onBoxConfirmed(List> configs) { _adopt(configs); _reconcile(); notifyListeners(); } void _reconcile() { _confirmTimeout?.cancel(); if (_expectedSignature == null) { // Changement venu d'ailleurs (fichier édité, autre client) : on adopte, sans // prétendre qu'il s'agit du résultat d'une action locale. notifyListeners(); return; } final actual = _signatureOfEntries(_entries); _saveState = actual == _expectedSignature ? LoadSaveState.confirmed : LoadSaveState.diverged; _expectedSignature = null; notifyListeners(); } /// Signature de ce que l'app est autorisée à écrire : `id:priority:domain:label: /// enabled`, plus une empreinte du matériel. Comparer plus large ferait crier à la /// divergence sur un champ que l'app n'a pas touché ; comparer plus étroit laisserait /// passer une écriture de mécanisme silencieusement refusée. static String _signatureOfPayload(List> payload) { final rows = [ for (final m in payload) _rowOf(LoadConfigEntry(m)), ]..sort(); return rows.join('|'); } /// Empreinte d'une charge : ce que l'app écrit, matériel compris. static String _rowOf(LoadConfigEntry e) { final relays = [for (final r in e.relays) '${normalizeThingId(r.thingId)}@${r.powerW}']; final sg = [for (final s in e.sgReadyStates) '${s.state}:${s.estimatedPowerW}']; return '${e.id}:${e.priority}:${e.domain ?? ''}:${e.label}:${e.enabled}' ':${e.minOnS}/${e.minOffS}:${e.maxPowerW}:${relays.join(",")}' ':${e.minStateHoldS}:${sg.join(",")}'; } static String _signatureOfEntries(List entries) { final rows = [ for (final e in entries) _rowOf(e), ]..sort(); return rows.join('|'); } /// Vide l'état au switch d'installation — la config appartient à la box quittée. void clearForSwitch() { _entries = const []; _grouped = null; _draft = null; _error = null; _conflict = null; _saveError = null; _saveState = LoadSaveState.idle; _expectedSignature = null; _confirmTimeout?.cancel(); notifyListeners(); } @override void dispose() { _confirmTimeout?.cancel(); _unsubscribe?.call(); super.dispose(); } }