// ============================================================ // nymea_client.dart // Client JSON-RPC WebSocket pour nymea — etm_powersync_app // // Protocole : JSON-RPC 2.0 sur WebSocket (port 2222) // Namespace plugin : "NymeaEnergy" (v0–8) // ============================================================ import 'dart:async'; import 'dart:convert'; import 'package:web_socket_channel/web_socket_channel.dart'; import 'package:web_socket_channel/io.dart'; // ───────────────────────────────────────────── // Modèles de données // ───────────────────────────────────────────── enum ChargingMode { normal, eco, ecoWithTargetTime } enum ChargingState { idle, surplusCharging, spotMarketCharging, timeRequirement } class ChargingInfo { final String evChargerId; final ChargingMode chargingMode; final ChargingState chargingState; final bool spotMarketChargingEnabled; final int dailySpotMarketPercentage; final int targetPercentage; final String? endDateTime; ChargingInfo({ required this.evChargerId, required this.chargingMode, required this.chargingState, required this.spotMarketChargingEnabled, required this.dailySpotMarketPercentage, required this.targetPercentage, this.endDateTime, }); factory ChargingInfo.fromJson(Map json) { final modeMap = { 'ChargingModeNormal': ChargingMode.normal, 'ChargingModeEco': ChargingMode.eco, 'ChargingModeEcoWithTargetTime': ChargingMode.ecoWithTargetTime, }; final stateMap = { 'ChargingStateIdle': ChargingState.idle, 'ChargingStateSurplusCharging': ChargingState.surplusCharging, 'ChargingStateSpotMarketCharging': ChargingState.spotMarketCharging, 'ChargingStateTimeRequirement': ChargingState.timeRequirement, }; return ChargingInfo( evChargerId: json['evChargerId'] ?? '', chargingMode: modeMap[json['chargingMode']] ?? ChargingMode.normal, chargingState: stateMap[json['chargingState']] ?? ChargingState.idle, spotMarketChargingEnabled: json['spotMarketChargingEnabled'] ?? false, dailySpotMarketPercentage: json['dailySpotMarketPercentage'] ?? 80, targetPercentage: json['targetPercentage'] ?? 80, endDateTime: json['endDateTime'], ); } } class ScoreEntry { final DateTime startDateTime; final DateTime endDateTime; final double value; final double weighting; // 0.0 (pire) → 1.0 (meilleur) ScoreEntry({ required this.startDateTime, required this.endDateTime, required this.value, required this.weighting, }); factory ScoreEntry.fromJson(Map json) { return ScoreEntry( startDateTime: DateTime.parse(json['startDateTime']), endDateTime: DateTime.parse(json['endDateTime']), value: (json['value'] as num).toDouble(), weighting: (json['weighting'] as num).toDouble(), ); } } // ───────────────────────────────────────────── // Client nymea principal // ───────────────────────────────────────────── class NymeaClient { final String host; final int port; WebSocketChannel? _channel; int _requestId = 1; String? _token; bool _authenticated = false; // Map id → Completer en attente de réponse final Map>> _pendingRequests = {}; // StreamController pour les notifications push final StreamController> _notificationController = StreamController.broadcast(); Stream> get notifications => _notificationController.stream; NymeaClient({required this.host, this.port = 2222}); // ─── Connexion ─────────────────────────────── Future connect() async { final uri = Uri.parse('ws://$host:$port'); _channel = IOWebSocketChannel.connect(uri); _channel!.stream.listen( (data) => _onMessage(data as String), onError: (e) => print('[nymea] Erreur WebSocket: $e'), onDone: () => print('[nymea] Connexion fermée'), ); // Attendre le Hello initial de nymea await _waitForHello(); print('[nymea] Connecté à $host:$port'); } Future _waitForHello() async { // nymea envoie spontanément un Hello dès la connexion // On attend simplement 500ms que la connexion s'établisse await Future.delayed(const Duration(milliseconds: 500)); } void _onMessage(String raw) { final msg = jsonDecode(raw) as Map; if (msg.containsKey('id') && _pendingRequests.containsKey(msg['id'])) { // Réponse à une requête _pendingRequests[msg['id']]!.complete(msg); _pendingRequests.remove(msg['id']); } else if (msg.containsKey('notification')) { // Notification push _notificationController.add(msg); } } Future> _send( String method, Map params) async { final id = _requestId++; final completer = Completer>(); _pendingRequests[id] = completer; final request = {'id': id, 'method': method, 'params': params}; if (_token != null) { request['token'] = _token!; } _channel!.sink.add(jsonEncode(request)); return completer.future.timeout( const Duration(seconds: 10), onTimeout: () => throw TimeoutException('Timeout pour $method'), ); } // ─── Authentification ──────────────────────── /// Crée un compte utilisateur (première utilisation uniquement) Future createUser(String username, String password) async { final response = await _send('JSONRPC.CreateUser', { 'username': username, 'password': password, }); return response['params']?['error'] == null; } /// Authentifie la connexion et récupère un token Future authenticate( String username, String password, String deviceName) async { final response = await _send('JSONRPC.Authenticate', { 'username': username, 'password': password, 'deviceName': deviceName, }); final params = response['params'] as Map?; if (params?['success'] == true) { _token = params!['token'] as String; _authenticated = true; print('[nymea] Authentifié — token: ${_token!.substring(0, 8)}...'); return _token; } print('[nymea] Échec authentification: ${params?['error']}'); return null; } /// Connexion avec token existant (sessions suivantes) Future authenticateWithToken(String token) async { _token = token; final response = await _send('JSONRPC.IsTokenValid', {'token': token}); final valid = response['params']?['valid'] == true; if (valid) _authenticated = true; return valid; } // ─── Abonnement aux notifications ─────────── Future subscribeToNotifications(List namespaces) async { await _send('JSONRPC.SetNotificationStatus', { 'namespaces': namespaces, }); print('[nymea] Abonné aux namespaces: $namespaces'); } // ─── Things ────────────────────────────────── /// Récupère tous les Things du système Future>> getThings() async { final response = await _send('Integrations.GetThings', {}); final things = response['params']?['things'] as List? ?? []; return things.cast>(); } /// Filtre les Things qui implémentent une interface donnée Future>> getThingsByInterface( String interface) async { final allThings = await getThings(); // nymea retourne les interfaces dans thingClass.interfaces // Pour filtrer, on utilise Integrations.GetThingClasses final classResponse = await _send('Integrations.GetThingClasses', {}); final classes = classResponse['params']?['thingClasses'] as List? ?? []; // Construire un Set des classIds qui supportent l'interface final matchingClassIds = {}; for (final tc in classes.cast>()) { final interfaces = (tc['interfaces'] as List?)?.cast() ?? []; if (interfaces.contains(interface)) { matchingClassIds.add(tc['id'] as String); } } return allThings .where((t) => matchingClassIds.contains(t['thingClassId'])) .toList(); } /// Lit la valeur d'un State Future getStateValue(String thingId, String stateTypeId) async { final response = await _send('Integrations.GetStateValue', { 'thingId': thingId, 'stateTypeId': stateTypeId, }); return response['params']?['value']; } /// Exécute une Action sur un Thing Future executeAction({ required String thingId, required String actionTypeId, Map params = const {}, }) async { final actionParams = params.entries .map((e) => {'paramTypeId': e.key, 'value': e.value}) .toList(); final response = await _send('Integrations.ExecuteAction', { 'thingId': thingId, 'actionTypeId': actionTypeId, 'params': actionParams, }); return response['params']?['thingError'] ?? 'ThingErrorNoError'; } // ─── API NymeaEnergy ───────────────────────── /// Récupère la limite de courant par phase Future getPhasePowerLimit() async { final response = await _send('NymeaEnergy.GetPhasePowerLimit', {}); return response['params']?['phasePowerLimit'] as int? ?? 0; } /// Définit la limite de courant par phase (0 = désactivé) Future setPhasePowerLimit(int ampere) async { final response = await _send( 'NymeaEnergy.SetPhasePowerLimit', {'phasePowerLimit': ampere}); return response['params']?['energyError'] ?? 'EnergyErrorNoError'; } /// Récupère les infos de charge de tous les chargeurs Future> getChargingInfos({String? evChargerId}) async { final params = {}; if (evChargerId != null) params['evChargerId'] = evChargerId; final response = await _send('NymeaEnergy.GetChargingInfos', params); final infos = response['params']?['chargingInfos'] as List? ?? []; return infos .cast>() .map(ChargingInfo.fromJson) .toList(); } /// Modifie la config de charge d'un chargeur Future setChargingInfo({ required String evChargerId, ChargingMode? mode, bool? spotMarketEnabled, int? dailySpotMarketPercentage, int? targetPercentage, String? endDateTime, }) async { final modeNames = { ChargingMode.normal: 'ChargingModeNormal', ChargingMode.eco: 'ChargingModeEco', ChargingMode.ecoWithTargetTime: 'ChargingModeEcoWithTargetTime', }; final chargingInfoMap = { 'evChargerId': evChargerId, }; if (mode != null) chargingInfoMap['chargingMode'] = modeNames[mode]; if (spotMarketEnabled != null) { chargingInfoMap['spotMarketChargingEnabled'] = spotMarketEnabled; } if (dailySpotMarketPercentage != null) { chargingInfoMap['dailySpotMarketPercentage'] = dailySpotMarketPercentage; } if (targetPercentage != null) { chargingInfoMap['targetPercentage'] = targetPercentage; } if (endDateTime != null) chargingInfoMap['endDateTime'] = endDateTime; final response = await _send( 'NymeaEnergy.SetChargingInfo', {'chargingInfo': chargingInfoMap}); return response['params']?['energyError'] ?? 'EnergyErrorNoError'; } /// Récupère les fournisseurs spot market disponibles Future>> getAvailableSpotMarketProviders() async { final response = await _send('NymeaEnergy.GetAvailableSpotMarketProviders', {}); return (response['params']?['providers'] as List? ?? []) .cast>(); } /// Active le spot market avec un fournisseur Future setSpotMarketConfiguration({ required bool enabled, String? providerId, }) async { final params = {'enabled': enabled}; if (providerId != null) params['providerId'] = providerId; final response = await _send('NymeaEnergy.SetSpotMarketConfiguration', params); return response['params']?['energyError'] ?? 'EnergyErrorNoError'; } /// Récupère les scores de prix spot (0=pire, 1=meilleur) Future> getSpotMarketScoreEntries() async { final response = await _send('NymeaEnergy.GetSpotMarketScoreEntries', {}); final entries = response['params']?['spotMarketScoreEntries'] as List? ?? []; return entries.cast>().map(ScoreEntry.fromJson).toList(); } /// Récupère les plannings de charge calculés Future>> getChargingSchedules() async { final response = await _send('NymeaEnergy.GetChargingSchedules', {}); return (response['params']?['chargingSchedules'] as List? ?? []) .cast>(); } // ─── Écoute des Events en temps réel ───────── /// Stream filtré sur un type de notification NymeaEnergy Stream> onNotification(String notificationName) { return notifications.where((msg) => msg['notification'] == 'NymeaEnergy.$notificationName'); } Stream get onChargingInfoChanged { return onNotification('ChargingInfoChanged').map((msg) => ChargingInfo.fromJson( msg['params']?['chargingInfo'] as Map)); } Stream> get onSpotMarketScoreEntriesChanged { return onNotification('SpotMarketScoreEntriesChanged').map((msg) { final entries = msg['params']?['spotMarketScoreEntries'] as List? ?? []; return entries .cast>() .map(ScoreEntry.fromJson) .toList(); }); } Stream>> get onChargingSchedulesChanged { return onNotification('ChargingSchedulesChanged').map((msg) => (msg['params']?['chargingSchedules'] as List? ?? []) .cast>()); } // ─── Fermeture ─────────────────────────────── Future disconnect() async { await _channel?.sink.close(); await _notificationController.close(); } }