etm-powersync-app/lib/providers/load_config_provider.dart
Patrick Schurig 2a8ad95120 feat(config): lot C — un seul écran de configuration, et le grisage vient du schéma
FUSION. L'écran « Ordre de service » est supprimé ; sa route redirige. C'est son CHEMIN DE
DONNÉES (LoadConfigProvider) qui a migré dans la présentation de « Rôles & appareils »,
jamais l'inverse. EmsRole fige six rôles dont dhw ET heatPump séparés — or LM-201 dit qu'une
PAC sans ballon séparé est UNE charge : le modèle des rôles aurait exigé deux charges sur un
canal unique, ce que validateSet() refuse à raison. Les charges viennent donc de
GetLoadConfig. Les compteurs restent sur EnergySetupProvider (autre contrat, SetRootMeter).

La note « le pilote peut bousculer l'ordre les jours Tempo rouge ou à l'approche d'une
échéance » est retirée : aucun de ces comportements n'existe dans le moteur.

ANNONCE AVANT ÉCRITURE. Miroir exact de EnergyArbitrator::sameHardware(). Le piège, vérifié
sur .75 : la comparaison des relais est INDEXÉE, donc réordonner relays[] sans rien changer
d'autre reconstruit l'adaptateur — contacts ouverts (applySafeState, force) et verrous
réarmés à froid. Un glisser-déposer dans la liste des contacteurs est un geste matériel.
L'écran le nomme, avec la durée de réarmement.

GRISAGE PAR INTROSPECTION. RpcSchema lit JSONRPC.Introspect à la connexion ; trois états et
non deux — écrivable, r: (mesure, pas fonction manquante), absent — plus « inconnu » quand le
schéma n'a pas pu être lu. Chaque champ grisé porte sa raison et le nom du champ manquant.
La version affichée est celle de l'API d'expérience (NymeaEnergy 0.8) : le paquet +etm15
n'est exposé par aucune méthode.

CORRIGÉ AU PASSAGE — les modes de recharge étaient INOPÉRANTS. setChargingInfo() appelait
EnergyPlugin.SetChargingInfo (namespace inexistant) avec mode/targetSoc/endTime/minCurrent
au lieu de chargingMode/targetPercentage/endDateTime, et minCurrent n'existe dans aucun
champ. Vérifié par appel réel : « Missing required key: chargingMode ». Le refus était de
plus avalé et l'état local mis à jour quand même. Désormais l'état ne suit que si la box
accepte, et le refus remonte sans en inventer la cause.

ZONES — constaté, pas corrigé. L'hypothèse « champs grisés » est fausse : AirConditioning
publie 8 méthodes toutes en écriture. ac_screen.dart n'en appelle aucune, ses 4 pièces sont
des constantes de maquette, et .75 déclare zéro zone. getZones() ajouté + bandeau qui lit le
vrai compte et annonce la maquette. Le câblage est un lot en soi, non exerçable sans zone.

Tests 80/80, analyze 0 erreur. Relevé : docs/RELEVE_LOTC.md — dont §6, ce qui n'a PAS pu
être vérifié : l'IU n'a pas pu être pilotée sur l'appareil, MIUI refusant INJECT_EVENTS.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HajXLUczEyZd22JeewRfff
2026-08-25 22:17:16 +02:00

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