etm-powersync-app/lib/services/nymea_service.dart
Patrick Schurig 3821bf3fdb refactor(energy): retire le calcul de ratios app-side, isole l'interim derrière EnergyRatiosInterim
- supprime selfRate/autoRate de _parsePowerBalance, le getter autoconsommationW, les .abs() interim
- ratios dérivés par Δ-de-cumuls (§2.1/§8.1) dans lib/services/energy_ratios.dart
- reseed jour-roulant & Δ non-monotone ; gardes : dén≤0→null, pas de NaN, clamp [0,100]
- selfConsumptionPower net-signé sans .abs() (§2.2) ; signes nymea bruts préservés (§7.1)
- seam swappable vers un state plugin en Phase 2 (une seule fonction)

Interim (Phase 1). Gestion du signe d'affichage renvoyée à la Phase 4 (§1.1).
Réf. UI_DATA_CONTRACT.md rev.5.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 11:51:50 +02:00

1549 lines
61 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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';
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<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 = '';
// ── 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<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;
// ── 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<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.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<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';
// ── 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';
_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<void> _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<bool> 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<bool> 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<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) {
// 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<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;
// Réinitialiser identité & auth — ne pas afficher une box déconnectée.
_token = '';
_serverUuid = '';
_serverName = '';
_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<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();
// 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,
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<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),
// 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'),
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();
}
// ═══════════════════════════════════════════════════════════════════════════
// CONFIG ÉNERGIE — rôles & charges pilotées (écran Rôles & appareils)
// ═══════════════════════════════════════════════════════════════════════════
/// gridMeter → **réel** `Energy.SetRootMeter` (natif, testable contre `hems`).
Future<void> 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);
}
}
/// **STUB** — `NymeaEnergy.SetLoadConfig` n'existe pas encore côté plugin
/// (l'energymanager l'exposera plus tard). En attendant on logge **le JSON
/// exact qui partira**, sans rien casser ni bloquer.
Future<void> setLoadConfig(List<Map<String, dynamic>> descriptors) async {
_log('[LoadConfig STUB] NymeaEnergy.SetLoadConfig — '
'${descriptors.length} charge(s) etmvariableload', force: true);
for (final d in descriptors) {
final pretty = const JsonEncoder.withIndent(' ').convert(d);
_log('[LoadConfig STUB] LoadDescriptor:\n$pretty', force: true);
}
}
/// **STUB** — `NymeaEnergy.GetLoadConfig` absent côté plugin. Retourne vide,
/// ne bloque pas le démarrage.
Future<List<Map<String, dynamic>>> getLoadConfig() async {
_log('[LoadConfig STUB] NymeaEnergy.GetLoadConfig → [] '
'(méthode plugin absente)', force: true);
return const [];
}
/// Trace de routage (charges hors etmvariableload : PAC SG-Ready, batterie).
void logInfo(String msg) => _log(msg, force: true);
// ═══════════════════════════════════════════════════════════════════════════
// 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();
}
}