`"user"` n'est pas « j'ai vu le badge ». C'est « ce rang me va, je l'assume » : un jugement porté sur l'ordre de service, et la seule voie pour éteindre le badge SANS changer l'arbitrage. Les trois conséquences sont dans le code, parce qu'elles sont faciles à perdre. 1. LE GESTE EST EXPLICITE. Un bouton sur le badge, appelé de là et de nulle part ailleurs. Jamais une écriture au passage, jamais l'effet de bord d'un autre enregistrement : écrire "user" parce que l'utilisateur a touché à autre chose affirmerait un jugement qu'il n'a pas porté — le reproche exact qu'on fait à un "auto" fabriqué côté client. 2. IL NE S'ANNULE PAS TOUT SEUL. Une fois posé, seul le moteur le lève, quand un rang différent arrive pour cet id. L'app ne le retire jamais. 3. IL NE REMPLACE PAS LE DÉPLACEMENT, et le libellé le dit : « Adopter ce rang ne le change pas. Pour en changer, glissez la charge. » Sans cette phrase, un installateur valide un ordre qu'il voulait changer — et n'y reviendra pas, puisque le badge s'est éteint. L'écriture emprunte le chemin de la suppression, pour la même raison : SetLoadConfig remplace tout l'ensemble. Lire (frais) → patcher le SEUL rankOrigin de la SEULE charge visée → réécrire verbatim → relire et comparer clé par clé. Le rang ne bouge pas : « ce rang me va » ne peut pas porter sur un rang qu'on vient de changer. Garde-fou des rangs à égalité inclus, et refus si un brouillon est en cours. Vérifié au passage, comme demandé : sansRankOriginAuto() laisse passer "user" — le confondre avec "auto" aurait rendu le bouton silencieusement inopérant. Test dédié, en plus de celui qui existait. Quatre tests de plus : le bouton est là sur "auto" avec sa phrase, absent sur "user" et sur champ vide, et la charge utile d'une adoption ne diffère que par rankOrigin. ⚠️ Non vérifiable sur `.75` : les deux bornes y sont antérieures à `+etm27`, donc aucun "auto" n'y est observable. À reprendre au premier appairage réel d'une borne. 226 tests (+4), flutter analyze inchangé à 27 remarques. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SQbZKrWqsMFP1Lh2jjjd9f
636 lines
24 KiB
Dart
636 lines
24 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';
|
|
}
|
|
|
|
/// **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();
|
|
}
|
|
}
|