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 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 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 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 _canonicalLoadOrder = [ EmsRole.evCharger, EmsRole.dhw, EmsRole.heatPump, EmsRole.battery, ]; final Map _states = { for (final r in EmsRole.values) r: const RoleUnconfigured(), }; final Map _loadConfigs = {}; final Map _enabled = {}; /// Charges pilotables présentes, dans l'ordre de priorité (index 0 = rang 1). final List _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 get meterRoles => EmsRole.values.where((r) => r.isMeter && isPresent(r)).toList(); /// Charges pilotables présentes, **dans l'ordre de priorité**. List 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 get toConfigureRoles => EmsRole.values.where((r) => r.isPicked && isUnconfigured(r)).toList(); /// Rôles actés « Pas d'appareil ». List 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? buildLoadDescriptor(EmsRole role) { if (!isVariableLoad(role)) return null; final thingId = thingIdOf(role); if (thingId == null) return null; final d = { '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> buildLoadDescriptors() => controllableRoles .map(buildLoadDescriptor) .whereType>() .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 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); } }