Manque trouvé en usage, pas en test : les flèches déplacent une charge DANS son bloc, le glissement déplace le bloc ENTIER. Rien ne sortait une charge de son groupe. Une borne regroupée avec les autres devenait donc prisonnière du bloc — et depuis que « Regrouper par domaine » existe, ce cas se produit d'un simple geste. Le bouton la renvoie en queue, seule dans sa plage. C'est la position qu'une borne détectée reçoit d'office depuis +etm27 (1 + max), donc un repère connu — et une fois seule, le glissement de plage la porte partout ailleurs. Ça rend la liberté de placement sans inventer un glisser-déposer entre blocs, qui demanderait de viser un point d'insertion au pixel près sur un téléphone. Brouillon seulement : rien ne part avant save(). Masqué quand la charge y est déjà — un bouton qui ne fait rien use la confiance dans ceux qui font quelque chose. Le contrôle porte sur l'état AVANT retrait : après, la plage d'origine a pu disparaître et la comparaison ne veut plus rien dire (première version fausse, prise par son propre test). ⚠️ Et une assertion Flutter trouvée par les tests de glissement, dont la cause est à trois widgets de son symptôme : `TextButton.styleFrom(textStyle:)` porte `inherit: true` là où le style du thème porte `inherit: false`, et Flutter refuse d'interpoler les deux pendant une animation — « Failed to interpolate TextStyles with different inherit values ». Le style va donc sur le LIBELLÉ, pas dans le ButtonStyle. Les deux boutons posés hier (« Regrouper par domaine », « Ce rang me convient ») portaient la même bombe à retardement : corrigés aussi. 231 tests (+5), flutter analyze inchangé à 27 remarques. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SQbZKrWqsMFP1Lh2jjjd9f
722 lines
28 KiB
Dart
722 lines
28 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.
|
|
/// 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<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';
|
|
}
|
|
|
|
/// **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<String?> adoptRank(String loadId) async {
|
|
final service = _service;
|
|
if (service == null) return 'NoService';
|
|
if (_draft != null) return 'PendingDraft';
|
|
_removalDiff = const [];
|
|
|
|
final List<Map<String, dynamic>> frais;
|
|
try {
|
|
frais = await service.getLoadConfig();
|
|
} catch (e) {
|
|
_saveError = e.toString();
|
|
_saveState = LoadSaveState.rejected;
|
|
notifyListeners();
|
|
return _saveError;
|
|
}
|
|
|
|
var trouvee = false;
|
|
final envoye = <Map<String, dynamic>>[];
|
|
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<String, dynamic>.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<Map<String, dynamic>> 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<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();
|
|
}
|
|
}
|