NymeaService
- batterySOCSource : retourne {'thingId': 'sim-battery', 'stateName': 'soc'}
en simulation au lieu de null — le SOC est désormais fetchable
- fetchHistory en simulation : données SOC réalistes en % (0-100) avec
courbe charge solaire 8h-15h (20→85%) puis décharge soir (85→20%)
au lieu de valeurs sinus 100-900 W incorrectes
EnergyScreen
- initState : appelle startSimulation() si non connecté (comme les autres écrans)
- leftTitles : interval explicite (yMax/4) + SideTitleWidget pour ancrage correct
- gridData : horizontalInterval aligné sur yInterval
- rightTitles (SOC %) : SideTitleWidget + interval aligné
- bottomTitles : SideTitleWidget sur les deux graphes
- barChart leftTitles : interval + SideTitleWidget
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1385 lines
53 KiB
Dart
1385 lines
53 KiB
Dart
import 'dart:async';
|
|
import 'dart:developer' as dev;
|
|
import 'dart:convert';
|
|
import 'dart:io';
|
|
import 'dart:math';
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:shared_preferences/shared_preferences.dart';
|
|
import 'package:web_socket_channel/web_socket_channel.dart';
|
|
import '../models/energy_data.dart'; // EnergyData, HistoryPoint, PowerBalanceEntry
|
|
import '../models/nymea_models.dart';
|
|
|
|
// ── 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.tcpRaw;
|
|
|
|
int _requestId = 1;
|
|
final Map<int, Completer<Map<String, dynamic>>> _pendingRequests = {};
|
|
final StringBuffer _buffer = StringBuffer(); // TCP fragment buffer
|
|
|
|
// ── State ────────────────────────────────────────────────────────────────────
|
|
bool _connected = false;
|
|
bool _connecting = false;
|
|
String _host = '192.168.1.106';
|
|
int _port = 2222;
|
|
String? _connectionError;
|
|
String? _nymeaVersion;
|
|
Timer? _heartbeatTimer;
|
|
Timer? _simulationTimer;
|
|
bool _isSimulation = false;
|
|
|
|
// ── Auth ─────────────────────────────────────────────────────────────────────
|
|
String _token = '';
|
|
String _username = '';
|
|
String _password = '';
|
|
|
|
// ── 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();
|
|
List<HistoryPoint> _historyPoints = [];
|
|
List<NymeaThing> _things = [];
|
|
List<NymeaThingClass> _thingClasses = [];
|
|
List<FavoriteWidget> _favoriteWidgets = [];
|
|
|
|
static const _favKey = 'etm_favorites_v1';
|
|
|
|
NymeaService() {
|
|
_loadFavorites();
|
|
}
|
|
|
|
Future<void> _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<String, dynamic>))
|
|
.toList();
|
|
notifyListeners();
|
|
} catch (_) {
|
|
// Données corrompues — on repart de zéro
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<void> _saveFavorites() async {
|
|
final prefs = await SharedPreferences.getInstance();
|
|
await prefs.setString(
|
|
_favKey,
|
|
jsonEncode(_favoriteWidgets.map((f) => f.toJson()).toList()),
|
|
);
|
|
}
|
|
|
|
// ── Getters ──────────────────────────────────────────────────────────────────
|
|
bool get connected => _connected;
|
|
bool get isConnected => _connected; // alias pour les screens
|
|
bool get connecting => _connecting;
|
|
bool get isSimulation => _isSimulation;
|
|
NymeaProtocol get protocol => _protocol;
|
|
String get host => _host;
|
|
int get port => _port;
|
|
String? get connectionError => _connectionError;
|
|
String? get nymeaVersion => _nymeaVersion;
|
|
String get username => _username;
|
|
String get password => _password;
|
|
EnergyData get energyData => _energyData;
|
|
List<HistoryPoint> get historyPoints => _historyPoints;
|
|
List<NymeaThing> get things => _things;
|
|
List<NymeaThingClass> get thingClasses => _thingClasses;
|
|
List<FavoriteWidget> 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<String, String>? 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 <String>[]; } }).toList()})', force: true);
|
|
return null;
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// CONNEXION — entrée publique
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
Future<bool> connect(
|
|
String host,
|
|
int port, {
|
|
NymeaProtocol protocol = NymeaProtocol.tcpRaw,
|
|
String username = '',
|
|
String password = '',
|
|
}) 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();
|
|
// Charger le token sauvegardé pour cet hôte
|
|
final prefs = await SharedPreferences.getInstance();
|
|
_token = prefs.getString('nymea_token_$host') ?? '';
|
|
if (_token.isNotEmpty) {
|
|
_log('🔑 Token restauré depuis SharedPreferences', 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<bool> _connectTcp(String host, int port) async {
|
|
_log('TCP → $host:$port');
|
|
_socket = await Socket.connect(host, port,
|
|
timeout: const Duration(seconds: 8));
|
|
|
|
final welcomeCompleter = Completer<Map<String, dynamic>>();
|
|
bool welcomeReceived = false;
|
|
|
|
_socket!.cast<List<int>>().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<String, dynamic>;
|
|
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<bool> _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<int>) {
|
|
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<String, dynamic>;
|
|
_processMessage(data);
|
|
} catch (e) {
|
|
_log('❌ WS parse error: $e raw type: ${raw.runtimeType}', force: true);
|
|
}
|
|
},
|
|
onError: (e) {
|
|
_log('❌ WS stream error: $e', force: true);
|
|
_onError(e);
|
|
},
|
|
onDone: _onDone,
|
|
cancelOnError: false,
|
|
);
|
|
|
|
_log('📡 WS listener actif, envoi Hello...', force: true);
|
|
|
|
// Sur WebSocket, c'est le CLIENT qui envoie Hello en premier
|
|
final hello = await _sendRequest('JSONRPC.Hello', {})
|
|
.timeout(const Duration(seconds: 8));
|
|
|
|
return _finishConnect(hello);
|
|
}
|
|
|
|
// ── Finalisation commune ─────────────────────────────────────────────────────
|
|
Future<bool> _finishConnect(Map<String, dynamic> welcome) async {
|
|
final p = welcome['params'] as Map<String, dynamic>? ?? welcome;
|
|
_nymeaVersion = p['version'] as String?
|
|
?? p['serverVersion'] as String?
|
|
?? 'nymea';
|
|
|
|
_log('✅ Connected to nymea $_nymeaVersion'
|
|
' via ${_protocol == NymeaProtocol.tcpRaw ? "TCP" : "WebSocket"}');
|
|
|
|
// Flux auth : si requis ET pas encore authentifié avec token valide
|
|
if (p['authenticationRequired'] == true && p['authenticated'] != true) {
|
|
if (_token.isNotEmpty) {
|
|
_log('🔑 Token existant présent, tentative d\'utilisation...', force: true);
|
|
// Le token sera inclus dans les requêtes — on verra si ça marche
|
|
} else if (_username.isNotEmpty) {
|
|
try {
|
|
await _doAuthenticate();
|
|
} catch (e) {
|
|
_connectionError = e.toString();
|
|
_connected = false;
|
|
_connecting = false;
|
|
notifyListeners();
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
|
|
await _setNotificationsEnabled();
|
|
|
|
_connected = true;
|
|
_connecting = false;
|
|
notifyListeners();
|
|
|
|
await _initialLoad();
|
|
_startHeartbeat();
|
|
return true;
|
|
}
|
|
|
|
Future<void> _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) {
|
|
final prefs = await SharedPreferences.getInstance();
|
|
await prefs.setString('nymea_token_$_host', _token);
|
|
_log('✅ Token obtenu et sauvegardé', force: true);
|
|
} else {
|
|
_log('❌ Authenticate échoué: ${r["params"]}', force: true);
|
|
throw Exception('Authentication failed: ${r["params"]}');
|
|
}
|
|
}
|
|
|
|
Future<void> _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;
|
|
_buffer.clear();
|
|
for (final c in _pendingRequests.values) {
|
|
c.completeError(Exception('Disconnected'));
|
|
}
|
|
_pendingRequests.clear();
|
|
notifyListeners();
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// JSON-RPC SEND — \n pour TCP, frame WebSocket pour WS
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
Future<Map<String, dynamic>> _sendRequest(
|
|
String method, Map<String, dynamic> params) async {
|
|
if (_socket == null && _wsChannel == null) {
|
|
throw Exception('Not connected');
|
|
}
|
|
final id = _requestId++;
|
|
final completer = Completer<Map<String, dynamic>>();
|
|
_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<String, dynamic> 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<String, dynamic>? ?? {},
|
|
);
|
|
}
|
|
}
|
|
|
|
void _handleNotification(
|
|
String notification, Map<String, dynamic> 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<String, dynamic>));
|
|
notifyListeners();
|
|
}
|
|
break;
|
|
case 'Integrations.ThingRemoved':
|
|
_things.removeWhere((t) => t.id == params['thingId']);
|
|
notifyListeners();
|
|
break;
|
|
}
|
|
}
|
|
|
|
void _handleStateChanged(Map<String, dynamic> 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<NymeaStateValue>.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<void> _initialLoad() async {
|
|
await Future.wait([
|
|
_loadThings(),
|
|
_loadEnergyStatus(),
|
|
_loadEnergyHistory(),
|
|
]);
|
|
}
|
|
|
|
void _startHeartbeat() {
|
|
_heartbeatTimer?.cancel();
|
|
// Polling toutes les 5 secondes pour rafraîchir les données énergie
|
|
_heartbeatTimer = Timer.periodic(const Duration(seconds: 5), (_) async {
|
|
if (!_connected || _isSimulation) return;
|
|
try {
|
|
await _loadEnergyStatus();
|
|
} catch (_) {
|
|
_connected = false;
|
|
notifyListeners();
|
|
}
|
|
});
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// ENERGY MANAGER
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
Future<void> _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<String, dynamic> response) {
|
|
final p = response['params'] as Map<String, dynamic>? ?? response;
|
|
_log('📊 Energy fields: ${p.keys.toList()}');
|
|
|
|
double? n(String k) => (p[k] as num?)?.toDouble();
|
|
|
|
// Production PV est négative dans nymea (convention producteur = négatif)
|
|
final rawProd = n('currentPowerProduction');
|
|
final pv = rawProd != null ? rawProd.abs() : 0.0;
|
|
|
|
final home = n('currentPowerConsumption') ?? 0.0;
|
|
final grid = n('currentPowerAcquisition') ?? 0.0; // + import, 0 si export
|
|
final bat = n('currentPowerStorage') ?? 0.0;
|
|
|
|
// Totaux cumulés en kWh → convertir en Wh pour l'affichage
|
|
final totalProdKwh = n('totalProduction') ?? 0.0;
|
|
final totalRetKwh = n('totalReturn') ?? 0.0;
|
|
final totalConsoKwh = n('totalConsumption') ?? 0.0;
|
|
final totalAcqKwh = n('totalAcquisition') ?? 0.0;
|
|
|
|
// Autoconsommation = production - injection réseau
|
|
final selfConsoKwh = totalProdKwh - totalRetKwh;
|
|
// Taux autoconsommation = autoconso / production * 100
|
|
final selfRate = totalProdKwh > 0 ? (selfConsoKwh / totalProdKwh * 100).clamp(0.0, 100.0) : 0.0;
|
|
// Taux autonomie = (production + batterie utilisée) / conso * 100
|
|
final autoRate = totalConsoKwh > 0 ? ((totalConsoKwh - totalAcqKwh) / totalConsoKwh * 100).clamp(0.0, 100.0) : 0.0;
|
|
|
|
_energyData = _energyData.copyWith(
|
|
pvPower: pv,
|
|
homePower: home,
|
|
gridPower: grid,
|
|
batteryPower: bat,
|
|
dayProductionWh: totalProdKwh * 1000,
|
|
dayGridInjectionWh: totalRetKwh * 1000,
|
|
daySelfConsumptionWh: selfConsoKwh * 1000,
|
|
selfConsumptionRate: selfRate,
|
|
autonomyRate: autoRate,
|
|
dayGains: totalRetKwh * 0.13 + selfConsoKwh * 0.22, // estimation tarifaire
|
|
);
|
|
notifyListeners();
|
|
}
|
|
|
|
void _parseRootMeter(Map<String, dynamic> response) {
|
|
// Dans nymea 1.14, tout est dans PowerBalance — méthode conservée pour compat
|
|
}
|
|
|
|
Future<void> refreshEnergy() => _loadEnergyStatus();
|
|
|
|
/// Récupère les logs de bilan énergétique via Energy.GetPowerBalanceLogs.
|
|
///
|
|
/// [sampleRate] : "SampleRate15Mins" | "SampleRate1Hour" |
|
|
/// "SampleRate3Hours" | "SampleRate1Day" | "SampleRate1Week"
|
|
/// Timestamps envoyés en **secondes** (convention nymea Energy API).
|
|
Future<List<PowerBalanceEntry>> 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<PowerBalanceEntry>((e) {
|
|
final m = e as Map<String, dynamic>;
|
|
double d(String k) => (m[k] as num?)?.toDouble() ?? 0.0;
|
|
return PowerBalanceEntry(
|
|
timestamp: DateTime.fromMillisecondsSinceEpoch(
|
|
(m['timestamp'] as num).toInt() * 1000),
|
|
// production est négative dans nymea → valeur absolue
|
|
productionW: d('production').abs(),
|
|
consumptionW: d('consumption'),
|
|
acquisitionW: d('acquisition'),
|
|
storageW: d('storage'),
|
|
totalProductionWh: d('totalProduction'),
|
|
totalConsumptionWh:d('totalConsumption'),
|
|
totalReturnWh: d('totalReturn'),
|
|
totalAcquisitionWh:d('totalAcquisition'),
|
|
);
|
|
}).toList();
|
|
} catch (e) {
|
|
_log('fetchPowerBalanceLogs: $e');
|
|
return [];
|
|
}
|
|
}
|
|
|
|
List<PowerBalanceEntry> _simulatePowerBalance(
|
|
DateTime from, DateTime to, String sampleRate) {
|
|
final entries = <PowerBalanceEntry>[];
|
|
const steps = 48;
|
|
final stepDur = to.difference(from) ~/ steps;
|
|
final stepHours = stepDur.inMinutes / 60.0;
|
|
double totalProd = 50000, totalConso = 80000,
|
|
totalRet = 20000, totalAcq = 50000;
|
|
|
|
for (int i = 0; i <= steps; i++) {
|
|
final t = from.add(stepDur * i);
|
|
// Courbe solaire en demi-sinus sur la journée (pic à midi)
|
|
final hFrac = (t.hour + t.minute / 60.0) / 24.0;
|
|
final prod = (2200 * sin(hFrac * pi)).clamp(0.0, 2200).toDouble();
|
|
final conso = 600 + 400 * sin(i * pi / 10 + 1.0);
|
|
final excess = prod - conso;
|
|
final storage = excess.clamp(-300.0, 300.0).toDouble();
|
|
final acq = (conso - prod - storage).clamp(0.0, double.infinity).toDouble();
|
|
final ret = (excess - storage).clamp(0.0, double.infinity).toDouble();
|
|
totalProd += prod * stepHours;
|
|
totalConso += conso * stepHours;
|
|
totalRet += ret * stepHours;
|
|
totalAcq += acq * stepHours;
|
|
entries.add(PowerBalanceEntry(
|
|
timestamp: t,
|
|
productionW: prod,
|
|
consumptionW: conso,
|
|
acquisitionW: acq,
|
|
storageW: storage,
|
|
totalProductionWh: totalProd,
|
|
totalConsumptionWh: totalConso,
|
|
totalReturnWh: totalRet,
|
|
totalAcquisitionWh: totalAcq,
|
|
));
|
|
}
|
|
return entries;
|
|
}
|
|
|
|
/// Rafraîchit things + classes + énergie
|
|
Future<void> refresh() async {
|
|
await Future.wait([_loadThings(), _loadEnergyStatus()]);
|
|
}
|
|
|
|
Future<void> _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<String, dynamic>;
|
|
return HistoryPoint(
|
|
time: DateTime.fromMillisecondsSinceEpoch(
|
|
(lm['timestamp'] as int) * 1000),
|
|
pvWh: (lm['totalEnergyProduced'] as num?)?.toDouble() ?? 0,
|
|
homeWh: (lm['totalEnergyConsumed'] as num?)?.toDouble() ?? 0,
|
|
gridWh: (lm['totalEnergyReturned'] as num?)?.toDouble() ?? 0,
|
|
);
|
|
}).toList();
|
|
notifyListeners();
|
|
} catch (e) { _log('GetEnergyLogs: $e'); }
|
|
}
|
|
|
|
/// Returns the thingId of the first configured EV charger, or null if none.
|
|
String? _findEvChargerId() {
|
|
for (final thing in _things) {
|
|
NymeaThingClass? cls;
|
|
try {
|
|
cls = _thingClasses.firstWhere((c) => c.id == thing.thingClassId);
|
|
} catch (_) {
|
|
continue;
|
|
}
|
|
if (cls.interfaces.any((i) => i.toLowerCase() == 'evcharger')) {
|
|
return thing.id;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/// Send EnergyPlugin.SetChargingInfo with full mode + optional deadline params.
|
|
///
|
|
/// [mode] : UI mode (pv → Eco, minPv → EcoWithMinCurrent, boost → Normal)
|
|
/// [deadline] : activate deadline variant (*WithTargetTime) — ignored for boost
|
|
/// [targetSoc] : target battery SOC % (1-100), required when deadline=true
|
|
/// [endTime] : desired arrival/completion time, required when deadline=true
|
|
Future<void> setChargingInfo({
|
|
required ChargingMode mode,
|
|
bool deadline = false,
|
|
int targetSoc = 80,
|
|
DateTime? endTime,
|
|
}) async {
|
|
// Map (mode, deadline) → API mode string + optional minCurrent
|
|
final String apiMode;
|
|
int? minCurrent;
|
|
|
|
if (mode == ChargingMode.boost) {
|
|
apiMode = 'Normal';
|
|
} else if (mode == ChargingMode.pv && !deadline) {
|
|
apiMode = 'Eco';
|
|
} else if (mode == ChargingMode.minPv && !deadline) {
|
|
apiMode = 'EcoWithMinCurrent';
|
|
minCurrent = 6;
|
|
} else if (mode == ChargingMode.pv && deadline) {
|
|
apiMode = 'EcoWithTargetTime';
|
|
} else {
|
|
// minPv + deadline
|
|
apiMode = 'EcoMinWithTargetTime';
|
|
minCurrent = 6;
|
|
}
|
|
|
|
if (_connected && !_isSimulation) {
|
|
final evChargerId = _findEvChargerId();
|
|
if (evChargerId != null) {
|
|
try {
|
|
final info = <String, dynamic>{
|
|
'evChargerId': evChargerId,
|
|
'mode': apiMode,
|
|
};
|
|
if (minCurrent != null) info['minCurrent'] = minCurrent;
|
|
if (deadline && mode != ChargingMode.boost) {
|
|
info['targetSoc'] = targetSoc;
|
|
info['endTime'] = endTime != null
|
|
? endTime.millisecondsSinceEpoch ~/ 1000
|
|
: null;
|
|
}
|
|
await _sendRequest('EnergyPlugin.SetChargingInfo', {'chargingInfo': info});
|
|
} catch (e) { _log('SetChargingInfo: $e'); }
|
|
} else {
|
|
_log('SetChargingInfo: no EV charger thing found');
|
|
}
|
|
}
|
|
_energyData = _energyData.copyWith(chargingMode: mode);
|
|
notifyListeners();
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// THINGS / INTEGRATIONS
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
bool _thingsLoaded = false;
|
|
bool get thingsLoaded => _thingsLoaded;
|
|
|
|
Future<void> _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<String, dynamic>))
|
|
.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<String, dynamic>))
|
|
.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<void> _loadAllThingClassesFiltered(List<String> 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<String, dynamic>))
|
|
.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<void> refreshThings() => _loadThings();
|
|
|
|
Future<List<Map<String, dynamic>>> discoverThings(String thingClassId) async {
|
|
if (_isSimulation) return [];
|
|
try {
|
|
final r = await _sendRequest(
|
|
'Integrations.DiscoverThings', {'thingClassId': thingClassId});
|
|
return List<Map<String, dynamic>>.from(
|
|
r['params']?['thingDescriptors'] ?? []);
|
|
} catch (_) { return []; }
|
|
}
|
|
|
|
Future<bool> 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<bool> 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<bool> 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<bool> 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<NymeaActionResult> executeAction({
|
|
required String thingId,
|
|
required String actionTypeId,
|
|
Map<String, dynamic> 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<bool> 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<NymeaStateValue>.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<List<HistoryEntry>> 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 = <HistoryEntry>[];
|
|
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<HistoryEntry>((e) {
|
|
final ts = DateTime.fromMillisecondsSinceEpoch(
|
|
(e['timestamp'] as num).toInt());
|
|
final values = (e['values'] as Map?)?.cast<String, dynamic>() ?? {};
|
|
// 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<num>();
|
|
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<dynamic> 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<List<Map<String, dynamic>>> getRules() async {
|
|
if (_isSimulation) return [];
|
|
try {
|
|
final r = await _sendRequest('Rules.GetRules', {});
|
|
return List<Map<String, dynamic>>.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();
|
|
}
|
|
} |