powersync-energy-plugin-etm/integration_app_examples/nymea_client.dart

417 lines
15 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

// ============================================================
// nymea_client.dart
// Client JSON-RPC WebSocket pour nymea — etm_powersync_app
//
// Protocole : JSON-RPC 2.0 sur WebSocket (port 2222)
// Namespace plugin : "NymeaEnergy" (v08)
// ============================================================
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();
}
}