etm-powersync-app/lib/providers/load_config_provider.dart
Patrick Schurig 51a1ab929c feat(config): retirer une charge orpheline, et régler le mode de recharge à un seul endroit
Deux manques trouvés en usage réel sur téléphone, contre .75.

── 1. Retirer une charge dont l'appareil n'existe plus ────────────────────────

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 : la Terra AC supprimée de nymea tenait encore le rang 4 deux jours plus tard, et
l'app savait le dire sans savoir y remédier.

Le bouton n'apparaît que sur THING_MISSING. Sur une charge vivante le geste n'aurait pas de
sens — le plugin recrée l'entrée au cycle suivant, et un bouton qui se défait tout seul
ferait douter de la box.

Le chemin est celui qui était demandé, et pour la raison qui était donnée :
  · LIRE (GetLoadConfig, frais — pas l'état en mémoire, la box a pu changer d'avis)
  · PATCHER (payloadWithoutLoad : les autres charges recopiées VERBATIM, champ par champ)
  · RÉÉCRIRE (SetLoadConfig, qui remplace l'ensemble)

Jamais une reconstruction depuis le modèle typé : un champ qu'une box plus récente porterait
et que l'app ne connaît pas serait perdu pour TOUTES les charges d'un coup, en silence. Les
rangs ne sont pas renumérotés — le trou laissé (1,2,3,5) ne change l'ordre de personne,
alors qu'une renumérotation réécrirait le rang de charges que personne n'a touchées.

Rien à moitié écrit : un refus laisse la configuration locale intacte (aucune adoption avant
confirmation) et la box refuse l'ensemble ou l'accepte en entier. Le geste est refusé tant
qu'un brouillon est en cours — sinon la suppression emporterait des modifications non
enregistrées. La confirmation annonce ce que l'utilisateur ne peut pas deviner : retirer une
charge réécrit toutes les autres.

── 2. Le mode de recharge, un seul lieu d'écriture ───────────────────────────

Les trois boutons quittent la tuile des favoris pour l'écran « Configurer » de la borne :
c'est l'écran de mise en service, celui où on découvre qu'une borne neuve est en
ChargingModeNormal. Deux endroits qui écrivent SetChargingInfo seraient deux comportements
qui divergent.

La tuile des favoris garde la LECTURE et l'avertissement « hors arbitrage » : c'est le
client qui ouvre ses favoris, et il doit voir que sa borne n'est pas pilotée même s'il doit
aller ailleurs pour le corriger.

⚠️ Bug latent trouvé en déplaçant : setChargingInfo écrivait toujours sur la PREMIÈRE borne
configurée (_findEvChargerId). Sur le banc qui en porte trois, régler la V2C depuis son
propre écran aurait réglé la wallbox simulée. La borne est maintenant visée explicitement,
et evChargerFor() réconcilie les deux graphies d'identifiant (§9 du contrat de données).

181 tests (+12), 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 14:36:50 +02:00

465 lines
17 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';
// 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';
// 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;
}
// L'acquittement ne vaut pas application : on attend `LoadConfigChanged`, avec le même
// filet de relecture que `save()`.
_confirmTimeout?.cancel();
_confirmTimeout = Timer(const Duration(seconds: 6), () async {
if (_saveState != LoadSaveState.pending) return;
await load();
_reconcile();
});
return null;
}
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();
}
}