Livré AVANT le déploiement, parce que ce refus-là est muet : le motif ne franchit pas la frontière RPC, et le symptôme au doigt aurait été « mon glissement ne s'enregistre pas », sans cause visible à l'écran. Même famille que le §9 du contrat de données. Trois points vérifiés, dans l'ordre demandé : 1. LE RÉAPLATISSAGE REND TOUJOURS DES RANGS DISTINCTS. flattenToPayload numérote 1..n sur l'ordre courant, donc par construction — mais « par construction » ne se vérifie pas tout seul : le test le refait après CHAQUE permutation de domaines et après un déplacement intra-domaine, en partant de la config du banc qui porte déjà deux charges au rang 1. Les états intermédiaires du glissement ne partent pas : seul `save()` écrit. 2. RIEN NE PART SI LE BROUILLON N'EST PAS COHÉRENT. validateSet() — le miroir client de LoadConfigStore::validateSet() — apprend le contrôle des rangs et rend un conflit `tiedPriority` qui NOMME les deux charges et le rang partagé. L'écran distingue désormais les trois refus (thing revendiqué deux fois, identifiant en double, rangs à égalité) là où il annonçait « deux charges revendiquent le même appareil » pour tout. 3. ⚠️ LE CHEMIN DE SUPPRESSION ÉTAIT LE PLUS EXPOSÉ, et rien ne l'aurait montré. Il réécrit les survivantes VERBATIM — c'est sa règle, et elle est bonne — 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 d'une installation déjà réglée). Une suppression légitime aurait donc été refusée en bloc, sans motif. Elle refuse maintenant côté app, avec la raison — plutôt que de renumérer en douce : un geste de nettoyage qui redistribue les rangs devient un geste d'arbitrage. Deux tests de load_priority_test construisaient leurs entrées sans `priority` : les deux tombaient donc à égalité, et le nouveau contrôle masquait la règle qu'ils éprouvaient. Rangs distincts posés, avec la raison en commentaire. Les deux autres nouveautés d'+etm27 sont au TODO, non codées : `o:rankOrigin` (badge « rang par défaut, pas un choix », avec la règle absent = rien d'affirmé) et l'entrée en queue des bornes auto-provisionnées — qui devrait faire taire d'elle-même la note d'ordre entrelacé. 213 tests (+8), flutter analyze inchangé à 27 remarques. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SQbZKrWqsMFP1Lh2jjjd9f
523 lines
20 KiB
Dart
523 lines
20 KiB
Dart
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<LoadConfigEntry> _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<DomainGroup>? _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<LoadConfigEntry> 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<DomainGroup> 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<String> 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<void> 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<Map<String, dynamic>> 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<Map<String, dynamic>> 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<String, dynamic> raw) {
|
|
_patchEntry(loadId, (_) => raw);
|
|
}
|
|
|
|
/// Applique un patch à une charge du brouillon, en préservant sa place.
|
|
void _patchEntry(String loadId, Map<String, dynamic> 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<LoadImpact> 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<void> 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<String?> 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<Map<String, dynamic>> 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<Map<String, dynamic>> 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';
|
|
}
|
|
|
|
/// É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<String> get removalDiff => List.unmodifiable(_removalDiff);
|
|
List<String> _removalDiff = const [];
|
|
|
|
void _onBoxConfirmed(List<Map<String, dynamic>> 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<Map<String, dynamic>> 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<LoadConfigEntry> 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();
|
|
}
|
|
}
|