import 'dart:async'; import 'dart:developer' as dev; import 'dart:convert'; import 'dart:io'; import 'dart:math'; import 'package:flutter/foundation.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:web_socket_channel/web_socket_channel.dart'; import '../models/energy_data.dart'; // EnergyData, HistoryPoint, PowerBalanceEntry import '../models/nymea_models.dart'; import 'energy_ratios.dart'; // seam interim ratios (§2.1) // ── Protocole de connexion ───────────────────────────────────────────────────── enum NymeaProtocol { /// TCP brut port 2222 — nymea parle en premier (\n-delimited JSON) tcpRaw, /// WebSocket port 4444 — client envoie JSONRPC.Hello en premier webSocket, } /// Service de communication nymea JSON-RPC 2.0 /// Supporte TCP brut (port 2222) et WebSocket (port 4444) class NymeaService extends ChangeNotifier { // ── Transport ──────────────────────────────────────────────────────────────── Socket? _socket; // TCP raw WebSocketChannel? _wsChannel; // WebSocket StreamSubscription? _wsSub; NymeaProtocol _protocol = NymeaProtocol.webSocket; int _requestId = 1; final Map>> _pendingRequests = {}; final StringBuffer _buffer = StringBuffer(); // TCP fragment buffer // ── State ──────────────────────────────────────────────────────────────────── bool _connected = false; bool _connecting = false; String _host = '192.168.1.106'; int _port = 2222; String? _connectionError; String? _nymeaVersion; Timer? _heartbeatTimer; Timer? _simulationTimer; bool _isSimulation = false; // ── Auth ───────────────────────────────────────────────────────────────────── String _token = ''; String _username = ''; String _password = ''; // ── Identité & état d'auth issus du Hello (JSONRPC.Hello) ──────────────────── String _serverUuid = ''; // identité stable de la box (Hello `uuid`) String _serverName = ''; // nom de la box (Hello `name`) bool _initialSetupRequired = false; // box neuve → branche « créer admin » bool _authenticationRequired = false; bool _authenticated = false; // ── Debug verbeux ───────────────────────────────────────────────────────── bool _verboseLog = false; // activé depuis l'UI ou à la connexion bool get verboseLog => _verboseLog; set verboseLog(bool v) { _verboseLog = v; _log('🔧 Verbose logging ${v ? "ON" : "OFF"}', force: true); } void _log(String msg, {bool force = false, String? json}) { if (!force && !_verboseLog) return; dev.log(msg, name: 'nymea'); if (json != null && _verboseLog) { // Formatter le JSON pour lisibilité try { final parsed = jsonDecode(json); final pretty = const JsonEncoder.withIndent(' ').convert(parsed); dev.log(pretty, name: 'nymea.json'); } catch (_) { dev.log(json, name: 'nymea.json'); } } } EnergyData _energyData = const EnergyData(); // SEAM UNIQUE des ratios (interim §2.1) — calcul app-side isolé ici. final EnergyRatiosInterim _ratiosSeam = EnergyRatiosInterim(); List _historyPoints = []; List _things = []; List _thingClasses = []; List _favoriteWidgets = []; static const _favKey = 'etm_favorites_v1'; NymeaService() { _loadFavorites(); } Future _loadFavorites() async { final prefs = await SharedPreferences.getInstance(); final raw = prefs.getString(_favKey); if (raw != null) { try { final list = jsonDecode(raw) as List; _favoriteWidgets = list .map((e) => FavoriteWidget.fromJson(e as Map)) .toList(); notifyListeners(); } catch (_) { // Données corrompues — on repart de zéro } } } Future _saveFavorites() async { final prefs = await SharedPreferences.getInstance(); await prefs.setString( _favKey, jsonEncode(_favoriteWidgets.map((f) => f.toJson()).toList()), ); } // ── Getters ────────────────────────────────────────────────────────────────── bool get connected => _connected; bool get isConnected => _connected; // alias pour les screens bool get connecting => _connecting; bool get isSimulation => _isSimulation; NymeaProtocol get protocol => _protocol; String get host => _host; int get port => _port; String? get connectionError => _connectionError; String? get nymeaVersion => _nymeaVersion; String get username => _username; String get password => _password; // ── Identité & auth (Hello) — consommés par le ConnectionManager ──────────── String get token => _token; String get serverUuid => _serverUuid; String get serverName => _serverName; bool get initialSetupRequired => _initialSetupRequired; bool get authenticationRequired => _authenticationRequired; bool get authenticated => _authenticated; EnergyData get energyData => _energyData; List get historyPoints => _historyPoints; List get things => _things; List get thingClasses => _thingClasses; List get favoriteWidgets => _favoriteWidgets; /// Retourne {thingId, stateName} de la première batterie trouvée, /// ou null si aucune batterie configurée / pas encore chargée. Map? get batterySOCSource { // En simulation : retourne directement la batterie simulée. if (_isSimulation) { return {'thingId': 'sim-battery', 'stateName': 'soc'}; } if (_things.isEmpty) { _log('🔋 batterySOCSource: things pas encore chargés', force: true); return null; } for (final thing in _things) { NymeaThingClass? cls; try { cls = _thingClasses.firstWhere((c) => c.id == thing.thingClassId); } catch (_) { continue; } final isBattery = cls.interfaces.any((i) => const [ 'battery', 'energystorage', 'batterymonitor' ].contains(i.toLowerCase())); if (!isBattery) continue; try { final stateType = cls.stateTypes.firstWhere( (st) => st.name.toLowerCase() == 'batterylevel' || st.name.toLowerCase() == 'soc'); _log('🔋 batterySOCSource: trouvé "${thing.name}" / état "${stateType.name}"', force: true); return {'thingId': thing.id, 'stateName': stateType.name}; } catch (_) { _log('🔋 batterySOCSource: batterie "${thing.name}" sans état batteryLevel/soc (états: ${cls.stateTypes.map((s) => s.name).toList()})', force: true); continue; } } _log('🔋 batterySOCSource: aucune batterie trouvée (${_things.length} things, interfaces: ${_things.map((t) { try { return _thingClasses.firstWhere((c) => c.id == t.thingClassId).interfaces; } catch(_) { return []; } }).toList()})', force: true); return null; } // ═══════════════════════════════════════════════════════════════════════════ // CONNEXION — entrée publique // ═══════════════════════════════════════════════════════════════════════════ Future connect( String host, int port, { NymeaProtocol protocol = NymeaProtocol.webSocket, String username = '', String password = '', String? token, }) async { if (_connecting) return false; _host = host; _port = port; _protocol = protocol; _username = username; _password = password; _connectionError = null; _connecting = true; _isSimulation = false; _simulationTimer?.cancel(); _buffer.clear(); // Token fourni par le ConnectionManager (persistance **par UUID**, secure // storage). Posé AVANT le dispatch → sur WS, Hello part avec le token et la // box renvoie `authenticated:true` s'il est valide (reconnexion silencieuse). _token = token ?? ''; if (_token.isNotEmpty) { _log('🔑 Token préchargé (par UUID) fourni au connect', force: true); } notifyListeners(); try { if (protocol == NymeaProtocol.tcpRaw) { return await _connectTcp(host, port); } else { return await _connectWs(host, port); } } catch (e) { _connectionError = e.toString(); _connected = false; _connecting = false; notifyListeners(); return false; } } // ── TCP brut : nymea parle en PREMIER ──────────────────────────────────────── Future _connectTcp(String host, int port) async { _log('TCP → $host:$port'); _socket = await Socket.connect(host, port, timeout: const Duration(seconds: 8)); final welcomeCompleter = Completer>(); bool welcomeReceived = false; _socket!.cast>().transform(utf8.decoder).listen( (chunk) { _buffer.write(chunk); final raw = _buffer.toString(); int start = 0; for (int i = 0; i < raw.length; i++) { if (raw[i] == '\n') { final line = raw.substring(start, i).trim(); if (line.isNotEmpty) { try { final data = jsonDecode(line) as Map; if (!welcomeReceived) { welcomeReceived = true; _log('← raw TCP line (${line.length}): ${line.substring(0, line.length.clamp(0, 120))}…', force: _verboseLog); if (!welcomeCompleter.isCompleted) { welcomeCompleter.complete(data); } } else { _processMessage(data); } } catch (e) { _log('TCP parse error: $e', force: true); } } start = i + 1; } } _buffer.clear(); if (start < raw.length) _buffer.write(raw.substring(start)); }, onError: (e) { if (!welcomeCompleter.isCompleted) welcomeCompleter.completeError(e); _onError(e); }, onDone: () { if (!welcomeCompleter.isCompleted) { welcomeCompleter.completeError( Exception('Socket closed before welcome')); } _onDone(); }, cancelOnError: false, ); // Attendre le message de bienvenue de nymea final welcome = await welcomeCompleter.future .timeout(const Duration(seconds: 8)); return _finishConnect(welcome); } // ── WebSocket : client parle en PREMIER (JSONRPC.Hello) ────────────────────── Future _connectWs(String host, int port) async { _log('WebSocket → ws://$host:$port'); final uri = Uri.parse('ws://$host:$port'); _wsChannel = WebSocketChannel.connect(uri); // Attendre que la connexion soit établie try { await _wsChannel!.ready.timeout(const Duration(seconds: 8)); } catch (_) { // some versions don't have .ready — ignore } _wsSub = _wsChannel!.stream.listen( (raw) { try { // nymea peut envoyer String ou binary (Uint8List) String str; if (raw is String) { str = raw; } else if (raw is List) { str = utf8.decode(raw); } else { _log('⚠️ WS frame type inattendu: ${raw.runtimeType}', force: true); return; } _log('← WS raw (${str.length} chars): ${str.substring(0, str.length.clamp(0, 80))}…'); final data = jsonDecode(str) as Map; _processMessage(data); } catch (e) { _log('❌ WS parse error: $e raw type: ${raw.runtimeType}', force: true); } }, onError: (e) { _log('❌ WS stream error: $e', force: true); _onError(e); }, onDone: _onDone, cancelOnError: false, ); _log('📡 WS listener actif, envoi Hello...', force: true); // Sur WebSocket, c'est le CLIENT qui envoie Hello en premier final hello = await _sendRequest('JSONRPC.Hello', {}) .timeout(const Duration(seconds: 8)); return _finishConnect(hello); } // ── Finalisation commune ───────────────────────────────────────────────────── Future _finishConnect(Map welcome) async { final p = welcome['params'] as Map? ?? welcome; _nymeaVersion = p['version'] as String? ?? p['serverVersion'] as String? ?? 'nymea'; // ── Identité & état d'auth — capturés AVANT toute décision d'auth. // `initialSetupRequired` tranche créer-admin vs login (consommé Étape 5). // nymea renvoie l'UUID entre accolades ({3943…}) ; on normalise une fois // ici → clé de stockage / comparaison / affichage cohérentes en aval. _serverUuid = ((p['uuid'] as String?) ?? '').replaceAll(RegExp(r'[{}]'), ''); _serverName = (p['name'] as String?) ?? 'nymea'; _authenticationRequired = p['authenticationRequired'] == true; _initialSetupRequired = _authenticationRequired && p['initialSetupRequired'] == true; _authenticated = !_authenticationRequired || p['authenticated'] == true; _log('✅ nymea $_nymeaVersion "$_serverName" ($_serverUuid) via ' '${_protocol == NymeaProtocol.tcpRaw ? "TCP" : "WebSocket"} · ' 'authReq=$_authenticationRequired setup=$_initialSetupRequired ' 'auth=$_authenticated', force: true); // Branche compat : credentials passés directement à connect() → login. // Le chemin nominal multi-box passe par token (déjà validé au Hello). if (_authenticationRequired && !_authenticated && _username.isNotEmpty) { try { await _doAuthenticate(); _authenticated = _token.isNotEmpty; } catch (e) { _connectionError = e.toString(); _connected = false; _connecting = false; notifyListeners(); return false; } } _connected = true; _connecting = false; notifyListeners(); // Ne charger que si authentifié — sinon transport up, mais on laisse le // ConnectionManager router vers l'écran Connexion (auth tardive). if (_authenticated) { await _postAuthLoad(); } else { _log('🔒 Auth requise — chargement différé (écran Connexion)', force: true); } return true; } /// Chargement post-authentification — partagé entre la connexion directe /// (`_finishConnect`, box déjà authentifiée) et l'auth **tardive** /// (`authenticate`/`createUser` depuis l'écran Connexion). Future _postAuthLoad() async { await _setNotificationsEnabled(); await _initialLoad(); _startHeartbeat(); } /// Login sur une box déjà initialisée (`JSONRPC.Authenticate`). À appeler /// quand le transport est up mais l'auth requise. Sur succès : token posé, /// `_authenticated=true`, chargement déclenché. Future authenticate(String username, String password) async { _username = username; _password = password; try { await _doAuthenticate(); // pose _token ou throw } catch (e) { _connectionError = e.toString(); notifyListeners(); return false; } _authenticated = true; _connectionError = null; notifyListeners(); await _postAuthLoad(); notifyListeners(); return true; } /// Crée l'administrateur d'une box **neuve** (`JSONRPC.CreateUser`, autorisé /// uniquement quand `initialSetupRequired`), **puis enchaîne `Authenticate`** /// pour obtenir le token (CreateUser n'en renvoie pas). Non vérifié en live /// faute de box vierge sous la main. Future createUser(String username, String password, {String? email}) async { _log('🔐 JSONRPC.CreateUser → user=$username', force: true); try { final r = await _sendRequest('JSONRPC.CreateUser', { 'username': username, 'password': password, if (email != null && email.isNotEmpty) 'email': email, }); final err = r['params']?['error'] as String?; if (err != null && err != 'UserErrorNoError') { _connectionError = 'CreateUser: $err'; _log('❌ $_connectionError', force: true); notifyListeners(); return false; } } catch (e) { _connectionError = e.toString(); notifyListeners(); return false; } // Token obtenu via Authenticate (CreateUser n'en renvoie pas). return authenticate(username, password); } Future _doAuthenticate() async { _log('🔐 JSONRPC.Authenticate → user=$_username', force: true); final r = await _sendRequest('JSONRPC.Authenticate', { 'username': _username, 'password': _password, 'deviceName': 'etm-powersync-app', }); _token = r['params']?['token'] as String? ?? ''; if (_token.isNotEmpty) { // Persistance par UUID assurée par le ConnectionManager (secure storage). _log('✅ Token obtenu', force: true); } else { _log('❌ Authenticate échoué: ${r["params"]}', force: true); throw Exception('Authentication failed: ${r["params"]}'); } } Future _setNotificationsEnabled() async { try { await _sendRequest('JSONRPC.SetNotificationStatus', {'enabled': true}); } catch (e) { _log('SetNotificationStatus: $e'); } } // ── Déconnexion ────────────────────────────────────────────────────────────── void disconnect() { _heartbeatTimer?.cancel(); _simulationTimer?.cancel(); _socket?.destroy(); _socket = null; _wsSub?.cancel(); _wsChannel?.sink.close(); _wsChannel = null; _connected = false; _connecting = false; _isSimulation = false; // Réinitialiser identité & auth — ne pas afficher une box déconnectée. _token = ''; _serverUuid = ''; _serverName = ''; _initialSetupRequired = false; _authenticationRequired = false; _authenticated = false; _buffer.clear(); for (final c in _pendingRequests.values) { c.completeError(Exception('Disconnected')); } _pendingRequests.clear(); notifyListeners(); } /// Vide les caches **par-box** en mémoire — appelé au switch d'installation /// (avant de connecter la cible). Ne touche **pas** aux favoris (préférence /// utilisateur globale, hors box). void resetState() { _energyData = const EnergyData(); _ratiosSeam.reset(); // baseline ratios par installation (interim §2.1) _historyPoints = []; _things = []; _thingClasses = []; _thingsLoaded = false; notifyListeners(); } // ═══════════════════════════════════════════════════════════════════════════ // JSON-RPC SEND — \n pour TCP, frame WebSocket pour WS // ═══════════════════════════════════════════════════════════════════════════ Future> _sendRequest( String method, Map params) async { if (_socket == null && _wsChannel == null) { throw Exception('Not connected'); } final id = _requestId++; final completer = Completer>(); _pendingRequests[id] = completer; final msg = jsonEncode({ 'id': id, 'method': method, 'params': params, 'token': _token, }); _log('→ [$id] $method', force: true, json: msg); if (_protocol == NymeaProtocol.tcpRaw) { _socket!.write('$msg\n'); } else { _wsChannel!.sink.add(msg); } // Timeout 15s — évite le blocage si nymea ne répond pas return completer.future.timeout( const Duration(seconds: 15), onTimeout: () { _pendingRequests.remove(id); throw TimeoutException('Request $method timed out', const Duration(seconds: 15)); }, ); } // ═══════════════════════════════════════════════════════════════════════════ // MESSAGE PROCESSING (commun TCP + WS) // ═══════════════════════════════════════════════════════════════════════════ void _processMessage(Map data) { // Réponse à une requête if (data.containsKey('id')) { final id = data['id']; // JSON decode peut retourner num au lieu de int — on normalise final intId = (id is num) ? id.toInt() : null; if (intId != null && _pendingRequests.containsKey(intId)) { _log('← [$intId] réponse status=${data['status'] ?? '?'}', force: true, json: jsonEncode(data)); _pendingRequests[intId]!.complete(data); _pendingRequests.remove(intId); } else if (intId != null) { _log('← [$intId] réponse non attendue (déjà timeout?)', force: true); } } // Notification push if (data.containsKey('notification')) { final notification = data['notification'] as String; _log('← push: $notification', force: true, json: _verboseLog ? jsonEncode(data) : null); _handleNotification( notification, data['params'] as Map? ?? {}, ); } } void _handleNotification( String notification, Map params) { _log('← push: $notification'); switch (notification) { case 'Energy.PowerBalanceChanged': _parsePowerBalance(params); break; case 'Energy.RootMeterChanged': _parseRootMeter(params); break; case 'Integrations.StateChanged': _handleStateChanged(params); break; case 'Integrations.ThingAdded': final t = params['thing']; if (t != null) { _things.add(NymeaThing.fromJson(t as Map)); notifyListeners(); } break; case 'Integrations.ThingRemoved': _things.removeWhere((t) => t.id == params['thingId']); notifyListeners(); break; } } void _handleStateChanged(Map params) { final thingId = params['thingId'] as String?; final stateTypeId = params['stateTypeId'] as String?; final value = params['value']; if (thingId == null || stateTypeId == null) return; final idx = _things.indexWhere((t) => t.id == thingId); if (idx >= 0) { final states = List.from(_things[idx].states); final si = states.indexWhere((s) => s.stateTypeId == stateTypeId); if (si >= 0) { states[si] = NymeaStateValue(stateTypeId: stateTypeId, value: value); } else { states.add(NymeaStateValue(stateTypeId: stateTypeId, value: value)); } _things[idx] = _things[idx].copyWith(states: states); // Synchronise batterySOC si l'état modifié est batteryLevel d'une batterie _maybeUpdateBatterySOC(_things[idx], stateTypeId, value); notifyListeners(); } } /// Met à jour _energyData.batterySOC si [thing] est une batterie/stockage /// et que [stateTypeId] correspond à l'état batteryLevel. void _maybeUpdateBatterySOC( NymeaThing thing, String stateTypeId, dynamic value) { NymeaThingClass? cls; try { cls = _thingClasses.firstWhere((c) => c.id == thing.thingClassId); } catch (_) { return; } final isBattery = cls.interfaces.any((i) => const [ 'battery', 'energystorage', 'batterymonitor' ].contains(i.toLowerCase())); if (!isBattery) return; final stateType = cls.stateTypeById(stateTypeId); if (stateType != null && value is num) { final n = stateType.name.toLowerCase(); // 'batterylevel' = convention nymea standard ; 'soc' = fallback certains plugins if (n == 'batterylevel' || n == 'soc') { _log('🔋 batterySOC mis à jour (${stateType.name}): ${value.toDouble()}%', force: true); _energyData = _energyData.copyWith(batterySOC: value.toDouble()); } } } /// Extrait le SOC initial depuis les états déjà chargés des things batterie. void _syncBatterySOCFromThings() { for (final thing in _things) { NymeaThingClass? cls; try { cls = _thingClasses.firstWhere((c) => c.id == thing.thingClassId); } catch (_) { continue; } final isBattery = cls.interfaces.any((i) => const [ 'battery', 'energystorage', 'batterymonitor' ].contains(i.toLowerCase())); if (!isBattery) continue; // Cherche l'état SOC : 'batteryLevel' (convention nymea) ou 'soc' (fallback) NymeaStateType? stateType; try { stateType = cls.stateTypes.firstWhere( (st) => st.name.toLowerCase() == 'batterylevel' || st.name.toLowerCase() == 'soc'); } catch (_) { continue; } final val = thing.stateValue(stateType.id); if (val is num) { _log('🔋 batterySOC initialisé (${stateType.name}): ${val.toDouble()}%', force: true); _energyData = _energyData.copyWith(batterySOC: val.toDouble()); return; // on prend la première batterie trouvée } } } void _onError(dynamic error) { _log('❌ Connection error: $error', force: true); _connectionError = error.toString(); _connected = false; for (final c in _pendingRequests.values) { c.completeError(error); } _pendingRequests.clear(); notifyListeners(); } void _onDone() { _log('🔌 Connection closed', force: true); _connected = false; notifyListeners(); } // ═══════════════════════════════════════════════════════════════════════════ // HEARTBEAT & INITIAL LOAD // ═══════════════════════════════════════════════════════════════════════════ Future _initialLoad() async { await Future.wait([ _loadThings(), _loadEnergyStatus(), _loadEnergyHistory(), ]); } void _startHeartbeat() { _heartbeatTimer?.cancel(); // Polling toutes les 5 secondes pour rafraîchir les données énergie _heartbeatTimer = Timer.periodic(const Duration(seconds: 5), (_) async { if (!_connected || _isSimulation) return; try { await _loadEnergyStatus(); } catch (_) { _connected = false; notifyListeners(); } }); } // ═══════════════════════════════════════════════════════════════════════════ // ENERGY MANAGER // ═══════════════════════════════════════════════════════════════════════════ Future _loadEnergyStatus() async { try { final r = await _sendRequest('Energy.GetPowerBalance', {}); _log('📊 PowerBalance: $r'); _parsePowerBalance(r); } catch (e) { _log('GetPowerBalance error: $e'); } } // ── nymea 1.14 Energy.PowerBalanceChanged / Energy.GetPowerBalance ─────────── // Champs réels confirmés depuis les logs : // currentPowerProduction : W solaire (NÉGATIF! ex: -1190 = 1190W PV) // currentPowerConsumption : W maison (positif) // currentPowerAcquisition : W réseau (positif = import, 0 = export) // currentPowerStorage : W batterie (+ charge, - décharge) // totalProduction : kWh PV cumulé // totalConsumption : kWh maison cumulé // totalAcquisition : kWh réseau importé cumulé // totalReturn : kWh réseau exporté cumulé void _parsePowerBalance(Map response) { final p = response['params'] as Map? ?? response; _log('📊 Energy fields: ${p.keys.toList()}'); double? n(String k) => (p[k] as num?)?.toDouble(); // Signes nymea BRUTS — ne corriger aucun signe (§7.1). Le power balance // agrégé donne production positive et acquisition net-signée (+import/−export). final pv = n('currentPowerProduction') ?? 0.0; final home = n('currentPowerConsumption') ?? 0.0; final grid = n('currentPowerAcquisition') ?? 0.0; final bat = n('currentPowerStorage') ?? 0.0; // Totaux cumulés (kWh). final totalProdKwh = n('totalProduction') ?? 0.0; final totalRetKwh = n('totalReturn') ?? 0.0; final totalConsoKwh = n('totalConsumption') ?? 0.0; final totalAcqKwh = n('totalAcquisition') ?? 0.0; // Ratios : PLUS de calcul ici (§2). Délégués au SEAM unique (interim Δ cumuls). final ratios = _ratiosSeam.compute( totalProduction: totalProdKwh, totalReturn: totalRetKwh, totalConsumption: totalConsoKwh, totalAcquisition: totalAcqKwh, now: DateTime.now(), ); // Hors scope ratios : énergie autoconso (Wh) + gains (€) — conservés tels quels. final selfConsoKwh = totalProdKwh - totalRetKwh; _energyData = _energyData.copyWith( pvPower: pv, homePower: home, gridPower: grid, batteryPower: bat, dayProductionWh: totalProdKwh * 1000, dayGridInjectionWh: totalRetKwh * 1000, daySelfConsumptionWh: selfConsoKwh * 1000, // n/a (seam → null) mappé à 0 pour le champ non-nullable (UX §2 item 4). selfConsumptionRate: ratios.autoconsommation ?? 0.0, autonomyRate: ratios.autonomie ?? 0.0, dayGains: totalRetKwh * 0.13 + selfConsoKwh * 0.22, // estimation tarifaire ); notifyListeners(); } void _parseRootMeter(Map response) { // Dans nymea 1.14, tout est dans PowerBalance — méthode conservée pour compat } Future refreshEnergy() => _loadEnergyStatus(); /// Récupère les logs de bilan énergétique via Energy.GetPowerBalanceLogs. /// /// [sampleRate] : "SampleRate15Mins" | "SampleRate1Hour" | /// "SampleRate3Hours" | "SampleRate1Day" | "SampleRate1Week" /// Timestamps envoyés en **secondes** (convention nymea Energy API). Future> fetchPowerBalanceLogs({ required DateTime from, required DateTime to, String sampleRate = 'SampleRate15Mins', }) async { if (_isSimulation) return _simulatePowerBalance(from, to, sampleRate); try { final r = await _sendRequest('Energy.GetPowerBalanceLogs', { 'sampleRate': sampleRate, 'from': from.millisecondsSinceEpoch ~/ 1000, 'to': to.millisecondsSinceEpoch ~/ 1000, }); final logs = (r['params']?['powerBalanceLogEntries'] ?? r['powerBalanceLogEntries']) as List? ?? []; return logs.map((e) { final m = e as Map; double d(String k) => (m[k] as num?)?.toDouble() ?? 0.0; return PowerBalanceEntry( timestamp: DateTime.fromMillisecondsSinceEpoch( (m['timestamp'] as num).toInt() * 1000), // Signes nymea BRUTS — ne corriger aucun signe (§7.1). L'autoconso // instantanée se dérive via la formule canonique du seam // (EnergyRatiosInterim.selfConsumptionPower), jamais par .abs(). productionW: d('production'), consumptionW: d('consumption'), acquisitionW: d('acquisition'), storageW: d('storage'), totalProductionWh: d('totalProduction'), totalConsumptionWh:d('totalConsumption'), totalReturnWh: d('totalReturn'), totalAcquisitionWh:d('totalAcquisition'), ); }).toList(); } catch (e) { _log('fetchPowerBalanceLogs: $e'); return []; } } List _simulatePowerBalance( DateTime from, DateTime to, String sampleRate) { final entries = []; const steps = 48; final stepDur = to.difference(from) ~/ steps; final stepHours = stepDur.inMinutes / 60.0; double totalProd = 50000, totalConso = 80000, totalRet = 20000, totalAcq = 50000; for (int i = 0; i <= steps; i++) { final t = from.add(stepDur * i); // Courbe solaire en demi-sinus sur la journée (pic à midi) final hFrac = (t.hour + t.minute / 60.0) / 24.0; final prod = (2200 * sin(hFrac * pi)).clamp(0.0, 2200).toDouble(); final conso = 600 + 400 * sin(i * pi / 10 + 1.0); final excess = prod - conso; final storage = excess.clamp(-300.0, 300.0).toDouble(); final acq = (conso - prod - storage).clamp(0.0, double.infinity).toDouble(); final ret = (excess - storage).clamp(0.0, double.infinity).toDouble(); totalProd += prod * stepHours; totalConso += conso * stepHours; totalRet += ret * stepHours; totalAcq += acq * stepHours; entries.add(PowerBalanceEntry( timestamp: t, productionW: prod, consumptionW: conso, acquisitionW: acq, storageW: storage, totalProductionWh: totalProd, totalConsumptionWh: totalConso, totalReturnWh: totalRet, totalAcquisitionWh: totalAcq, )); } return entries; } /// Rafraîchit things + classes + énergie Future refresh() async { await Future.wait([_loadThings(), _loadEnergyStatus()]); } Future _loadEnergyHistory() async { try { final now = DateTime.now(); final from = DateTime(now.year, now.month, now.day) .millisecondsSinceEpoch ~/ 1000; final to = now.millisecondsSinceEpoch ~/ 1000; final r = await _sendRequest('Energy.GetEnergyLogs', { 'sampleRate': 'SampleRateHour', 'from': from, 'to': to, }); final logs = r['params']?['logs'] as List? ?? []; _historyPoints = logs.map((l) { final lm = l as Map; return HistoryPoint( time: DateTime.fromMillisecondsSinceEpoch( (lm['timestamp'] as int) * 1000), pvWh: (lm['totalEnergyProduced'] as num?)?.toDouble() ?? 0, homeWh: (lm['totalEnergyConsumed'] as num?)?.toDouble() ?? 0, gridWh: (lm['totalEnergyReturned'] as num?)?.toDouble() ?? 0, ); }).toList(); notifyListeners(); } catch (e) { _log('GetEnergyLogs: $e'); } } /// Returns the thingId of the first configured EV charger, or null if none. String? _findEvChargerId() { for (final thing in _things) { NymeaThingClass? cls; try { cls = _thingClasses.firstWhere((c) => c.id == thing.thingClassId); } catch (_) { continue; } if (cls.interfaces.any((i) => i.toLowerCase() == 'evcharger')) { return thing.id; } } return null; } /// Send EnergyPlugin.SetChargingInfo with full mode + optional deadline params. /// /// [mode] : UI mode (pv → Eco, minPv → EcoWithMinCurrent, boost → Normal) /// [deadline] : activate deadline variant (*WithTargetTime) — ignored for boost /// [targetSoc] : target battery SOC % (1-100), required when deadline=true /// [endTime] : desired arrival/completion time, required when deadline=true Future setChargingInfo({ required ChargingMode mode, bool deadline = false, int targetSoc = 80, DateTime? endTime, }) async { // Map (mode, deadline) → API mode string + optional minCurrent final String apiMode; int? minCurrent; if (mode == ChargingMode.boost) { apiMode = 'Normal'; } else if (mode == ChargingMode.pv && !deadline) { apiMode = 'Eco'; } else if (mode == ChargingMode.minPv && !deadline) { apiMode = 'EcoWithMinCurrent'; minCurrent = 6; } else if (mode == ChargingMode.pv && deadline) { apiMode = 'EcoWithTargetTime'; } else { // minPv + deadline apiMode = 'EcoMinWithTargetTime'; minCurrent = 6; } if (_connected && !_isSimulation) { final evChargerId = _findEvChargerId(); if (evChargerId != null) { try { final info = { 'evChargerId': evChargerId, 'mode': apiMode, }; if (minCurrent != null) info['minCurrent'] = minCurrent; if (deadline && mode != ChargingMode.boost) { info['targetSoc'] = targetSoc; info['endTime'] = endTime != null ? endTime.millisecondsSinceEpoch ~/ 1000 : null; } await _sendRequest('EnergyPlugin.SetChargingInfo', {'chargingInfo': info}); } catch (e) { _log('SetChargingInfo: $e'); } } else { _log('SetChargingInfo: no EV charger thing found'); } } _energyData = _energyData.copyWith(chargingMode: mode); notifyListeners(); } // ═══════════════════════════════════════════════════════════════════════════ // CONFIG ÉNERGIE — rôles & charges pilotées (écran Rôles & appareils) // ═══════════════════════════════════════════════════════════════════════════ /// gridMeter → **réel** `Energy.SetRootMeter` (natif, testable contre `hems`). Future setRootMeter(String thingId) async { if (_isSimulation || !_connected) { _log('[RootMeter] sim/déconnecté — SetRootMeter ignoré ($thingId)', force: true); return; } try { final r = await _sendRequest( 'Energy.SetRootMeter', {'rootMeterThingId': thingId}); final err = r['params']?['energyError']; _log('[RootMeter] Energy.SetRootMeter($thingId) ← ${err ?? r['status']}', force: true); } catch (e) { _log('[RootMeter] SetRootMeter: $e', force: true); } } /// **STUB** — `NymeaEnergy.SetLoadConfig` n'existe pas encore côté plugin /// (l'energymanager l'exposera plus tard). En attendant on logge **le JSON /// exact qui partira**, sans rien casser ni bloquer. Future setLoadConfig(List> descriptors) async { _log('[LoadConfig STUB] NymeaEnergy.SetLoadConfig — ' '${descriptors.length} charge(s) etmvariableload', force: true); for (final d in descriptors) { final pretty = const JsonEncoder.withIndent(' ').convert(d); _log('[LoadConfig STUB] LoadDescriptor:\n$pretty', force: true); } } /// **STUB** — `NymeaEnergy.GetLoadConfig` absent côté plugin. Retourne vide, /// ne bloque pas le démarrage. Future>> getLoadConfig() async { _log('[LoadConfig STUB] NymeaEnergy.GetLoadConfig → [] ' '(méthode plugin absente)', force: true); return const []; } /// Trace de routage (charges hors etmvariableload : PAC SG-Ready, batterie). void logInfo(String msg) => _log(msg, force: true); // ═══════════════════════════════════════════════════════════════════════════ // THINGS / INTEGRATIONS // ═══════════════════════════════════════════════════════════════════════════ bool _thingsLoaded = false; bool get thingsLoaded => _thingsLoaded; Future _loadThings() async { // ── Étape 1 : charger les things configurés ────────────────────────────── try { _log('📦 GetThings → envoi...', force: true); final r = await _sendRequest('Integrations.GetThings', {}); _log('📦 GetThings ← status=${r["status"]} keys=${r.keys.toList()}', force: true); final thingsRaw = r['params']?['things'] ?? r['result']?['things'] ?? r['things']; if (thingsRaw is List) { _things = thingsRaw .map((t) => NymeaThing.fromJson(t as Map)) .where((t) => t.id.isNotEmpty) // exclure entrées invalides .toList(); _log('📦 ✅ ${_things.length} things configurés:', force: true); for (final t in _things) { _log(' 📱 "${t.name}" | classId=${t.thingClassId.substring(0, 8)}… | ${t.setupStatus}', force: true); } } else { _log('📦 ⚠️ GetThings: clé "things" absente — keys=${r.keys.toList()}', force: true); } } catch (e, st) { _log('📦 ❌ GetThings erreur: $e\n$st', force: true); } // ── Étape 2 : charger UNIQUEMENT les classes des things présents ────────── // On filtre sur les thingClassIds réellement utilisés → évite les 80+ plugins if (_things.isNotEmpty) { try { final neededClassIds = _things .map((t) => t.thingClassId) .where((id) => id.isNotEmpty) .toSet() .toList(); _log('📦 GetThingClasses pour ${neededClassIds.length} classes...', force: true); // nymea supporte un filtre optionnel thingClassIds final r = await _sendRequest('Integrations.GetThingClasses', { 'thingClassIds': neededClassIds, }); final classesRaw = r['params']?['thingClasses'] ?? r['result']?['thingClasses'] ?? r['thingClasses']; if (classesRaw is List && classesRaw.isNotEmpty) { _thingClasses = classesRaw .map((c) => NymeaThingClass.fromJson(c as Map)) .toList(); _log('📦 ✅ ThingClasses: ${_thingClasses.length}', force: true); for (final c in _thingClasses) { _log(' 📋 "${c.name}" | id=${c.id.substring(0, 8)}… | interfaces=${c.interfaces}', force: true); } } else { // Fallback : nymea < 1.6 ne supporte peut-être pas le filtre → tout charger _log('📦 Filtre non supporté, fallback GetThingClasses sans filtre...', force: true); await _loadAllThingClassesFiltered(neededClassIds); } } catch (e) { _log('📦 ❌ GetThingClasses erreur: $e', force: true); // Fallback si le filtre cause une erreur await _loadAllThingClassesFiltered( _things.map((t) => t.thingClassId).toSet().toList() ); } } _thingsLoaded = true; // Initialise le SOC batterie depuis les états des things déjà chargés _syncBatterySOCFromThings(); notifyListeners(); } /// Fallback : charge toutes les classes puis filtre côté client Future _loadAllThingClassesFiltered(List neededIds) async { try { final r = await _sendRequest('Integrations.GetThingClasses', {}); final classesRaw = r['params']?['thingClasses'] ?? r['result']?['thingClasses'] ?? r['thingClasses']; if (classesRaw is List) { final all = classesRaw .map((c) => NymeaThingClass.fromJson(c as Map)) .toList(); // Garder seulement les classes dont on a besoin _thingClasses = all .where((c) => neededIds.contains(c.id)) .toList(); _log('📦 Fallback: ${all.length} classes totales → ${_thingClasses.length} utilisées', force: true); for (final c in _thingClasses) { _log(' 📋 "${c.name}" interfaces=${c.interfaces}', force: true); } } } catch (e) { _log('📦 ❌ Fallback GetThingClasses erreur: $e', force: true); } } Future refreshThings() => _loadThings(); Future>> discoverThings(String thingClassId) async { if (_isSimulation) return []; try { final r = await _sendRequest( 'Integrations.DiscoverThings', {'thingClassId': thingClassId}); return List>.from( r['params']?['thingDescriptors'] ?? []); } catch (_) { return []; } } Future addThing({ required String thingClassId, required String thingDescriptorId, required String name, }) async { if (_isSimulation) return false; try { final r = await _sendRequest('Integrations.AddThing', { 'thingClassId': thingClassId, 'name': name, 'thingDescriptorId': thingDescriptorId, }); if (r['status'] == 'success') { await _loadThings(); return true; } return false; } catch (_) { return false; } } Future removeThing(String thingId) async { if (_isSimulation) return false; try { final r = await _sendRequest( 'Integrations.RemoveThing', {'thingId': thingId}); if (r['status'] == 'success') { _things.removeWhere((t) => t.id == thingId); notifyListeners(); return true; } return false; } catch (_) { return false; } } Future renameThing(String thingId, String newName) async { if (_isSimulation) return false; try { final r = await _sendRequest( 'Integrations.EditThing', {'thingId': thingId, 'name': newName}); if (r['status'] == 'success') { final idx = _things.indexWhere((t) => t.id == thingId); if (idx >= 0) { _things[idx] = _things[idx].copyWith(name: newName); notifyListeners(); } return true; } return false; } catch (_) { return false; } } Future setThingSettings( String thingId, String settingTypeId, dynamic value) async { if (_isSimulation) return true; try { final r = await _sendRequest('Integrations.SetThingSettings', { 'thingId': thingId, 'settings': [{'paramTypeId': settingTypeId, 'value': value}], }); return r['params']?['thingError'] == 'ThingErrorNoError'; } catch (e) { _log('SetThingSettings: $e'); return false; } } /// Exécute une action sur un thing. /// Retourne un [NymeaActionResult] avec le thingError et un message humain. Future executeAction({ required String thingId, required String actionTypeId, Map params = const {}, }) async { if (_isSimulation) { return const NymeaActionResult(success: true); } try { final r = await _sendRequest('Integrations.ExecuteAction', { 'thingId': thingId, 'actionTypeId': actionTypeId, // Format nymea : liste [{paramTypeId, value}] 'params': params.entries .map((e) => {'paramTypeId': e.key, 'value': e.value}) .toList(), }); final thingError = r['params']?['thingError'] as String? ?? 'ThingErrorNoError'; final displayMessage = r['params']?['displayMessage'] as String?; final success = r['status'] == 'success' && (thingError.isEmpty || thingError == 'ThingErrorNoError'); return NymeaActionResult( success: success, thingError: thingError, displayMessage: displayMessage, ); } catch (e) { _log('ExecuteAction: $e'); return NymeaActionResult( success: false, thingError: 'ThingErrorHardwareNotAvailable', displayMessage: e.toString(), ); } } /// Modifie directement la valeur d'un état writable via Integrations.SetStateValue. /// Certains integrateurs nymea supportent cette méthode (nymea ≥ 1.10). Future setStateValue( String thingId, String stateTypeId, dynamic value) async { if (_isSimulation) { // Mise à jour locale pour la simulation final idx = _things.indexWhere((t) => t.id == thingId); if (idx >= 0) { final states = List.from(_things[idx].states); final si = states.indexWhere((s) => s.stateTypeId == stateTypeId); if (si >= 0) { states[si] = NymeaStateValue(stateTypeId: stateTypeId, value: value); } else { states.add(NymeaStateValue(stateTypeId: stateTypeId, value: value)); } _things[idx] = _things[idx].copyWith(states: states); notifyListeners(); } return true; } try { final r = await _sendRequest('Integrations.SetStateValue', { 'thingId': thingId, 'stateTypeId': stateTypeId, 'value': value, }); final thingError = r['params']?['thingError'] as String? ?? 'ThingErrorNoError'; return r['status'] == 'success' && (thingError.isEmpty || thingError == 'ThingErrorNoError'); } catch (e) { _log('SetStateValue: $e'); return false; } } /// Récupère l'historique d'un état via Logging.GetLogEntries. /// /// [stateTypeName] = nom de l'état (ex: "currentPower", "temperature"). /// [sampleRate] = "SampleRate1Min" | "SampleRate15Mins" | "SampleRate1Hour" | /// "SampleRate3Hours" | "SampleRate1Day". /// Timestamps [from] et [to] convertis en ms côté protocole. Future> fetchHistory({ required String thingId, required String stateTypeName, required DateTime from, required DateTime to, String sampleRate = 'SampleRate15Mins', }) async { if (_isSimulation) { // SOC simulé : charge dans la journée (solaire) puis décharge le soir. // Valeurs en % (0-100) pour être normalisables côté graphe. final entries = []; const steps = 48; final step = to.difference(from) ~/ steps; for (int i = 0; i <= steps; i++) { final t = from.add(step * i); final hour = t.hour + t.minute / 60.0; // Montée solaire 8h-15h → 20% → 85%, descente soir → 20% final double soc; if (hour < 8) { soc = 20 + 5 * (hour / 8); } else if (hour < 15) { soc = 25 + 60 * ((hour - 8) / 7); } else { soc = 85 - 65 * ((hour - 15) / 9); } entries.add(HistoryEntry(timestamp: t, value: soc.clamp(5.0, 95.0))); } return entries; } try { // Source nymea : "state-{thingId}-{stateTypeName}" final source = 'state-$thingId-$stateTypeName'; _log('📊 fetchHistory: source=$source sampleRate=$sampleRate', force: true); final r = await _sendRequest('Logging.GetLogEntries', { 'sources': [source], 'startTime': from.millisecondsSinceEpoch, 'endTime': to.millisecondsSinceEpoch, 'sampleRate': sampleRate, 'sortOrder': 'Qt::AscendingOrder', }); final logEntries = (r['params']?['logEntries'] ?? r['logEntries']) as List? ?? []; _log('📊 fetchHistory: ${logEntries.length} entrées reçues pour $source', force: true); if (logEntries.isNotEmpty) { // Log le premier entry pour vérifier le format _log('📊 fetchHistory: exemple entry[0] = ${logEntries.first}', force: true); } final entries = logEntries.map((e) { final ts = DateTime.fromMillisecondsSinceEpoch( (e['timestamp'] as num).toInt()); final values = (e['values'] as Map?)?.cast() ?? {}; // Essaie d'abord le nom de l'état, puis toutes les clés disponibles num? raw = values[stateTypeName] as num?; if (raw == null && values.isNotEmpty) { final nums = values.values.whereType(); if (nums.isNotEmpty) { raw = nums.first; _log('📊 fetchHistory: clé "$stateTypeName" absente, fallback sur première valeur num (clés: ${values.keys.toList()})', force: true); } } final v = raw?.toDouble() ?? 0.0; return HistoryEntry(timestamp: ts, value: v); }).toList(); return entries; } catch (e) { _log('fetchHistory: $e', force: true); return []; } } Future getStateValue(String thingId, String stateTypeId) async { if (_isSimulation) return null; try { final r = await _sendRequest('Integrations.GetStateValue', { 'thingId': thingId, 'stateTypeId': stateTypeId, }); return r['params']?['value']; } catch (_) { return null; } } Future>> getRules() async { if (_isSimulation) return []; try { final r = await _sendRequest('Rules.GetRules', {}); return List>.from(r['params']?['rules'] ?? []); } catch (_) { return []; } } // ═══════════════════════════════════════════════════════════════════════════ // FAVORITES // ═══════════════════════════════════════════════════════════════════════════ void addFavorite(FavoriteWidget widget) { if (!_favoriteWidgets.any((f) => f.id == widget.id)) { _favoriteWidgets.add(widget); notifyListeners(); _saveFavorites(); } } void removeFavorite(String id) { _favoriteWidgets.removeWhere((f) => f.id == id); notifyListeners(); _saveFavorites(); } void reorderFavorites(int oldIndex, int newIndex) { if (newIndex > oldIndex) newIndex--; final item = _favoriteWidgets.removeAt(oldIndex); _favoriteWidgets.insert(newIndex, item); notifyListeners(); _saveFavorites(); } // ═══════════════════════════════════════════════════════════════════════════ // SIMULATION // ═══════════════════════════════════════════════════════════════════════════ void startSimulation() { disconnect(); _connected = true; _isSimulation = true; _nymeaVersion = 'demo 1.8.2'; _generateSimulatedThings(); _generateHistory(); if (_favoriteWidgets.isEmpty) { _favoriteWidgets = [ FavoriteWidget(id: 'fw_pv', type: FavoriteType.pvPower, title: 'Production PV'), FavoriteWidget(id: 'fw_home', type: FavoriteType.homePower, title: 'Consommation'), FavoriteWidget(id: 'fw_soc', type: FavoriteType.batterySOC, title: 'Batterie'), FavoriteWidget(id: 'fw_rates', type: FavoriteType.rates, title: 'Taux'), FavoriteWidget(id: 'fw_ev', type: FavoriteType.evCharger, title: 'Borne EV'), ]; } _simulationTimer?.cancel(); _simulationTimer = Timer.periodic( const Duration(seconds: 2), (_) => _simulateData()); _simulateData(); notifyListeners(); } void _simulateData() { final now = DateTime.now(); final hour = now.hour + now.minute / 60.0; final rng = Random(); double pv = 0; if (hour >= 7 && hour <= 20) { pv = (3800 * sin((hour - 7) * pi / 13) + rng.nextDouble() * 80 - 40) .clamp(0, 4500); } final home = 200 + rng.nextDouble() * 600; final battery = pv > home + 200 ? (pv - home - 200).clamp(0.0, 2000.0) : 0.0; final batDischarge = home > pv + 100 && _energyData.batterySOC > 10 ? min(home - pv, 1500.0) : 0.0; final grid = home - pv - battery + batDischarge; final soc = (_energyData.batterySOC + (battery > 0 ? 0.1 : batDischarge > 0 ? -0.05 : 0)).clamp(5.0, 100.0); _energyData = _energyData.copyWith( pvPower: pv, homePower: home, batteryPower: battery > 0 ? battery : (batDischarge > 0 ? -batDischarge : 0), gridPower: grid, batterySOC: soc, temperature: 7, dayProductionWh: pv * 0.5, daySelfConsumptionWh: home * 0.4, dayGridInjectionWh: grid < 0 ? -grid * 0.3 : 0, selfConsumptionRate: pv > 0 ? (min(pv, home) / pv * 100).clamp(0.0, 100.0) : 0.0, autonomyRate: home > 0 ? ((pv + batDischarge) / home * 100).clamp(0.0, 100.0) : 0.0, dayGains: (pv * 0.5 / 1000) * 0.13, ); notifyListeners(); } void _generateHistory() { final now = DateTime.now(); _historyPoints = List.generate(24, (i) { final h = i.toDouble(); final pv = (h >= 7 && h <= 20) ? (3000 * sin((h - 7) * pi / 13)).clamp(0.0, 4500.0) : 0.0; return HistoryPoint( time: DateTime(now.year, now.month, now.day, i), pvWh: pv, homeWh: 200 + Random().nextDouble() * 400, gridWh: (200 - pv * 0.3).clamp(0, 500), ); }); } void _generateSimulatedThings() { _things = [ NymeaThing(id: 'sim-inverter', name: 'Onduleur SolarEdge', thingClassId: 'solaredge', setupStatus: 'ThingSetupStatusComplete', paramValues: [], states: [NymeaStateValue(stateTypeId: 'power', value: 4200.0)]), NymeaThing(id: 'sim-meter', name: 'Compteur Linky', thingClassId: 'linky', setupStatus: 'ThingSetupStatusComplete', paramValues: [], states: [NymeaStateValue(stateTypeId: 'power', value: 180.0)]), NymeaThing(id: 'sim-battery', name: 'Batterie BYD', thingClassId: 'byd', setupStatus: 'ThingSetupStatusComplete', paramValues: [], states: [NymeaStateValue(stateTypeId: 'soc', value: 42.0)]), NymeaThing(id: 'sim-ev', name: 'Borne Wallbox', thingClassId: 'wallbox', setupStatus: 'ThingSetupStatusComplete', paramValues: [], states: [NymeaStateValue(stateTypeId: 'power', value: 3600.0)]), NymeaThing(id: 'sim-pac', name: 'PAC Atlantic', thingClassId: 'atlantic-pac', setupStatus: 'ThingSetupStatusComplete', paramValues: [], states: [NymeaStateValue(stateTypeId: 'sgReadyState', value: 'normal')]), NymeaThing(id: 'sim-dhw', name: 'Chauffe-eau Atlantic', thingClassId: 'atlantic-dhw', setupStatus: 'ThingSetupStatusComplete', paramValues: [], states: [NymeaStateValue(stateTypeId: 'power', value: false)]), NymeaThing(id: 'sim-ac', name: 'Climatiseur Salon', thingClassId: 'mitsubishi', setupStatus: 'ThingSetupStatusComplete', paramValues: [], states: [NymeaStateValue(stateTypeId: 'power', value: false)]), ]; // Classes simulées avec leurs interfaces — nécessaires pour le filtrage de rôles EMS _thingClasses = [ const NymeaThingClass( id: 'solaredge', name: 'solaredge', displayName: 'SolarEdge', interfaces: ['solarinverter', 'inverter', 'energymeter'], ), const NymeaThingClass( id: 'linky', name: 'linky', displayName: 'Linky', interfaces: ['energymeter', 'smartmeter', 'meter'], ), const NymeaThingClass( id: 'byd', name: 'byd', displayName: 'BYD Battery', interfaces: ['battery', 'energystorage', 'batterymonitor'], ), const NymeaThingClass( id: 'wallbox', name: 'wallbox', displayName: 'Wallbox', interfaces: ['evcharger'], ), const NymeaThingClass( id: 'atlantic-pac', name: 'atlantic-pac', displayName: 'PAC Atlantic', interfaces: ['sgready', 'heatpump'], ), const NymeaThingClass( id: 'atlantic-dhw', name: 'atlantic-dhw', displayName: 'Chauffe-eau', interfaces: ['simpleheatpump', 'smartplug'], ), const NymeaThingClass( id: 'mitsubishi', name: 'mitsubishi', displayName: 'Mitsubishi AC', interfaces: ['airconditioning'], ), ]; } @override void dispose() { _heartbeatTimer?.cancel(); _simulationTimer?.cancel(); _socket?.destroy(); _wsSub?.cancel(); _wsChannel?.sink.close(); super.dispose(); } }