etm-powersync-app/lib/providers/load_config_provider.dart
Patrick Schurig 2c193077f7 fix(config): +etm27 refuse deux rangs identiques — l'app le voit avant la box
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
2026-08-28 16:06:11 +02:00

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();
}
}