etm-powersync-app/lib/services/nymea_service.dart
Patrick Schurig ETM-Schurig dd798ae85e fix: graphe énergie — double axe kW/SOC% visible + simulation SOC réaliste
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>
2026-05-29 22:33:18 +02:00

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();
}
}