Lot B côté app, partie NON bloquée par le lot plugin (voir §Limites).
§1 Locale (indépendant) — JSONRPC.Hello portait {} : l'app était en en_US de
fait alors que nymea gère la locale PAR CONNEXION (o:locale). Envoi de la
locale à chaque Hello, donc aussi après reconnexion subie (m_clientLocales
repart au défaut de la box à chaque socket). Locale demandée ET retenue
journalisées : un .qm introuvable rend exactement comme une absence de
traduction, sans cette trace un catalogue mal packagé passe inaperçu.
§2 i18n — flutter_localizations + intl + ARB (français seul). Aucune chaîne
en dur dans le nouvel écran. Les codes reçus de la box (domaine, mécanisme)
sont des clés ARB avec repli explicite sur code brut si l'app ne les connaît
pas : une box en avance sur l'app est un cas normal en parc déployé.
§3 Modèle — LoadConfigEntry est une VUE en lecture au-dessus de la map brute ;
l'écriture repart de la map d'origine (patched()). Pas de couche de filtrage
préventive : l'aller-retour neutre passe contre .75, donc l'écho verbatim est
sûr par construction (§7-1).
§4 Priorité à deux niveaux — groupByDomain / flattenToPayload. Le rang de
domaine se déduit de l'ordre d'apparition à plat ; la box ne sait rien des
groupes. Config non contiguë : signalée, ordre réel affiché, normalisation
seulement sur confirmation explicite. Miroir client de validateSet().
§5 Écritures asynchrones — LoadConfigProvider ne conclut jamais sur
l'acquittement du RPC : pending → LoadConfigChanged → confirmed|diverged.
NymeaEnergy.GetLoadConfig / SetLoadConfig deviennent RÉELS (ils étaient des
stubs journalisés). Conséquence traitée : EnergySetupProvider.persist()
n'émet plus setLoadConfig(buildLoadDescriptors()) — SetLoadConfig remplace
TOUT l'ensemble, et _commit() l'appelait à chaque changement d'UI : une
reconstruction depuis le modèle typé aurait écrasé la config réelle de la
box sans geste explicite.
Écran /energy/loads, gaté installateur (drawer + garde de routeur).
Limites — le lot plugin n'est PAS déployé sur .75 : GetLoadTelemetry,
LoadTelemetryChanged et o:domain sont absents (19 méthodes / 13 notifications
inchangées). Toute la moitié télémétrie du brief est donc hors d'atteinte, et
o:domain n'étant pas au schéma, la box rejetterait la clé (jsonvalidator.cpp:143).
Fixtures multi-domaines synthétiques : .75 n'a qu'une charge.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
257 lines
8.3 KiB
Dart
257 lines
8.3 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:flutter/foundation.dart';
|
|
|
|
import '../models/load_config_entry.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;
|
|
}
|
|
|
|
// ── É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();
|
|
}
|
|
|
|
/// 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 = suite `id:priority:domain` dans l'ordre de service. C'est exactement
|
|
/// ce que ce lot est autorisé à modifier ; comparer plus large ferait crier à la
|
|
/// divergence sur un champ que l'app n'a pas touché.
|
|
static String _signatureOfPayload(List<Map<String, dynamic>> payload) {
|
|
final rows = [
|
|
for (final m in payload)
|
|
'${m['id']}:${m['priority']}:${m['domain'] ?? ''}',
|
|
]..sort();
|
|
return rows.join('|');
|
|
}
|
|
|
|
static String _signatureOfEntries(List<LoadConfigEntry> entries) {
|
|
final rows = [
|
|
for (final e in entries) '${e.id}:${e.priority}:${e.domain ?? ''}',
|
|
]..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();
|
|
}
|
|
}
|