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/daily_balance.dart'; import '../models/energy_data.dart'; import '../models/ev_charger_live.dart'; // EnergyData, HistoryPoint, PowerBalanceEntry import '../models/modbus_models.dart'; // ModbusRtuMaster, SerialPort 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; /// Dernière charge utile de `JSONRPC.Hello`. Porte `experiences[]` et `version`. Map? _helloPayload; Timer? _heartbeatTimer; Timer? _simulationTimer; bool _isSimulation = false; // ── Auth ───────────────────────────────────────────────────────────────────── String _token = ''; String _username = ''; String _password = ''; // ── Locale de connexion (JSONRPC.Hello `o:locale`) ─────────────────────────── // nymea gère la locale PAR CONNEXION (jsonrpcserverimplementation.cpp:239-241, // QHash m_clientLocales) : deux clients de la même box peuvent // avoir des langues différentes. Sans ce paramètre, la box retombe sur son // défaut persisté (`en_US`), quelle que soit la langue de l'app. // // ⚠️ Ne RIEN attendre du vocabulaire matériel : mesuré sur .75, sur 784 chaînes // affichables, l'allemand en change une et le français zéro (aucun catalogue .qm // fr n'est livré par les plugins installés). Les `displayName` de ThingClass, // états et actions sont à traiter comme des identifiants anglais. String _requestedLocale = 'fr_FR'; String _serverLocale = ''; // locale réellement retenue par la box /// Locale demandée à la box au prochain `Hello` (format `fr_FR`). String get requestedLocale => _requestedLocale; set requestedLocale(String v) { if (v.isEmpty || v == _requestedLocale) return; _requestedLocale = v; _log('🌐 Locale demandée changée → $v (effective au prochain Hello)', force: true); } /// Locale confirmée par la box au dernier `Hello` — vide si jamais connecté. /// Diverge de [requestedLocale] si la box n'a pas su interpréter la valeur /// (ex. `zz_ZZ` → `C`). String get serverLocale => _serverLocale; // ── 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; /// Charge utile de `JSONRPC.Hello`, pour les versions d'API d'expérience. Map? get helloPayload => _helloPayload; 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. // La locale part à CHAQUE Hello, donc aussi après une reconnexion subie : // `m_clientLocales` est indexé par clientId et repart au défaut de la box // à chaque nouvelle socket (jsonrpcserverimplementation.cpp:1085-1086). final hello = await _sendRequest( 'JSONRPC.Hello', {'locale': _requestedLocale}, ).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'; // Charge utile de Hello conservée telle quelle : elle porte `experiences[]`, seul // endroit du fil où se lisent les versions d'API (`NymeaEnergy 0.8`). La version du // PAQUET du moteur (`1.15.2+etm15`) n'y est pas — voir [introspect]. _helloPayload = Map.from(p); // ── 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'; // Locale retenue par la box. Journalisée systématiquement (force) : un .qm // introuvable côté box produit EXACTEMENT le même rendu qu'une absence de // traduction. Sans cette trace, un catalogue mal packagé passe inaperçu. _serverLocale = (p['locale'] as String?) ?? (p['language'] as String?) ?? ''; if (_serverLocale == _requestedLocale) { _log('🌐 Locale : demandée "$_requestedLocale" → retenue "$_serverLocale"', force: true); } else { _log('🌐 ⚠️ Locale : demandée "$_requestedLocale" mais la box a retenu ' '"$_serverLocale" — valeur non interprétée, ou Hello sans paramètre.', force: true); } _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 = ''; // La locale DEMANDÉE est une préférence d'app : elle survit à la déconnexion. // Seule la locale CONFIRMÉE est effacée — elle appartient à la connexion morte. _serverLocale = ''; _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; case 'NymeaEnergy.LoadTelemetryChanged': // Émise à chaque changement SIGNIFICATIF **et** toutes les 60 s même sans // changement (battement de cœur). C'est ce battement qui rend un arbitre figé // détectable : les trames continuent d'arriver tandis que leur `timestamp` // cesse d'avancer. Sans lui, « installation stable » et « moteur arrêté » // seraient le même silence. _onLoadTelemetryChanged(params); break; case 'NymeaEnergy.ChargingInfoAdded': case 'NymeaEnergy.ChargingInfoChanged': // La box confirme le mode qu'elle porte VRAIMENT. C'est la seule source du mode // affiché : l'app ne le déduit plus de la commande qu'elle vient d'envoyer. final info = params['chargingInfo']; if (info is Map) { final m = Map.from(info); final id = m['evChargerId'] as String?; if (id != null) { _chargingInfos[id] = m; notifyListeners(); } } break; case 'NymeaEnergy.ChargingInfoRemoved': final id = params['evChargerId'] as String?; if (id != null && _chargingInfos.remove(id) != null) notifyListeners(); break; case 'NymeaEnergy.LoadConfigChanged': // Émise après TOUTE persistance réussie (SetLoadConfig ou relecture du // fichier édité hors app). Porte la liste complète à jour : c'est LA // confirmation qu'une écriture a réellement été appliquée — l'acquittement // du RPC, lui, ne vaut pas application. final raw = (params['loadConfigs'] as List?) ?? const []; _onLoadConfigChanged(raw .whereType() .map((m) => Map.from(m)) .toList()); break; } } /// Abonnés à `NymeaEnergy.LoadConfigChanged` (l'écran de configuration des charges). final List>)> _loadConfigListeners = []; /// S'abonne aux changements de configuration des charges poussés par la box. /// Renvoie la fonction de désabonnement. void Function() onLoadConfigChanged( void Function(List>) cb) { _loadConfigListeners.add(cb); return () => _loadConfigListeners.remove(cb); } void _onLoadConfigChanged(List> configs) { _log('[LoadConfig] ← LoadConfigChanged : ${configs.length} charge(s)', force: true); for (final cb in [..._loadConfigListeners]) { cb(configs); } } 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(), ]); // Après les Things : la borne se trouve par son interface, et `GetChargingInfos` ne // dit rien tant qu'on ne sait pas à quel Thing rattacher chaque entrée. await refreshChargingInfos(); } 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, // Compteurs CUMULÉS, transportés tels quels : ni corrigés, ni recalculés. Leur // plausibilité se juge à l'affichage (EnergyData.isPlausibleKwh) — l'app ne // maquille pas une donnée que la box publie fausse, elle refuse de la présenter // comme une mesure. gridExportKwh: totalRetKwh, gridImportKwh: totalAcqKwh, 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). /// /// ## ⚠️ Les entrées de log ne portent PAS le préfixe `currentPower` /// /// `GetPowerBalance` rend `currentPowerProduction` / `currentPowerConsumption` ; les /// entrées de log, elles, s'appellent **`production`, `consumption`, `acquisition`, /// `storage`**. Réutiliser les clés de l'un pour l'autre ne produit **aucune erreur** : /// tout tombe sur le défaut `0`, et l'historique paraît vide alors que la base est /// pleine. C'est exactement ce qui a fait croire à un défaut de la box côté plugin — et /// c'est pour ça que ce piège est écrit ici plutôt que retenu. /// /// Vérifié sur `.75` le 2026-08-27, avec les clés ci-dessous : **7 960 échantillons sur /// 90 jours** en `SampleRate15Mins`, dont 4 975 avec une production non nulle. Rien ne /// bloque l'historique 90 jours de ce côté. 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'), totalProductionKwh: d('totalProduction'), totalConsumptionKwh:d('totalConsumption'), totalReturnKwh: d('totalReturn'), totalAcquisitionKwh:d('totalAcquisition'), ); }).toList(); } catch (e) { _log('fetchPowerBalanceLogs: $e'); return []; } } /// **Le bilan du jour** — de minuit local à maintenant. /// /// ## Ce qu'il corrige /// /// Le tableau de bord n'avait aucune vue journalière : deux pourcentages instantanés et /// deux compteurs cumulés **depuis l'origine**, dont deux portaient le mot /// « aujourd'hui ». Cette méthode fournit la vraie journée. /// /// ## Les trois pièges, déjà payés une fois chacun /// /// 1. **Minuit LOCAL, et via les logs.** `GetPowerBalance` ne rend que des totaux depuis /// l'origine : il ne sait rien dire d'une journée. Seul `GetPowerBalanceLogs` permet /// de prendre deux points et de les soustraire. `DateTime(y, m, d)` donne bien minuit /// dans le fuseau du téléphone — pas UTC. /// 2. **Les entrées de log ne portent PAS le préfixe `currentPower`.** Elles s'appellent /// `production`, `consumption`, `acquisition`, `storage`, à la différence de /// `GetPowerBalance`. Se tromper de clés ne produit **aucune erreur** : tout tombe sur /// le défaut `0`, et l'écran affiche une journée vide sur une installation qui a /// produit. C'est exactement ce qui a fait croire à un défaut de la box côté plugin. /// Le décodage est déjà correct dans [fetchPowerBalanceLogs] ; cette méthode s'appuie /// dessus plutôt que de refaire un décodage qui pourrait diverger. /// 3. **Les cumulés peuvent être corrompus.** `.75` a publié 9,1e+33 kWh exportés. Un /// bilan bâti là-dessus serait absurde sans le paraître, d'où le contrôle de /// plausibilité sur les deux bornes AVANT toute soustraction. /// /// \return `null` quand la journée n'est **pas mesurable** — jamais un bilan à zéro. /// « Rien produit » et « on ne sait pas » sont deux états différents, et le second ne /// doit pas se déguiser en premier. Future fetchDailyBalance({DateTime? jour}) async { final maintenant = jour ?? DateTime.now(); final minuit = DateTime(maintenant.year, maintenant.month, maintenant.day); // Le quart d'heure est le taux le plus fin peuplé sur la durée d'une journée, et il // place assez de points pour qu'un début de fenêtre tardif se voie. final entries = await fetchPowerBalanceLogs( from: minuit, to: maintenant, sampleRate: 'SampleRate15Mins', ); // Une différence a besoin de deux points. Un seul échantillon n'est pas « une journée // à zéro », c'est une journée qui n'a pas encore de quoi être mesurée. if (entries.length < 2) { _log('[Bilan] ${entries.length} échantillon(s) depuis minuit — non mesurable', force: true); return null; } final tries = [...entries] ..sort((a, b) => a.timestamp.compareTo(b.timestamp)); final a = tries.first; final b = tries.last; // Compteurs corrompus : on refuse AVANT de soustraire. Deux valeurs absurdes peuvent // avoir une différence d'apparence saine. final bornes = [ a.totalProductionKwh, b.totalProductionKwh, a.totalConsumptionKwh, b.totalConsumptionKwh, a.totalReturnKwh, b.totalReturnKwh, a.totalAcquisitionKwh, b.totalAcquisitionKwh, ]; if (!bornes.every(EnergyData.isPlausibleKwh)) { _log('[Bilan] compteurs cumulés inexploitables — non mesurable', force: true); return null; } double delta(double fin, double debut) => fin - debut; final bilan = DailyBalance( from: a.timestamp, to: b.timestamp, sampleCount: tries.length, productionKwh: delta(b.totalProductionKwh, a.totalProductionKwh), consumptionKwh: delta(b.totalConsumptionKwh, a.totalConsumptionKwh), importKwh: delta(b.totalAcquisitionKwh, a.totalAcquisitionKwh), exportKwh: delta(b.totalReturnKwh, a.totalReturnKwh), ); // Un compteur qui RECULE = remise à zéro ou redémarrage au milieu de la fenêtre. Le // bilan serait négatif, donc faux, et un `max(0, …)` le rendrait faux en silence. if (bilan.productionKwh < 0 || bilan.consumptionKwh < 0 || bilan.importKwh < 0 || bilan.exportKwh < 0) { _log('[Bilan] compteur en recul entre les deux bornes — non mesurable', force: true); return null; } _log( '[Bilan] ${tries.length} échantillons · ' 'prod ${bilan.productionKwh.toStringAsFixed(1)} · ' 'conso ${bilan.consumptionKwh.toStringAsFixed(1)} · ' 'import ${bilan.importKwh.toStringAsFixed(1)} · ' 'export ${bilan.exportKwh.toStringAsFixed(1)} kWh · ' 'identité ${bilan.identityHolds ? "OK" : "FAUSSE"}', force: true); return bilan; } 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(); // Les compteurs cumulés de la box sont en kWh (vérifié sur `.75`) : la simulation // doit produire la même unité, sinon elle ment d'un facteur mille et un écran qui // marche en simulation casse contre une vraie box. totalProd += prod * stepHours / 1000; totalConso += conso * stepHours / 1000; totalRet += ret * stepHours / 1000; totalAcq += acq * stepHours / 1000; entries.add(PowerBalanceEntry( timestamp: t, productionW: prod, consumptionW: conso, acquisitionW: acq, storageW: storage, totalProductionKwh: totalProd, totalConsumptionKwh: totalConso, totalReturnKwh: totalRet, totalAcquisitionKwh: 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; } /// `NymeaEnergy.GetChargingInfos` — l'objet de recharge **tel que la box le porte**. /// /// Lecture indispensable avant toute écriture : `SetChargingInfo` reconstruit l'objet /// entier et un champ absent retombe sur son défaut C++ (cf. [setChargingInfo]). On ne /// peut donc pas « écrire un champ » sans avoir lu les autres. /// /// \return l'objet de cette borne, ou `null` si la box n'en porte pas — auquel cas il /// n'y a rien à préserver, et c'est une information, pas un échec. Future?> getChargingInfo(String evChargerId) async { if (_isSimulation || !_connected) return null; try { final r = await _sendRequest('NymeaEnergy.GetChargingInfos', {}); final list = r['params']?['chargingInfos']; if (list is! List) return null; for (final e in list) { if (e is Map && e['evChargerId'] == evChargerId) { return Map.from(e); } } return null; } catch (e) { _log('[Recharge] GetChargingInfos: $e', force: true); return null; } } /// `NymeaEnergy.SetChargingInfo` — mode de recharge du véhicule. /// /// ## Ce bloc envoyait une requête que la box REJETTE /// /// Corrigé le 2026-08-25 contre `JSONRPC.Introspect` puis vérifié par un appel réel /// sur `.75` (plugin `1.15.2+etm15`). Trois erreurs se cumulaient, et chacune suffisait /// à faire échouer l'appel : /// /// - le **namespace** `EnergyPlugin.*` n'existe pas — il n'y a que `NymeaEnergy.*` ; /// - le champ s'appelle `chargingMode`, pas `mode`, et porte l'énumération **complète** /// (`ChargingModeEco`), pas son suffixe (`Eco`) ; /// - `targetSoc` et `endTime` s'appellent `targetPercentage` et `endDateTime`. /// /// Réponse mesurée de la box sur l'ancienne charge utile : /// `Invalid params: Missing required key: chargingMode`. Les boutons de mode de /// recharge étaient donc **inopérants** contre cette version. /// /// ## `minCurrent` n'existe pas dans ce contrat /// /// L'ancien code envoyait `minCurrent: 6`. Le schéma de `ChargingInfo` ne porte /// **aucun** champ de courant : la box publie bien les modes /// `ChargingModeEcoWithMinCurrent` et `ChargingModeEcoMinWithTargetTime`, mais la /// valeur du courant minimal ne se règle pas ici. Le mode se choisit, son paramètre /// non — et l'écran doit le dire au lieu d'offrir un réglage sans effet. /// /// ## Écriture PARTIELLE : la box promet le contraire de ce qu'elle fait /// /// `JSONRPC.Introspect` décrit la méthode ainsi : *« Only given properties will be set, /// others will be untouched. »* **C'est faux.** `ChargingInfo` est reconstruit /// intégralement à chaque écriture (`unpack`), et un champ absent du JSON /// ne reste pas en place : il retombe sur le **défaut du membre C++**. Pour /// `targetPercentage`, ce défaut vaut **80**. /// /// Et la substitution est **muette** : réponse `EnergyErrorNoError`, aucun /// avertissement (le seul du chemin d'écriture ne se déclenche qu'au-delà de 100), et /// la notification `ChargingInfoChanged` qui suit porte 80 comme si l'utilisateur /// l'avait choisi. /// /// Reproduit sur `.75` le 2026-08-27, plugin `+etm22` : `targetPercentage` valait `0`, /// un `SetChargingInfo` portant le seul `chargingMode` l'a laissé à **80**. /// /// D'où **lire, patcher, réécrire l'objet complet** — le même chemin que `priority` /// dans `LoadConfig`. Le seul champ qu'on ne renvoie PAS est `chargingState`, marqué /// `r:` au schéma : le réémettre ferait rejeter l'appel entier. /// /// Conséquence pour l'écran : `targetPercentage` **n'est une cible choisie par /// l'utilisateur que s'il l'a lui-même écrite**. 80 peut être l'empreinte d'une /// écriture qui ne parlait pas de cible du tout. /// /// \return `null` si la box a accepté, sinon de quoi l'annoncer. **Ne rien inventer** /// sur la cause : le détail reste au journal de la box. Future setChargingInfo({ required ChargingMode mode, bool deadline = false, int targetSoc = 80, DateTime? endTime, }) async { // (mode d'UI, échéance) → énumération publiée par la box. final String apiMode; if (mode == ChargingMode.boost) { apiMode = 'ChargingModeNormal'; } else if (mode == ChargingMode.pv && !deadline) { apiMode = 'ChargingModeEco'; } else if (mode == ChargingMode.minPv && !deadline) { apiMode = 'ChargingModeEcoWithMinCurrent'; } else if (mode == ChargingMode.pv && deadline) { apiMode = 'ChargingModeEcoWithTargetTime'; } else { apiMode = 'ChargingModeEcoMinWithTargetTime'; } String? error; if (_connected && !_isSimulation) { final evChargerId = _findEvChargerId(); if (evChargerId == null) { _log('[Recharge] SetChargingInfo: aucune borne trouvée', force: true); error = 'NoEvCharger'; } else { try { // LIRE d'abord — une écriture partielle n'existe pas ici, tout champ omis // retombe sur son défaut C++. Une lecture qui échoue laisse `courant` vide : // on écrit alors le strict nécessaire, ce qui n'est pas pire que l'ancien // comportement et reste honnête sur ce qu'on sait. final courant = await getChargingInfo(evChargerId) ?? const {}; final info = { ...courant, 'evChargerId': evChargerId, 'chargingMode': apiMode, }; // `chargingState` est en lecture seule (`r:`) au schéma : le renvoyer ferait // rejeter TOUT l'appel. Même règle pour toute clé hors schéma — nymea valide // avant d'atteindre le handler. info.remove('chargingState'); if (deadline && mode != ChargingMode.boost) { info['targetPercentage'] = targetSoc; if (endTime != null) { info['endDateTime'] = endTime.millisecondsSinceEpoch ~/ 1000; } } else if (!info.containsKey('targetPercentage')) { // Sans échéance, l'app ne CHOISIT pas de cible — mais l'omettre en // choisirait une (80) à la place de l'utilisateur, sans rien lui dire. On // repose donc explicitement la valeur que la box portait déjà ; si elle n'en // portait aucune, `0` est le neutre, pas `80`. info['targetPercentage'] = 0; } final r = await _sendRequest( 'NymeaEnergy.SetChargingInfo', {'chargingInfo': info}); final err = r['params']?['energyError'] as String?; if (r['status'] != 'success') { error = (r['error'] as String?) ?? r['status'] as String?; } else if (err != null && err != 'EnergyErrorNoError') { error = err; } _log('[Recharge] SetChargingInfo($apiMode) ← ${error ?? 'OK'}', force: true); } catch (e) { _log('[Recharge] SetChargingInfo: $e', force: true); error = e.toString(); } } } // ⚠️ L'app ne tient PLUS d'état de mode local, même après un succès. // // Elle le faisait, et c'était la racine de la fiction : `EnergyData.chargingMode` // partait de `pv` par défaut, n'était jamais relu sur la box, et n'avançait qu'ici — // donc l'écran affichait « Mode PV » sur une borne que personne n'avait réglée, et // « Boost » après une commande que la borne pouvait avoir appliquée autrement. // // Le mode affiché vient désormais de `GetChargingInfos` et de la notification // `ChargingInfoChanged`. On relit après une écriture acceptée : c'est la box qui dit // ce qu'elle porte, pas l'app qui se souvient de ce qu'elle a demandé. if (error == null) await refreshChargingInfos(); return error; } // ── Ce que la borne publie, et rien de plus ───────────────────────────────── /// `evChargerId` → charge utile `ChargingInfo` telle que la box la porte. /// /// Alimenté par [refreshChargingInfos] et par les notifications /// `ChargingInfoAdded/Changed/Removed`. Vide tant que rien n'a été lu — et « vide » doit /// se lire « pas encore lu », jamais « aucun mode ». final Map> _chargingInfos = {}; /// Relit `NymeaEnergy.GetChargingInfos` pour TOUTES les bornes. Future refreshChargingInfos() async { if (_isSimulation || !_connected) return; try { final r = await _sendRequest('NymeaEnergy.GetChargingInfos', {}); final list = r['params']?['chargingInfos']; if (list is! List) return; _chargingInfos.clear(); for (final e in list) { if (e is Map) { final m = Map.from(e); final id = m['evChargerId'] as String?; if (id != null) _chargingInfos[id] = m; } } _log('[Recharge] GetChargingInfos → ${_chargingInfos.length} borne(s)', force: true); notifyListeners(); } catch (e) { _log('[Recharge] GetChargingInfos: $e', force: true); } } /// Valeur d'un état du Thing **par son nom** (`pluggedIn`, `currentPower`, …). /// /// Les états voyagent par `stateTypeId` (un UUID) ; le nom vit dans la `ThingClass`. Sans /// la classe, on ne peut rien nommer — et `null` est alors la seule réponse honnête. Object? _stateByName(NymeaThing thing, String name) { NymeaThingClass? cls; for (final c in _thingClasses) { if (c.id == thing.thingClassId) { cls = c; break; } } if (cls == null) return null; String? id; for (final st in cls.stateTypes) { if (st.name == name) { id = st.id; break; } } if (id == null) return null; for (final s in thing.states) { if (s.stateTypeId == id) return s.value; } return null; } /// La première borne de recharge configurée, **telle que la box la publie**. /// /// `null` = aucune borne sur cette installation. Chaque champ de [EvChargerLive] est /// nullable pour la même raison : une borne qui ne publie pas `currentPower` ne consomme /// pas 0 W, elle ne le dit pas. Cf. LM-1009 §C1. EvChargerLive? get evCharger { final id = _findEvChargerId(); if (id == null) return null; NymeaThing? thing; for (final t in _things) { if (t.id == id) { thing = t; break; } } if (thing == null) return null; final info = _chargingInfos[id]; final power = _stateByName(thing, 'currentPower'); return EvChargerLive( thingId: id, name: thing.name, pluggedIn: _stateByName(thing, 'pluggedIn') as bool?, charging: _stateByName(thing, 'charging') as bool?, currentPowerW: power is num ? power.toDouble() : null, apiMode: info?['chargingMode'] as String?, ); } // ═══════════════════════════════════════════════════════════════════════════ // CONFIG ÉNERGIE — rôles & charges pilotées (écran Rôles & appareils) // ═══════════════════════════════════════════════════════════════════════════ /// `NymeaEnergy.GetBatteryLevelConsideration` — la **réserve batterie**. /// /// ## Un seuil dont la portée s'élargit /// /// Fraction de 0 à 1 : sous ce niveau de charge, la batterie est considérée comme /// réservée. Aujourd'hui la règle ne gouverne que la recharge du véhicule. Une fois /// montée dans le budget, elle gouvernera **toute l'installation** : sous le seuil, le /// budget de surplus est **annulé** — pas réduit — donc ni ECS, ni PAC, ni bornes. /// /// ## Pourquoi il ne peut pas rester invisible /// /// Le défaut d'usine **était** à 0,9 ; il est passé à **0,2**. Sur une installation dont /// la batterie plafonne à 50 %, 0,9 gèle tout — et le symptôme, chez un client, est un /// HEMS qui ne pilote plus rien par grand soleil, **sans erreur ni panne**. Le nouveau /// défaut ne rattrape pas les box déjà déployées, qui gardent la valeur persistée, et le /// seuil reste réglable jusqu'à 1,0. Un seuil qui peut produire ça n'est pas un /// paramètre d'usine : c'est un réglage d'installation, et il doit se voir. /// /// \return la fraction lue, ou `null` si la box ne l'expose pas. Future getBatteryLevelConsideration() async { if (_isSimulation || !_connected) return null; try { final r = await _sendRequest('NymeaEnergy.GetBatteryLevelConsideration', {}); final v = (r['params']?['batteryLevelConsideration'] as num?)?.toDouble(); _log('[Batterie] réserve → ${v ?? 'non exposée'}', force: true); return v; } catch (e) { _log('[Batterie] GetBatteryLevelConsideration: $e', force: true); return null; } } /// `NymeaEnergy.SetBatteryLevelConsideration`. /// /// \param fraction niveau de réserve, de 0 à 1. /// \return `null` si la box a accepté, sinon le code d'erreur. Future setBatteryLevelConsideration(double fraction) async { if (_isSimulation || !_connected) return 'Disconnected'; try { final r = await _sendRequest('NymeaEnergy.SetBatteryLevelConsideration', {'batteryLevelConsideration': fraction}); final err = r['params']?['energyError'] as String?; _log('[Batterie] réserve ← $fraction : ${err ?? r['status']}', force: true); if (err == null || err == 'EnergyErrorNoError') return null; return err; } catch (e) { _log('[Batterie] SetBatteryLevelConsideration: $e', force: true); return e.toString(); } } /// `Energy.GetRootMeter` — le compteur principal **tel que la box le connaît**. /// /// ## Ce qu'il répare /// /// L'app ne l'appelait **jamais** : elle savait poser le compteur racine /// (`SetRootMeter`) mais pas le relire. Son état en mémoire repartait donc vide à chaque /// démarrage, et le rôle « compteur réseau » réapparaissait « à configurer » alors que la /// box l'avait bel et bien — vérifié sur `.75`, qui rend /// `549449c1-…` (Fronius Three Phase Meter) sans discontinuer. /// /// Rien n'était perdu : c'était une configuration **jamais lue**. La nuance compte, parce /// qu'elle change le geste — il n'y a pas à reconfigurer, il y a à afficher. /// /// \return le ThingId du compteur racine, ou `null` si la box n'en a pas. Future getRootMeter() async { if (_isSimulation || !_connected) return null; try { final r = await _sendRequest('Energy.GetRootMeter', {}); final id = r['params']?['rootMeterThingId'] as String?; _log('[RootMeter] GetRootMeter → ${id ?? 'aucun'}', force: true); return (id == null || id.isEmpty) ? null : id; } catch (e) { _log('[RootMeter] GetRootMeter: $e', force: true); return null; } } /// 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); } } /// `NymeaEnergy.SetLoadConfig` — **réel**, vérifié contre `.75` le 2026-08-25. /// /// Remplace l'ENSEMBLE des charges : rejet en bloc (`energyError`) si une seule /// entrée est invalide ; persistance et application atomiques. /// /// [payloads] doit provenir de `LoadConfigEntry.patched()` — c'est-à-dire des maps /// reçues de [getLoadConfig] avec les seuls champs modifiés réécrits. **Ne jamais /// passer une re-sérialisation d'un modèle typé** : le plugin sérialise via le /// méta-objet, donc la réponse porte les charges utiles vides des mécanismes /// inutilisés (`sgReady: {}`, `powerLevels: []`) qu'une reconstruction amputerait. /// /// L'acquittement ne vaut PAS application : la confirmation est la notification /// `NymeaEnergy.LoadConfigChanged`, émise après toute persistance réussie. /// /// \return `null` si la box a accepté, sinon le code d'erreur qu'elle a renvoyé. Future setLoadConfig(List> payloads) async { if (_isSimulation || !_connected) { _log('[LoadConfig] sim/déconnecté — SetLoadConfig ignoré', force: true); return 'Disconnected'; } try { final r = await _sendRequest( 'NymeaEnergy.SetLoadConfig', {'loadConfigs': payloads}); final err = r['params']?['energyError'] as String?; _log('[LoadConfig] SetLoadConfig(${payloads.length} charge(s)) ← ' '${err ?? r['status']}', force: true); if (err == null || err == 'EnergyErrorNoError') return null; return err; } catch (e) { _log('[LoadConfig] SetLoadConfig: $e', force: true); return e.toString(); } } /// `NymeaEnergy.GetLoadConfig` — **réel**, vérifié contre `.75`. /// /// Renvoie les charges utiles BRUTES, telles que la box les a émises, y compris les /// charges `enabled: false` (éditables depuis l'app) et les charges utiles vides des /// mécanismes inutilisés. Ces maps sont la source de vérité : les conserver intactes. Future>> getLoadConfig() async { if (_isSimulation || !_connected) return const []; try { final r = await _sendRequest('NymeaEnergy.GetLoadConfig', {}); final raw = (r['params']?['loadConfigs'] as List?) ?? const []; final out = raw .whereType() .map((m) => Map.from(m)) .toList(); _log('[LoadConfig] GetLoadConfig → ${out.length} charge(s)', force: true); return out; } catch (e) { _log('[LoadConfig] GetLoadConfig: $e', force: true); rethrow; } } /// `NymeaEnergy.GetLoadTelemetry` — instantané de l'état runtime de l'arbitrage. /// /// Le `Get` **ne recalcule rien** : il rend l'objet qui a été publié au dernier cycle. /// La charge utile est identique à celle de la notification `LoadTelemetryChanged`. /// /// \return la charge utile brute, ou `null` si **cette box n'expose pas la méthode** /// (plugin antérieur au lot télémétrie). Les deux cas se distinguent : une box sans la /// méthode répond `status: error` / `"No such method"`, ce qui n'est pas une panne à /// signaler mais une version à annoncer. Une erreur de transport, elle, remonte. Future?> getLoadTelemetry() async { if (_isSimulation || !_connected) return null; final r = await _sendRequest('NymeaEnergy.GetLoadTelemetry', {}); if (r['status'] != 'success') { _loadTelemetrySupported = false; _log('[Télémétrie] GetLoadTelemetry ← ${r['error'] ?? r['status']} ' '— cette box n\'expose pas la méthode', force: true); return null; } _loadTelemetrySupported = true; final params = r['params']; if (params is! Map) return null; final out = Map.from(params); _log('[Télémétrie] GetLoadTelemetry → ${(out['loads'] as List?)?.length ?? 0} ' 'charge(s), cycle ${out['timestamp'] ?? 'AUCUN'}', force: true); return out; } /// Faux dès qu'un `GetLoadTelemetry` a été refusé par cette box. bool _loadTelemetrySupported = true; bool get loadTelemetrySupported => _loadTelemetrySupported; /// `AirConditioning.GetZones` — les zones de climatisation déclarées sur la box. /// /// ## Ce que l'introspection a révélé, et qui contredit une hypothèse de travail /// /// L'expérience `AirConditioning` n'est pas une surface réduite qu'il faudrait griser : /// elle publie **huit** méthodes, toutes en écriture — `AddZone`, `RemoveZone`, /// `SetZoneName`, `SetZoneThings`, `SetZoneStandbySetpoint`, /// `SetZoneSetpointOverride`, `SetZoneWeekSchedule`, `GetZones` (relevé sur `.75`, /// `AirConditioning 1.1`). Une zone porte ses thermostats, ses vannes, ses sondes, /// une consigne de veille, une dérogation avec mode et durée, et un programme /// hebdomadaire complet. /// /// Les champs de `ZoneInfo` sont tous `r:` — mais cela ne veut PAS dire « lecture /// seule » au sens fonctionnel : ils se règlent par les setters dédiés ci-dessus, pas /// en réécrivant l'objet. Confondre les deux ferait griser un écran entier qui est en /// réalité pleinement pilotable. /// /// **Et c'est bien là que vivent thermostats et vannes** — pas dans `LoadConfig`. /// LM-202 : un thermostat ouvre une vanne, il ne porte pas la consommation, qui reste /// à la machine. Les déclarer comme charges produirait des entrées à 0 W dans le /// waterfall pendant que la PAC est comptée ailleurs. Les zones décrivent **où va la /// chaleur**, pas **combien on en achète**. /// /// \return les zones, ou liste vide. Sur `.75` au 2026-08-25 : **aucune zone déclarée**. Future>> getZones() async { if (_isSimulation || !_connected) return const []; try { final r = await _sendRequest('AirConditioning.GetZones', {}); final raw = (r['params']?['zones'] as List?) ?? const []; final out = raw .whereType() .map((m) => Map.from(m)) .toList(); _log('[Zones] GetZones → ${out.length} zone(s)', force: true); return out; } catch (e) { _log('[Zones] GetZones: $e', force: true); return const []; } } /// `JSONRPC.Introspect` — le **schéma que cette box accepte**, tel qu'elle le publie. /// /// C'est la seule source honnête pour décider ce qu'un écran active ou grise. Le parc /// est hétérogène : une table de versions codée en dur mentirait dans les deux sens — /// champ disponible resté gris sur une box récente, champ absent présenté comme actif /// sur une ancienne. Dérivé du schéma, un champ que le plugin se met à exposer s'active /// **seul**, sans nouvelle version de l'app. /// /// Relevé sur `.75` (plugin `1.15.2+etm15`) : 20 méthodes `NymeaEnergy.*`, 8 méthodes /// `AirConditioning.*`. Les préfixes de champ portent le sens — `o:` optionnel et /// écrivable, `r:` lecture seule. /// /// \return la charge utile brute d'introspection, ou `null` si elle n'a pas pu être /// lue. `null` ne veut PAS dire « champs absents » : il veut dire « on ne sait pas », /// et les écrans doivent l'annoncer ainsi plutôt que de griser au hasard. Future?> introspect() async { if (_isSimulation || !_connected) return null; try { final r = await _sendRequest('JSONRPC.Introspect', {}); final params = r['params']; if (r['status'] != 'success' || params is! Map) { _log('[Schéma] Introspect ← ${r['error'] ?? r['status']}', force: true); return null; } final out = Map.from(params); final methods = (out['methods'] as Map?)?.length ?? 0; _log('[Schéma] Introspect → $methods méthode(s)', force: true); return out; } catch (e) { _log('[Schéma] Introspect: $e', force: true); return null; } } /// `NymeaEnergy.ClearLoadFault` — lève le verrou de défaut d'une charge (ECS-410). /// /// **L'acquittement ne prouve rien.** Vérifié sur `.75` le 2026-08-25 : appelée sur une /// charge parfaitement saine, la méthode répond `EnergyErrorNoError`. Le seul constat /// recevable est `available` repassé à vrai dans la télémétrie **relue**. /// /// \return `null` si la box a accepté, sinon le code d'erreur (`loadId` inconnu → /// `EnergyErrorInvalidParameter`). Future clearLoadFault(String loadId) async { if (_isSimulation || !_connected) return 'Disconnected'; try { final r = await _sendRequest( 'NymeaEnergy.ClearLoadFault', {'loadId': loadId}); final err = r['params']?['energyError'] as String?; _log('[Télémétrie] ClearLoadFault($loadId) ← ${err ?? r['status']} ' '— ne vaut PAS levée : à constater sur la télémétrie', force: true); if (err == null || err == 'EnergyErrorNoError') return null; return err; } catch (e) { _log('[Télémétrie] ClearLoadFault: $e', force: true); return e.toString(); } } /// Abonnés à `NymeaEnergy.LoadTelemetryChanged`. final List)> _loadTelemetryListeners = []; /// S'abonne à la télémétrie poussée. Renvoie la fonction de désabonnement. void Function() onLoadTelemetryChanged(void Function(Map) cb) { _loadTelemetryListeners.add(cb); return () => _loadTelemetryListeners.remove(cb); } void _onLoadTelemetryChanged(Map payload) { _log('[Télémétrie] ← LoadTelemetryChanged : cycle ' '${payload['timestamp'] ?? 'AUCUN'}, ' '${(payload['loads'] as List?)?.length ?? 0} charge(s)'); for (final cb in [..._loadTelemetryListeners]) { cb(payload); } } /// Trace de routage (charges hors etmvariableload : PAC SG-Ready, batterie). void logInfo(String msg) => _log(msg, force: true); // ═══════════════════════════════════════════════════════════════════════════ // MODBUS RTU MASTERS (zone installateur — prérequis meters SDM/abbterra) // ═══════════════════════════════════════════════════════════════════════════ /// Ports série dispo sur l'hôte (`ModbusRtu.GetSerialPorts`). Future> getSerialPorts() async { if (_isSimulation || !_connected) return const []; try { final r = await _sendRequest('ModbusRtu.GetSerialPorts', {}); final raw = (r['params']?['serialPorts'] as List?) ?? const []; return raw .map((e) => SerialPort.fromJson(e as Map)) .toList(); } catch (e) { _log('GetSerialPorts: $e', force: true); return const []; } } /// Masters Modbus RTU configurés (`ModbusRtu.GetModbusRtuMasters`). Future> getModbusRtuMasters() async { if (_isSimulation || !_connected) return const []; try { final r = await _sendRequest('ModbusRtu.GetModbusRtuMasters', {}); final raw = (r['params']?['modbusRtuMasters'] as List?) ?? const []; return raw .map((e) => ModbusRtuMaster.fromJson(e as Map)) .toList(); } catch (e) { _log('GetModbusRtuMasters: $e', force: true); return const []; } } /// Crée un master (`ModbusRtu.AddModbusRtuMaster`). Renvoie `null` si succès, /// sinon un message d'erreur lisible (modbusError mappé / erreur transport). Future addModbusRtuMaster({ required String serialPort, required int baudrate, required String parity, required String dataBits, required String stopBits, required int numberOfRetries, required int timeout, }) => _writeMaster('ModbusRtu.AddModbusRtuMaster', { 'serialPort': serialPort, 'baudrate': baudrate, 'parity': parity, 'dataBits': dataBits, 'stopBits': stopBits, 'numberOfRetries': numberOfRetries, 'timeout': timeout, }); /// Édite un master (`ModbusRtu.ReconfigureModbusRtuMaster`). Future reconfigureModbusRtuMaster({ required String modbusUuid, required String serialPort, required int baudrate, required String parity, required String dataBits, required String stopBits, required int numberOfRetries, required int timeout, }) => _writeMaster('ModbusRtu.ReconfigureModbusRtuMaster', { 'modbusUuid': modbusUuid, 'serialPort': serialPort, 'baudrate': baudrate, 'parity': parity, 'dataBits': dataBits, 'stopBits': stopBits, 'numberOfRetries': numberOfRetries, 'timeout': timeout, }); /// Supprime un master (`ModbusRtu.RemoveModbusRtuMaster`). Future removeModbusRtuMaster(String modbusUuid) => _writeMaster('ModbusRtu.RemoveModbusRtuMaster', {'modbusUuid': modbusUuid}); /// Tronc commun Add/Reconfigure/Remove : envoie, lit `modbusError`, mappe. Future _writeMaster( String method, Map params) async { if (_isSimulation || !_connected) { return 'Indisponible : aucune box connectée (mode démo).'; } try { final r = await _sendRequest(method, params); final err = (r['params']?['modbusError'] as String?) ?? 'ModbusRtuErrorNoError'; return err == 'ModbusRtuErrorNoError' ? null : modbusErrorMessage(err); } catch (e) { _log('$method: $e', force: true); return 'Erreur de communication avec la box.'; } } // ═══════════════════════════════════════════════════════════════════════════ // THINGS / INTEGRATIONS // ═══════════════════════════════════════════════════════════════════════════ bool _thingsLoaded = false; bool get thingsLoaded => _thingsLoaded; /// Catalogue (lecture seule) — fabricants connus de ce nymea (`GetVendors`). Future> getVendors() async { if (_isSimulation || !_connected) return const []; try { final r = await _sendRequest('Integrations.GetVendors', {}); final raw = (r['params']?['vendors'] as List?) ?? const []; return raw .map((e) => NymeaVendor.fromJson(e as Map)) .toList(); } catch (e) { _log('GetVendors: $e', force: true); return const []; } } /// Catalogue (lecture seule) — **toutes** les thing classes connues de ce /// nymea (`GetThingClasses` sans filtre). Indépendant de `_thingClasses` /// (qui ne couvre que les classes des things configurés). Future> getAllThingClasses() async { if (_isSimulation || !_connected) return const []; try { final r = await _sendRequest('Integrations.GetThingClasses', {}); final raw = (r['params']?['thingClasses'] as List?) ?? const []; return raw .map((e) => NymeaThingClass.fromJson(e as Map)) .toList(); } catch (e) { _log('GetAllThingClasses: $e', force: true); return const []; } } /// **Lot C — discovery.** Lance `Integrations.DiscoverThings`. Renvoie /// `(error, descriptors)` : `error==null` = succès (descriptors peut être /// **vide** = aucun appareil trouvé, à distinguer d'une erreur). /// /// ⚠️ À REVALIDER sur SDM réel : le scan « peut prendre un moment » — si le bus /// réel dépasse le timeout 15 s de `_sendRequest`, l'allonger ici. Future<({String? error, List descriptors})> discoverThings( String thingClassId, List> discoveryParams, ) async { if (_isSimulation || !_connected) { return ( error: 'Indisponible : aucune box connectée (mode démo).', descriptors: [] ); } try { final r = await _sendRequest('Integrations.DiscoverThings', { 'thingClassId': thingClassId, if (discoveryParams.isNotEmpty) 'discoveryParams': discoveryParams, }); final err = (r['params']?['thingError'] as String?) ?? 'ThingErrorNoError'; if (err != 'ThingErrorNoError') { return (error: thingErrorMessage(err), descriptors: []); } final raw = (r['params']?['thingDescriptors'] as List?) ?? const []; return ( error: null, descriptors: raw .map((e) => ThingDescriptor.fromJson(e as Map)) .toList() ); } catch (e) { _log('DiscoverThings: $e', force: true); return ( error: 'Erreur de communication avec la box.', descriptors: [] ); } } /// **Lot C — ajout descriptor-based** (`AddThing` avec `thingDescriptorId`, /// params pris du descriptor). `null` = succès (things rechargés), sinon /// message FR. Réservé aux classes `setupMethod=JustAdd` (pas de pairing). Future addThingFromDescriptor({ required String thingClassId, required String thingDescriptorId, required String name, }) async { if (_isSimulation || !_connected) return 'Indisponible (mode démo).'; try { final r = await _sendRequest('Integrations.AddThing', { 'name': name, 'thingClassId': thingClassId, 'thingDescriptorId': thingDescriptorId, }); final err = (r['params']?['thingError'] as String?) ?? 'ThingErrorNoError'; if (err == 'ThingErrorNoError') { await _loadThings(); // refresh things return null; } return thingErrorMessage(err); } catch (e) { _log('AddThing(descriptor): $e', force: true); return 'Erreur de communication avec la box.'; } } 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(); // (ancien discoverThings sans params/erreur retiré — remplacé par la version // typée avec discoveryParams + ThingError, plus haut.) 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(); } }