417 lines
15 KiB
Dart
417 lines
15 KiB
Dart
// ============================================================
|
||
// 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<String, dynamic> 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<String, dynamic> 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<int, Completer<Map<String, dynamic>>> _pendingRequests = {};
|
||
|
||
// StreamController pour les notifications push
|
||
final StreamController<Map<String, dynamic>> _notificationController =
|
||
StreamController.broadcast();
|
||
|
||
Stream<Map<String, dynamic>> get notifications =>
|
||
_notificationController.stream;
|
||
|
||
NymeaClient({required this.host, this.port = 2222});
|
||
|
||
// ─── Connexion ───────────────────────────────
|
||
|
||
Future<void> 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<void> _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<String, dynamic>;
|
||
|
||
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<Map<String, dynamic>> _send(
|
||
String method, Map<String, dynamic> params) async {
|
||
final id = _requestId++;
|
||
final completer = Completer<Map<String, dynamic>>();
|
||
_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<bool> 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<String?> 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<String, dynamic>?;
|
||
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<bool> 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<void> subscribeToNotifications(List<String> 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<List<Map<String, dynamic>>> getThings() async {
|
||
final response = await _send('Integrations.GetThings', {});
|
||
final things = response['params']?['things'] as List<dynamic>? ?? [];
|
||
return things.cast<Map<String, dynamic>>();
|
||
}
|
||
|
||
/// Filtre les Things qui implémentent une interface donnée
|
||
Future<List<Map<String, dynamic>>> 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<dynamic>? ??
|
||
[];
|
||
|
||
// Construire un Set des classIds qui supportent l'interface
|
||
final matchingClassIds = <String>{};
|
||
for (final tc in classes.cast<Map<String, dynamic>>()) {
|
||
final interfaces = (tc['interfaces'] as List<dynamic>?)?.cast<String>() ?? [];
|
||
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<dynamic> 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<String> executeAction({
|
||
required String thingId,
|
||
required String actionTypeId,
|
||
Map<String, dynamic> 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<int> 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<String> 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<List<ChargingInfo>> getChargingInfos({String? evChargerId}) async {
|
||
final params = <String, dynamic>{};
|
||
if (evChargerId != null) params['evChargerId'] = evChargerId;
|
||
|
||
final response = await _send('NymeaEnergy.GetChargingInfos', params);
|
||
final infos =
|
||
response['params']?['chargingInfos'] as List<dynamic>? ?? [];
|
||
return infos
|
||
.cast<Map<String, dynamic>>()
|
||
.map(ChargingInfo.fromJson)
|
||
.toList();
|
||
}
|
||
|
||
/// Modifie la config de charge d'un chargeur
|
||
Future<String> 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 = <String, dynamic>{
|
||
'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<List<Map<String, dynamic>>> getAvailableSpotMarketProviders() async {
|
||
final response =
|
||
await _send('NymeaEnergy.GetAvailableSpotMarketProviders', {});
|
||
return (response['params']?['providers'] as List<dynamic>? ?? [])
|
||
.cast<Map<String, dynamic>>();
|
||
}
|
||
|
||
/// Active le spot market avec un fournisseur
|
||
Future<String> setSpotMarketConfiguration({
|
||
required bool enabled,
|
||
String? providerId,
|
||
}) async {
|
||
final params = <String, dynamic>{'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<List<ScoreEntry>> getSpotMarketScoreEntries() async {
|
||
final response =
|
||
await _send('NymeaEnergy.GetSpotMarketScoreEntries', {});
|
||
final entries =
|
||
response['params']?['spotMarketScoreEntries'] as List<dynamic>? ?? [];
|
||
return entries.cast<Map<String, dynamic>>().map(ScoreEntry.fromJson).toList();
|
||
}
|
||
|
||
/// Récupère les plannings de charge calculés
|
||
Future<List<Map<String, dynamic>>> getChargingSchedules() async {
|
||
final response = await _send('NymeaEnergy.GetChargingSchedules', {});
|
||
return (response['params']?['chargingSchedules'] as List<dynamic>? ?? [])
|
||
.cast<Map<String, dynamic>>();
|
||
}
|
||
|
||
// ─── Écoute des Events en temps réel ─────────
|
||
|
||
/// Stream filtré sur un type de notification NymeaEnergy
|
||
Stream<Map<String, dynamic>> onNotification(String notificationName) {
|
||
return notifications.where((msg) =>
|
||
msg['notification'] == 'NymeaEnergy.$notificationName');
|
||
}
|
||
|
||
Stream<ChargingInfo> get onChargingInfoChanged {
|
||
return onNotification('ChargingInfoChanged').map((msg) =>
|
||
ChargingInfo.fromJson(
|
||
msg['params']?['chargingInfo'] as Map<String, dynamic>));
|
||
}
|
||
|
||
Stream<List<ScoreEntry>> get onSpotMarketScoreEntriesChanged {
|
||
return onNotification('SpotMarketScoreEntriesChanged').map((msg) {
|
||
final entries = msg['params']?['spotMarketScoreEntries']
|
||
as List<dynamic>? ??
|
||
[];
|
||
return entries
|
||
.cast<Map<String, dynamic>>()
|
||
.map(ScoreEntry.fromJson)
|
||
.toList();
|
||
});
|
||
}
|
||
|
||
Stream<List<Map<String, dynamic>>> get onChargingSchedulesChanged {
|
||
return onNotification('ChargingSchedulesChanged').map((msg) =>
|
||
(msg['params']?['chargingSchedules'] as List<dynamic>? ?? [])
|
||
.cast<Map<String, dynamic>>());
|
||
}
|
||
|
||
// ─── Fermeture ───────────────────────────────
|
||
|
||
Future<void> disconnect() async {
|
||
await _channel?.sink.close();
|
||
await _notificationController.close();
|
||
}
|
||
}
|