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