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>
506 lines
21 KiB
Dart
506 lines
21 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é) ;
|
|
/// • PAC / batterie → chemins distincts (rév. 2), tracés explicitement.
|
|
///
|
|
/// ## Pourquoi `SetLoadConfig` n'est PLUS appelé ici
|
|
///
|
|
/// [buildLoadDescriptors] **reconstruit** des charges utiles depuis le modèle typé
|
|
/// de cet écran. Tant que `NymeaEnergy.SetLoadConfig` était un stub qui journalisait,
|
|
/// c'était sans conséquence. Ce n'est plus le cas : la méthode écrit réellement, et
|
|
/// `SetLoadConfig` **remplace l'ensemble** des charges.
|
|
///
|
|
/// Envoyer une reconstruction écraserait donc la configuration réelle de la box par
|
|
/// une projection partielle — sur `.75`, la charge `chauffe-eau` (relay-router, trois
|
|
/// relais) disparaîtrait au profit de descripteurs dérivés des `EmsRole`. Et
|
|
/// `_commit()` appelle `persist()` à **chaque** changement d'UI : la destruction
|
|
/// partirait sans geste explicite de l'utilisateur.
|
|
///
|
|
/// Le chemin sanctionné est *lire → patcher → réécrire* : `getLoadConfig()` puis
|
|
/// `LoadConfigEntry.patched()` puis `setLoadConfig()`, où les charges utiles des
|
|
/// mécanismes non modélisés sont transportées telles quelles.
|
|
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.logInfo('[LoadConfig] ${buildLoadDescriptors().length} descripteur(s) '
|
|
'construits mais NON émis : SetLoadConfig remplace tout l\'ensemble, une '
|
|
'reconstruction écraserait la config réelle de la box.');
|
|
|
|
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);
|
|
}
|
|
}
|