etm-powersync-app/lib/providers/energy_setup_provider.dart
Patrick Schurig c638ec6c52 feat: connexions multi-HEMS + rôles & appareils (beta add/config)
Lot Connexions multi-HEMS :
- modèle Installation + InstallationStore (persistance par UUID ;
  métadonnées SharedPreferences, token via flutter_secure_storage)
- ConnectionManager au-dessus de nymea_service : mono-connexion, switchTo,
  connectNew, enterDemo (démo = active factice), contrôle d'identité DHCP
- nymea_service : connect() réveillé (ws://4444, token par UUID), capture
  Hello (uuid/name/initialSetupRequired), authenticate/createUser, resetState
- écran Installations (3 états) + déverrouillage installateur depuis la racine
- écran Connexion : auth 2 branches (JSONRPC.Authenticate / CreateUser),
  détection auto via initialSetupRequired, segmenteur de repli
- gate de routage (redirect + refreshListenable merge[cm,svc]) ;
  hasActiveConnection = isSimulation || (connected && authenticated)
- en-tête drawer cliquable → Installations
- invalidation des caches au switch (service.resetState + clearForSwitch
  rôles/scheduler/tariff), sans persist()

Lot Rôles & appareils :
- EnergySetupProvider refondu en tri-état (present/absent/non-configuré)
- écran roles_devices_screen (3 zones, drag-priorité, inférence solaire/batterie)
- LoadDescriptor (etmvariableload, rév.2 : PAC exclue → SG-Ready) ;
  Energy.SetRootMeter réel, Get/SetLoadConfig en stub loggé

Correctifs :
- bug signe énergie : production +, consommation NÉGATIVE sur nymea 1.15.2
  → consumptionW/home en .abs() (autoconso n'est plus clouée à 0)
- UUID Hello brace-wrapped normalisé

Tests : flutter analyze 0 erreur ; gate + zéro-fuite au switch (5/5 verts).
Validé en vrai : login auth .120, .75 ouvert, switch, déverrouillage installateur.
Note : embarque aussi le WIP dashboard/thème déjà présent dans l'arbre.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 08:39:08 +02:00

488 lines
20 KiB
Dart

import 'package:flutter/foundation.dart';
import '../services/nymea_service.dart';
import '../models/nymea_models.dart';
// ─────────────────────────────────────────────────────────────────────────────
// Rôles EMS
// ─────────────────────────────────────────────────────────────────────────────
/// Rôles de l'EMS (Energy Management System).
///
/// Six rôles répartis en trois destinations (contrat) :
/// • Compteurs → [gridMeter] (rootMeter), [solarMeter] (détecté)
/// • Charges pilotables → [evCharger], [dhw], [heatPump], [battery]
/// • Absent → n'importe quel rôle acté « Pas d'appareil »
enum EmsRole {
gridMeter, // Compteur réseau → Energy.SetRootMeter
solarMeter, // Compteur solaire → inféré par interface
battery, // Batterie → inférée ; charge pilotable (priorité + needs)
evCharger, // Borne de recharge
dhw, // Chauffe-eau (ECS)
heatPump, // Pompe à chaleur / SG-Ready
}
extension EmsRoleExt on EmsRole {
String get label => switch (this) {
EmsRole.evCharger => 'Borne de recharge',
EmsRole.dhw => 'Chauffe-eau',
EmsRole.heatPump => 'Pompe à chaleur',
EmsRole.battery => 'Batterie',
EmsRole.solarMeter => 'Compteur solaire',
EmsRole.gridMeter => 'Compteur réseau',
};
String get icon => switch (this) {
EmsRole.evCharger => '🚗',
EmsRole.dhw => '🌡️',
EmsRole.heatPump => '♨️',
EmsRole.battery => '🔋',
EmsRole.solarMeter => '☀️',
EmsRole.gridMeter => '',
};
/// Interfaces nymea compatibles avec ce rôle.
List<String> get compatibleInterfaces => switch (this) {
EmsRole.evCharger => ['evcharger'],
EmsRole.dhw => ['simpleheatpump', 'relay', 'smartplug', 'powerswitch'],
EmsRole.heatPump => ['sgready', 'sgrelay', 'heatpump'],
EmsRole.battery => ['battery', 'energystorage', 'batterymonitor'],
EmsRole.solarMeter => ['solarinverter', 'energymeter', 'meter', 'inverter', 'smartmeter'],
EmsRole.gridMeter => ['energymeter', 'smartmeter', 'meter'],
};
/// Label d'interface affiché dans la liste de sélection des things.
String interfaceLabelFor(String iface) => switch (iface.toLowerCase()) {
'sgready' => 'SG-Ready',
'sgrelay' => 'SG-Ready',
'heatpump' => 'Heat Pump',
'simpleheatpump' => 'SimpleHeatpump',
'solarinverter' => 'Inverter',
'energymeter' => 'Meter',
'meter' => 'Meter',
'inverter' => 'Inverter',
'smartmeter' => 'Smart Meter',
'evcharger' => 'EV Charger',
'battery' => 'Battery',
'energystorage' => 'Energy Storage',
_ => iface,
};
}
/// Classification d'un rôle — détermine sa zone et son comportement (contrat).
extension EmsRoleClassify on EmsRole {
/// Rôle inféré par interface (solaire, batterie). Apparaît **direct** en zone
/// définitive avec un badge « détecté » — jamais en « À configurer », jamais
/// bloquant pour la complétude.
bool get isInferred => this == EmsRole.solarMeter || this == EmsRole.battery;
/// Rôle « pické » par l'installateur (réseau, EV, ECS, PAC). Compté quand
/// assigné **ou** acté « Pas d'appareil ».
bool get isPicked => !isInferred;
/// Compteur → zone Compteurs, carte non draggable.
bool get isMeter => this == EmsRole.gridMeter || this == EmsRole.solarMeter;
/// Charge pilotable → zone Charges pilotables, priorisable par drag.
bool get isControllableLoad =>
this == EmsRole.battery ||
this == EmsRole.evCharger ||
this == EmsRole.dhw ||
this == EmsRole.heatPump;
}
// ─────────────────────────────────────────────────────────────────────────────
// État tri-état d'un rôle (contrat : present / absent / non-configuré)
// ─────────────────────────────────────────────────────────────────────────────
/// État d'un rôle. **Jamais `thingId:null`** : l'absence d'appareil est un état
/// explicite ([RoleAbsent]), distinct du « pas encore configuré »
/// ([RoleUnconfigured]).
sealed class RoleState {
const RoleState();
}
/// Rôle pas encore configuré (clé absente, au sens du contrat).
class RoleUnconfigured extends RoleState {
const RoleUnconfigured();
}
/// Rôle assigné à un thing concret.
class RolePresent extends RoleState {
final String thingId;
/// `true` si le rôle a été **inféré** par interface (solaire / batterie).
final bool detected;
const RolePresent(this.thingId, {this.detected = false});
}
/// Rôle acté « Pas d'appareil » (`{status: "absent"}`).
class RoleAbsent extends RoleState {
const RoleAbsent();
}
// ─────────────────────────────────────────────────────────────────────────────
// Déclaration de charge pilotable (LoadDescriptor §3 — édité écran 2)
// ─────────────────────────────────────────────────────────────────────────────
enum LoadMode { fixed, dynamic }
/// Besoins d'une charge pilotable (`needs` du LoadDescriptor).
class LoadNeeds {
final String? dailyDeadline; // ex. "06:00"
final int? minEnergyWhPerDay; // ex. 4000
const LoadNeeds({this.dailyDeadline, this.minEnergyWhPerDay});
Map<String, dynamic> toJson() => {
if (dailyDeadline != null) 'dailyDeadline': dailyDeadline,
if (minEnergyWhPerDay != null) 'minEnergyWhPerDay': minEnergyWhPerDay,
};
}
/// Déclaration d'une charge pilotable, éditée dans l'**écran 2**.
///
/// Sur la carte de liste (écran 1), seul [summary] est montré — **pas d'éditeur
/// de paliers** (décision contrat : les paliers sont une déclaration, pas une
/// propriété matérielle).
class LoadConfig {
final LoadMode mode;
/// Paliers atteignables en W — REQUIS si [LoadMode.fixed], trié, contient `0`.
final List<int> powerLevels;
/// Plafond physique en W — REQUIS si [LoadMode.dynamic] ; plafond aussi en fixed.
final int? maxPowerW;
final LoadNeeds? needs;
const LoadConfig({
required this.mode,
this.powerLevels = const [],
this.maxPowerW,
this.needs,
});
/// Plafond effectif : `maxPowerW` sinon dernier palier déclaré.
int? get effectiveMaxW =>
maxPowerW ?? (powerLevels.isNotEmpty ? powerLevels.last : null);
/// Résumé affiché sur la carte de liste, ex. « 5 paliers · max 2400 W ».
String get summary {
if (mode == LoadMode.dynamic) {
return maxPowerW != null ? 'Dynamique · max $maxPowerW W' : 'Dynamique';
}
final n = powerLevels.length;
final maxW = effectiveMaxW;
return maxW != null ? '$n paliers · max $maxW W' : '$n paliers';
}
}
// ─────────────────────────────────────────────────────────────────────────────
// État global de l'écran Rôles & appareils
// ─────────────────────────────────────────────────────────────────────────────
/// Trois états d'écran (contrat) : vierge / en cours / complet.
enum SetupScreenState { blank, inProgress, complete }
/// Source de vérité **unique** des rôles EMS et de leur configuration.
///
/// Modèle tri-état conforme à `INTERFACE_etmvariableload.md`. La persistance
/// réelle (`Energy.SetRootMeter`, `NymeaEnergy.Set/GetLoadConfig`) est branchée
/// à l'**Étape 2** ; ce provider ne gère que l'état en mémoire et son routage.
class EnergySetupProvider extends ChangeNotifier {
/// Ordre canonique d'insertion des charges pilotables (contrat :
/// EV → ECS → PAC → Batterie). L'installateur réordonne ensuite par drag.
static const List<EmsRole> _canonicalLoadOrder = [
EmsRole.evCharger,
EmsRole.dhw,
EmsRole.heatPump,
EmsRole.battery,
];
final Map<EmsRole, RoleState> _states = {
for (final r in EmsRole.values) r: const RoleUnconfigured(),
};
final Map<EmsRole, LoadConfig> _loadConfigs = {};
final Map<EmsRole, bool> _enabled = {};
/// Charges pilotables présentes, dans l'ordre de priorité (index 0 = rang 1).
final List<EmsRole> _priorityOrder = [];
/// Service de transport (Étape 2) — attaché par l'écran une fois connecté.
NymeaService? _service;
/// Dernier rootMeter poussé — évite de renvoyer `SetRootMeter` (RPC admin)
/// à chaque mutation.
String? _lastRootMeterId;
// ── Lecture d'état ─────────────────────────────────────────────────────────
RoleState stateOf(EmsRole role) => _states[role]!;
LoadConfig? loadConfigOf(EmsRole role) => _loadConfigs[role];
bool isEnabled(EmsRole role) => _enabled[role] ?? true;
bool isPresent(EmsRole role) => _states[role] is RolePresent;
bool isAbsent(EmsRole role) => _states[role] is RoleAbsent;
bool isUnconfigured(EmsRole role) => _states[role] is RoleUnconfigured;
/// `true` si le rôle a été inféré par interface.
bool isDetected(EmsRole role) {
final s = _states[role];
return s is RolePresent && s.detected;
}
/// Thing assigné au rôle, ou `null` si non présent.
String? thingIdOf(EmsRole role) {
final s = _states[role];
return s is RolePresent ? s.thingId : null;
}
/// Rang de priorité (1 = servi en premier), ou `null` si non priorisé.
int? priorityOf(EmsRole role) {
final i = _priorityOrder.indexOf(role);
return i < 0 ? null : i + 1;
}
// ── Zones d'affichage ──────────────────────────────────────────────────────
/// Compteurs présents (réseau, solaire détecté). Non draggables.
List<EmsRole> get meterRoles =>
EmsRole.values.where((r) => r.isMeter && isPresent(r)).toList();
/// Charges pilotables présentes, **dans l'ordre de priorité**.
List<EmsRole> get controllableRoles => List.unmodifiable(_priorityOrder);
/// Rôles pickés encore non configurés → zone « À configurer ».
/// Les rôles inférés n'y figurent jamais.
List<EmsRole> get toConfigureRoles =>
EmsRole.values.where((r) => r.isPicked && isUnconfigured(r)).toList();
/// Rôles actés « Pas d'appareil ».
List<EmsRole> get absentRoles =>
EmsRole.values.where((r) => isAbsent(r)).toList();
// ── Compteur X/6 & complétude ──────────────────────────────────────────────
int get totalRoles => EmsRole.values.length;
/// Rôles configurés : inférés comptés d'office ; pickés comptés quand assignés
/// **ou** actés absents.
int get configuredCount {
var n = 0;
for (final r in EmsRole.values) {
if (r.isInferred) {
n++; // configurés d'office
} else if (_states[r] is RolePresent || _states[r] is RoleAbsent) {
n++;
}
}
return n;
}
/// « Complet » = aucun rôle pické en non-configuré (les inférés ne bloquent
/// jamais).
bool get isComplete => EmsRole.values
.where((r) => r.isPicked)
.every((r) => _states[r] is! RoleUnconfigured);
SetupScreenState get screenState {
final anyPresent = _states.values.any((s) => s is RolePresent);
if (!anyPresent) return SetupScreenState.blank;
return isComplete ? SetupScreenState.complete : SetupScreenState.inProgress;
}
// ── Mutations ──────────────────────────────────────────────────────────────
/// Assigne un thing à un rôle. Une charge pilotable rejoint la liste de
/// priorité à sa place canonique.
void assignThing(EmsRole role, String thingId) {
_states[role] = RolePresent(thingId);
if (role.isControllableLoad) _ensureInOrder(role);
_commit();
}
/// Acte « Pas d'appareil » pour un rôle.
void markAbsent(EmsRole role) {
_states[role] = const RoleAbsent();
_priorityOrder.remove(role);
_commit();
}
/// Remet un rôle en « À configurer ».
void unconfigure(EmsRole role) {
_states[role] = const RoleUnconfigured();
_priorityOrder.remove(role);
_commit();
}
/// Active / exclut une charge de l'arbitrage (`enabled` du LoadDescriptor).
void setEnabled(EmsRole role, bool enabled) {
_enabled[role] = enabled;
_commit();
}
/// Enregistre la déclaration éditée dans l'écran 2.
void setLoadConfig(EmsRole role, LoadConfig config) {
_loadConfigs[role] = config;
_commit();
}
/// Réordonne les charges pilotables → réécrit la priorité (rang 1 = premier
/// servi). Sémantique `ReorderableListView` (newIndex décalé vers le haut).
void reorderControllableLoads(int oldIndex, int newIndex) {
if (oldIndex < 0 || oldIndex >= _priorityOrder.length) return;
if (newIndex > oldIndex) newIndex--;
final role = _priorityOrder.removeAt(oldIndex);
_priorityOrder.insert(newIndex.clamp(0, _priorityOrder.length), role);
_commit();
}
// ── Persistance côté plugin (Étape 2) ──────────────────────────────────────
/// Attache le service de transport. Appelé par l'écran une fois connecté.
/// Tente une lecture initiale (stub vide — ne bloque pas le démarrage).
void attach(NymeaService service) {
if (identical(_service, service)) return;
_service = service;
service.getLoadConfig();
}
/// `etmvariableload` = charge à **puissance pilotable** (rév. 2). La **PAC**
/// en est exclue (modèle à états SG-Ready, §8) ; la **batterie** aussi (kind
/// `Constraint`, déféré 3f). Seuls EV & ECS émettent un `LoadDescriptor`.
bool isVariableLoad(EmsRole role) =>
role == EmsRole.evCharger || role == EmsRole.dhw;
/// Construit le `LoadDescriptor` (§4) d'une charge etmvariableload présente,
/// ou `null` sinon. Les champs déclarés (`mode`/`powerLevels`/`maxPowerW`/
/// `needs`) ne sont posés que si la déclaration (écran 2) existe — sinon seul
/// l'ossature priorité/activation part (déclaration encore à faire).
Map<String, dynamic>? buildLoadDescriptor(EmsRole role) {
if (!isVariableLoad(role)) return null;
final thingId = thingIdOf(role);
if (thingId == null) return null;
final d = <String, dynamic>{
'id': thingId,
'label': role.label,
'adapter': 'etmvariableload',
'priority': priorityOf(role) ?? 0,
'enabled': isEnabled(role),
};
final cfg = _loadConfigs[role];
if (cfg != null) {
d['mode'] = cfg.mode == LoadMode.fixed ? 'fixed' : 'dynamic';
if (cfg.mode == LoadMode.fixed) d['powerLevels'] = cfg.powerLevels;
if (cfg.maxPowerW != null) d['maxPowerW'] = cfg.maxPowerW;
if (cfg.needs != null) d['needs'] = cfg.needs!.toJson();
}
return d;
}
/// Tous les `LoadDescriptor` à émettre, dans l'ordre de priorité.
List<Map<String, dynamic>> buildLoadDescriptors() => controllableRoles
.map(buildLoadDescriptor)
.whereType<Map<String, dynamic>>()
.toList();
/// Pousse l'état courant vers le plugin :
/// • gridMeter → `Energy.SetRootMeter` (**réel**, seulement si changé) ;
/// • EV / ECS → `SetLoadConfig` (**stub** : logge le JSON) ;
/// • PAC / batterie → chemins distincts (rév. 2), tracés explicitement.
void persist() {
final service = _service;
if (service == null) return;
final gridId = thingIdOf(EmsRole.gridMeter);
if (gridId != null && gridId != _lastRootMeterId) {
_lastRootMeterId = gridId;
service.setRootMeter(gridId);
}
service.setLoadConfig(buildLoadDescriptors());
for (final role in controllableRoles) {
if (role == EmsRole.heatPump) {
service.logInfo('[LoadConfig STUB] PAC "${role.label}" → action état '
'SG-Ready (hors LoadDescriptor, chemin §8)');
} else if (role == EmsRole.battery) {
service.logInfo('[LoadConfig STUB] Batterie "${role.label}" → kind '
'Constraint (déféré 3f), non émis');
}
}
}
/// Notifie l'UI **et** pousse vers le plugin.
void _commit() {
notifyListeners();
persist();
}
/// Réinitialise l'état au **switch d'installation**. Reset mémoire **sans
/// `persist()`** : on ne pousse jamais une config vide vers la nouvelle box
/// (sinon on écraserait sa vraie config au connect). Garde la ref `_service`.
void clearForSwitch() {
for (final r in EmsRole.values) {
_states[r] = const RoleUnconfigured();
}
_loadConfigs.clear();
_enabled.clear();
_priorityOrder.clear();
_lastRootMeterId = null;
notifyListeners();
}
// ── Inférence solaire / batterie ───────────────────────────────────────────
/// Infère les rôles solaire & batterie depuis les interfaces des things : ils
/// passent **direct** en zone définitive (badge « détecté »). N'écrase jamais
/// un choix explicite (assignation manuelle ou « Pas d'appareil »).
void syncFromThings(NymeaService service) {
var changed = false;
for (final role in const [EmsRole.solarMeter, EmsRole.battery]) {
if (_states[role] is! RoleUnconfigured) continue; // respecte l'explicite
final matches = compatibleThings(role, service);
if (matches.isNotEmpty) {
_states[role] = RolePresent(matches.first.id, detected: true);
if (role.isControllableLoad) _ensureInOrder(role);
changed = true;
}
}
if (changed) notifyListeners();
}
// ── Filtrage des things compatibles avec un rôle ───────────────────────────
List<NymeaThing> compatibleThings(EmsRole role, NymeaService service) {
return service.things.where((thing) {
try {
final cls =
service.thingClasses.firstWhere((c) => c.id == thing.thingClassId);
return cls.interfaces
.any((i) => role.compatibleInterfaces.contains(i.toLowerCase()));
} catch (_) {
return false;
}
}).toList();
}
// ── Interne ────────────────────────────────────────────────────────────────
/// Insère une charge pilotable à sa place canonique si absente de l'ordre.
void _ensureInOrder(EmsRole role) {
if (_priorityOrder.contains(role)) return;
final canonIdx = _canonicalLoadOrder.indexOf(role);
var insertAt = _priorityOrder.length;
for (var i = 0; i < _priorityOrder.length; i++) {
if (_canonicalLoadOrder.indexOf(_priorityOrder[i]) > canonIdx) {
insertAt = i;
break;
}
}
_priorityOrder.insert(insertAt, role);
}
}