etm-powersync-app/lib/providers/energy_setup_provider.dart
Patrick Schurig cd76df3a73 feat(etm16): s'aligner sur le nouveau contrat — table de paliers, stagesW, compteur et sonde
RELAIS : mon avertissement de reconstruction sur un réordonnancement est devenu FAUX, et
il est corrigé. `+etm16` compare la TABLE que les relais produisent (deriveStages), plus la
liste par index. Le miroir Dart suit, y compris le détail qui compte : les ThingIds d'une
combinaison se comparent comme un ENSEMBLE — collectés dans l'ordre de déclaration, fermer
{K1,K2} ou {K2,K1} est le même geste électrique. Mais l'appariement palier par palier
reste positionnel, ce qui préserve le seul cas où l'ordre signifie encore quelque chose :
deux relais de MÊME puissance, où il décide quel contact sert ce palier.

Conséquence pour l'écran : réordonner des contacteurs de puissances distinctes n'avertit
plus de rien. Avertir sur un geste devenu gratuit serait aussi trompeur que de se taire sur
un geste coûteux.

PALIERS : l'app ne dérive plus la combinatoire pour l'affichage — la box les publie
(mechanism.stagesW). Vérifié sur appareil : [0, 500, 1000, 1500, 2000, 2500, 3000, 3500].
deriveStages reste côté app pour la seule annonce d'impact, qui se fait avant l'écriture.
Tant que le brouillon n'a pas touché aux contacteurs, c'est la table PUBLIÉE qui s'affiche ;
dès qu'il y touche elle devient périmée, et l'écran montre une prévision annoncée comme
telle plutôt qu'une table qui décrit l'état d'avant.

COMPTEUR ET SONDE : ils ne « vont pas arriver », ils SONT au schéma de .75
(o:meterThingId, o:sensorThingId). Le grisage dérivé les a donc activés seul, sans nouvelle
version de l'app — exactement ce qui était promis. Mais un bloc actif sur un placeholder
mort est pire qu'un bloc grisé : les deux sont désormais câblés sur un vrai sélecteur,
filtré par INTERFACE (smartmeter, temperaturesensor) et jamais par nom de plugin.

Ni l'un ni l'autre n'entre dans sameHardware() : les rattacher ne reconstruit rien. Ni dans
le contrôle d'unicité : deux charges peuvent partager une sonde, un compteur divisionnaire
en couvrir plusieurs — le sélecteur ne grise donc rien ici, contrairement aux contacteurs.
Et la mesure sert à VÉRIFIER, jamais à décider : elle n'entre pas dans le budget, la charge
étant déjà comptée dans le racine dont elle n'est qu'une décomposition.

COMPTEURS CUMULÉS : la base purgée, les valeurs sont saines (55 730 / 50 / 6 327 / 62 007
kWh) et la tuile « compteur box invalide » a disparu d'elle-même. Le plafond de
plausibilité reste : un zéro réel s'affiche « 0,0 kWh », jamais « — ».

hasNeverRun était déjà traité comme un état distinct — « l'absence de cycle est dite pour
elle-même, jamais rendue par un âge de zéro ».

Vérifié sur appareil contre .75 : compteur racine relu, « à configurer » vide, deux bornes
listées (Simulated wallbox + Terra AC Charger), stagesW de la box, les deux champs
writable, 5/5 sections de l'écran SG-Ready. Tests 98/98, analyze 0 erreur.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HajXLUczEyZd22JeewRfff
2026-08-26 10:54:16 +02:00

534 lines
23 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 CHARGES en sont exclues depuis la fusion du lot C.** Elles ne viennent plus
/// d'un rôle inféré des Things mais de `GetLoadConfig` : réclamer de configurer une
/// « pompe à chaleur » pendant que la box en arbitre une, déclarée et vivante, est le
/// défaut même que la fusion devait faire disparaître. Ce qui reste ici relève des
/// **compteurs**, dont le contrat est ailleurs (`Energy.SetRootMeter`).
List<EmsRole> get toConfigureRoles => EmsRole.values
.where((r) => r.isPicked && !r.isControllableLoad && 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 ──────────────────────────────────────────────
/// Rôles que cet écran gouverne encore — les **compteurs** seulement.
///
/// Compter les charges ici donnerait « 2 / 6 rôles configurés · 4 restent à configurer »
/// sur une installation où les deux charges réelles sont déclarées et arbitrées. Le
/// décompte doit décrire ce que l'écran demande, pas une énumération figée.
int get totalRoles =>
EmsRole.values.where((r) => !r.isControllableLoad).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.isControllableLoad) continue; // décomptées par LoadConfig, pas ici
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 »).
/// Adopte le compteur racine **que la box déclare**.
///
/// À appeler à la connexion. Sans elle, le rôle repart « à configurer » à chaque
/// démarrage alors que la box a le compteur : une configuration jamais lue se présente
/// exactement comme une configuration perdue, et pousse à refaire un geste inutile.
Future<void> syncRootMeter(NymeaService service) async {
final id = await service.getRootMeter();
if (id == null) return;
final actuel = _states[EmsRole.gridMeter];
if (actuel is RolePresent && actuel.thingId == id) return;
// `detected: true` : ce n'est pas une saisie de cette session, c'est un état lu.
_states[EmsRole.gridMeter] = RolePresent(id, detected: true);
notifyListeners();
}
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);
}
}