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. /// Déplace une charge à l'intérieur de **sa plage**, désignée par son index. /// /// ⚠️ Elle était désignée par son **domaine** jusqu'au 2026-08-29. Depuis qu'un groupe /// est une plage contiguë, un même domaine peut en occuper plusieurs — `heating` séparant /// deux plages `ev`, par exemple — et une clé de domaine désignerait alors la première /// venue. L'index est le seul identifiant exact d'une plage. void reorderWithinGroup(int groupIndex, int oldIndex, int newIndex) { final current = [...groups]; final gi = groupIndex; if (gi < 0 || gi >= current.length) 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(); } /// **Placer une charge en dernier** — la sortir de sa plage, seule, en queue. /// /// ## Le manque qu'elle comble /// /// Les flèches déplacent une charge **dans sa plage** ; le glissement déplace une plage /// **entière**. Rien ne sortait une charge de son groupe : une borne regroupée avec les /// autres ne pouvait plus être renvoyée en queue, et son rang devenait prisonnier du /// bloc. Le geste manquait d'autant plus que le regroupement, lui, existe. /// /// ## Pourquoi « en dernier » et pas « n'importe où » /// /// C'est la position qu'une borne détectée reçoit d'office depuis `+etm27` (`1 + max`), /// donc un repère que l'installateur connaît. Et une fois la charge **seule dans sa /// plage**, le glissement de plage la porte partout ailleurs : ce geste rend la liberté /// de placement sans qu'il faille inventer un glisser-déposer entre blocs, qui /// demanderait de choisir un point d'insertion au pixel près sur un téléphone. /// /// Brouillon seulement : rien ne part avant [save]. void moveToTail(String loadId) { // Déjà la dernière de la dernière plage : ne rien faire plutôt que de poser un // brouillon identique, qui allumerait la barre d'enregistrement pour une écriture sans // effet. Le test porte sur l'état AVANT retrait — après, la plage d'origine a pu // disparaître, et la comparaison ne veut plus rien dire. final avant = groups; if (avant.isNotEmpty && avant.last.loads.isNotEmpty && avant.last.loads.last.id == loadId) { return; } final current = [for (final g in groups) g.copyWith(loads: [...g.loads])]; LoadConfigEntry? deplacee; for (final g in current) { final i = g.loads.indexWhere((e) => e.id == loadId); if (i >= 0) { deplacee = g.loads.removeAt(i); break; } } if (deplacee == null) return; current.removeWhere((g) => g.loads.isEmpty); final domaine = deplacee.domain ?? kUnclassifiedDomain; // Elle rejoint la dernière plage si celle-ci porte son domaine — sinon elle ouvre la // sienne. Deux plages de même domaine collées seraient artificielles : elles n'en font // qu'une à la relecture, puisqu'une plage est une suite contiguë. if (current.isNotEmpty && current.last.domain == domaine) { current[current.length - 1] = current.last.copyWith(loads: [...current.last.loads, deplacee]); } else { current.add(DomainGroup(domain: domaine, loads: [deplacee])); } _draft = current; notifyListeners(); } /// **Regrouper par domaine, à la demande** — jamais à l'enregistrement. /// /// Rassemble les plages d'un même domaine à la position de leur première apparition, et /// pose le résultat en **brouillon** : l'installateur voit le nouvel ordre, peut /// l'annuler, et rien ne part avant `save()`. C'est ce que l'enregistrement faisait en /// douce jusqu'au 2026-08-29 — un regroupement subi est une machine qui réordonne ce /// qu'un humain a décidé. void groupByDomainNow() { final regroupe = groupContiguous(groups); // Rien à faire si l'ordre est déjà groupé : poser un brouillon identique allumerait la // barre d'enregistrement pour une écriture sans effet. final avant = [for (final g in groups) ...g.loads.map((e) => e.id)]; final apres = [for (final g in regroupe) ...g.loads.map((e) => e.id)]; if (avant.join('|') == apres.join('|')) return; _draft = regroupe; 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)); // La DERNIÈRE plage du domaine, pas la première : un domaine peut en occuper // plusieurs depuis que les groupes sont des plages contiguës, et remonter la charge // dans la première la ferait sauter des rangs que personne n'a touchés. La dernière // est la plus proche de « à la fin de ce domaine », qui était l'intention d'origine. final gi = current.lastIndexWhere((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(); }); } /// **Supprime une charge de la configuration** — le seul geste de l'app qui DÉTRUIT. /// /// ## Pourquoi il existe /// /// Le plugin crée d'office une entrée par borne détectée, mais **ne la retire pas quand /// le Thing s'en va** : l'entrée survit, occupe un rang, et se publie en `THING_MISSING`. /// Constaté sur `.75` avec une ABB Terra AC remplacée par une V2C, dont l'entrée tenait /// encore un rang deux jours plus tard. `ClearLoadFault` n'y peut rien — il n'y a plus /// d'appareil à qui parler. /// /// ## Le chemin, et pourquoi c'est celui-là /// /// **LIRE → PATCHER → RÉÉCRIRE**, sur les charges utiles brutes de la box. Jamais une /// reconstruction depuis le modèle typé : `SetLoadConfig` remplace **tout** l'ensemble, /// donc supprimer une charge, c'est réécrire toutes les autres. Un champ que le modèle /// ne connaîtrait pas — box plus récente que l'app — serait perdu pour toutes les /// charges d'un coup, en silence. /// /// La lecture est **fraîche**, pas l'état en mémoire : entre l'ouverture de l'écran et /// le geste, la box a pu changer d'avis, et réécrire un état périmé effacerait ce /// changement. /// /// ## Rien à moitié écrit /// /// Un refus de la box laisse la configuration locale **intacte** : aucune adoption avant /// la confirmation, et l'écriture elle-même est atomique côté box — `validateSet()` /// refuse l'ensemble ou l'accepte en entier, il n'y a pas d'état intermédiaire. /// /// Refusé aussi tant qu'un **brouillon** est en cours : sinon la suppression emporterait /// des modifications non enregistrées que personne n'a demandé d'écrire. /// /// \return `null` si la box a accepté, sinon un code : `PendingDraft`, `NotFound`, /// `NoService`, ou le refus de la box tel qu'elle le nomme. Future removeLoad(String loadId) async { final service = _service; if (service == null) return 'NoService'; if (_draft != null) return 'PendingDraft'; _removalDiff = const []; // 1. LIRE — la charge utile de la box, à cet instant. final List> frais; try { frais = await service.getLoadConfig(); } catch (e) { _saveError = e.toString(); _saveState = LoadSaveState.rejected; notifyListeners(); return _saveError; } if (frais.isEmpty) return 'NotFound'; // 2. PATCHER — règle pure, éprouvée à part (`payloadWithoutLoad`). final reste = payloadWithoutLoad(frais, loadId); if (reste == null) return 'NotFound'; // ⚠️ `1.15.2+etm27` refuse EN BLOC un ensemble où deux charges partagent un rang — et // le motif ne franchit pas la frontière RPC. // // Ce chemin réécrit les survivantes VERBATIM, donc il transporte les rangs tels que la // box les porte. Or c'est le plugin lui-même qui a créé des égalités avant `+etm27` // (borne détectée au rang 1, sur une installation où le rang 1 était pris). Une // suppression parfaitement légitime serait donc refusée, sans cause visible. // // On refuse ICI, avec la raison — plutôt que de renuméroter en douce : un geste de // nettoyage qui redistribue les rangs devient un geste d'arbitrage, et c'est // exactement ce que ce chemin s'interdit. final apres = [for (final m in reste) LoadConfigEntry(m)]; if (tiedByPriority(apres).isNotEmpty) { _saveError = 'La configuration porte des rangs à égalité, que la box refuse ' 'depuis 1.15.2+etm27. Classez les charges avant de retirer celle-ci.'; _saveState = LoadSaveState.rejected; notifyListeners(); return 'TiedRanks'; } // 3. RÉÉCRIRE. _expectedSignature = _signatureOfPayload(reste); _conflict = null; _saveError = null; _saveState = LoadSaveState.pending; notifyListeners(); final err = await service.setLoadConfig(reste); if (err != null) { // Rien n'a bougé : ni la box (refus en bloc), ni l'état local (aucune adoption). _saveState = LoadSaveState.rejected; _saveError = err; _expectedSignature = null; notifyListeners(); return err; } // 4. RELIRE ET VÉRIFIER, tout de suite — pas « la prochaine fois ». // // L'acquittement ne vaut pas application, et sur le SEUL geste qui détruit de la // configuration, « la box a dit oui » ne suffit pas : il faut voir ce qu'elle porte // ensuite. La relecture est immédiate et synchrone du point de vue de l'appelant — // attendre `LoadConfigChanged` laisserait une fenêtre pendant laquelle un autre geste // peut s'intercaler, et c'est exactement ce qui a rendu la vérification manuelle du // 2026-08-28 inconcluante : deux écritures à deux minutes d'écart, impossible de dire // laquelle avait bougé les rangs. List> relu; try { relu = await service.getLoadConfig(); } catch (e) { _saveState = LoadSaveState.diverged; _saveError = 'Suppression envoyée, mais la relecture a échoué ($e).'; _expectedSignature = null; notifyListeners(); return 'RereadFailed'; } _adopt(relu); _expectedSignature = null; _confirmTimeout?.cancel(); // Le contrôle de l'aller-retour neutre, appliqué à une écriture qui retire : les // survivantes doivent être identiques clé par clé, rangs compris. final ecarts = diffPayloads(reste, relu); if (ecarts.isEmpty) { _saveState = LoadSaveState.confirmed; _saveError = null; notifyListeners(); return null; } _saveState = LoadSaveState.diverged; _removalDiff = ecarts; _saveError = 'La charge est retirée, mais ${ecarts.length} autre(s) écart(s) : ' '${ecarts.take(3).join(' · ')}'; notifyListeners(); return 'Diverged'; } /// **Adopter le rang posé d'office** — écrit `rankOrigin: "user"` sur une charge. /// /// ## Ce que ce geste AFFIRME /// /// « Ce rang me va, je l'assume » — l'installateur adopte un rang qu'il n'a pas choisi, /// sans le déplacer. Ce n'est **pas** « j'ai vu le badge » : c'est un jugement porté sur /// l'ordre de service, et c'est la seule voie pour éteindre le badge sans changer /// l'arbitrage. /// /// D'où trois règles, et elles sont dans le code parce qu'elles sont faciles à perdre : /// /// 1. **Le geste est explicite, jamais un effet de bord.** Cette méthode n'est appelée /// que par le bouton du badge. Écrire `"user"` parce que l'utilisateur a enregistré /// autre chose affirmerait un jugement qu'il n'a pas porté — exactement ce qu'on /// reproche à un `"auto"` fabriqué côté client. /// 2. **Il ne s'annule pas tout seul.** Une fois posé, seul le moteur l'efface, quand un /// rang différent arrive pour cet id. L'app ne le retire jamais. /// 3. **Il ne remplace pas le déplacement.** Pour un autre rang, on glisse la charge ; /// `"user"` est pour le cas où le défaut convient. Le libellé du bouton le dit. /// /// Même chemin d'écriture que la suppression — lire → patcher → réécrire verbatim, avec /// le garde-fou des rangs à égalité et la relecture qui vérifie — parce que c'est le même /// danger : `SetLoadConfig` remplace tout l'ensemble. /// /// \return `null` si la box a accepté, sinon un code. Future adoptRank(String loadId) async { final service = _service; if (service == null) return 'NoService'; if (_draft != null) return 'PendingDraft'; _removalDiff = const []; final List> frais; try { frais = await service.getLoadConfig(); } catch (e) { _saveError = e.toString(); _saveState = LoadSaveState.rejected; notifyListeners(); return _saveError; } var trouvee = false; final envoye = >[]; for (final m in frais) { if (m['id'] == loadId) { trouvee = true; // Seul `rankOrigin` change. Le rang, lui, ne bouge PAS : adopter n'est pas // reclasser, et un geste qui déplacerait la charge en même temps rendrait // l'affirmation fausse. envoye.add({...m, 'rankOrigin': 'user'}); } else { envoye.add(Map.from(m)); } } if (!trouvee) return 'NotFound'; // Même refus que la suppression : sur une configuration héritée d'avant `+etm27`, la // box rejetterait l'ensemble sans motif lisible. if (tiedByPriority([for (final m in envoye) LoadConfigEntry(m)]).isNotEmpty) { _saveError = 'La configuration porte des rangs à égalité, que la box refuse ' 'depuis 1.15.2+etm27. Classez les charges d\'abord.'; _saveState = LoadSaveState.rejected; notifyListeners(); return 'TiedRanks'; } _expectedSignature = _signatureOfPayload(envoye); _conflict = null; _saveError = null; _saveState = LoadSaveState.pending; notifyListeners(); final err = await service.setLoadConfig(envoye); if (err != null) { _saveState = LoadSaveState.rejected; _saveError = err; _expectedSignature = null; notifyListeners(); return err; } // Relire et vérifier tout de suite : l'acquittement ne vaut pas application, et une // adoption qui aurait déplacé autre chose serait pire que pas d'adoption du tout. List> relu; try { relu = await service.getLoadConfig(); } catch (e) { _saveState = LoadSaveState.diverged; _saveError = 'Adoption envoyée, mais la relecture a échoué ($e).'; _expectedSignature = null; notifyListeners(); return 'RereadFailed'; } _adopt(relu); _expectedSignature = null; _confirmTimeout?.cancel(); final ecarts = diffPayloads(envoye, relu); if (ecarts.isEmpty) { _saveState = LoadSaveState.confirmed; _saveError = null; notifyListeners(); return null; } _saveState = LoadSaveState.diverged; _removalDiff = ecarts; _saveError = 'Rang adopté, mais ${ecarts.length} écart(s) : ' '${ecarts.take(3).join(' · ')}'; notifyListeners(); return 'Diverged'; } /// Écarts constatés par la dernière suppression — vide quand elle n'a touché à rien /// d'autre. Ce n'est pas un échec de l'app : c'est ce que la box porte, et il faut le /// montrer plutôt que de le taire. List get removalDiff => List.unmodifiable(_removalDiff); List _removalDiff = const []; 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(); } }