etm-powersync-app/lib/services/nymea_service.dart
Patrick Schurig 38475e0007 fix(dashboard+thème): compteurs réseau honnêtes, et le sélecteur clair/sombre s'applique partout
1) « Vers réseau » — TROIS défauts, dont un seul était visible.

   La valeur aberrante (9,10184409403335e+33 kWh) ne vient PAS de l'app : Energy.GetPowerBalance
   la publie telle quelle sur .75. Trois des quatre compteurs cumulés sont corrompus
   (totalReturn 9,1e+33, totalAcquisition 7,5e+33, totalConsumption -1,6e+33) alors que
   totalProduction reste sain, et que les états du Thing compteur sont tous plausibles
   (1429 kWh importés, 88 kWh exportés). C'est donc l'accumulation de l'experience-plugin
   énergie qui déraille, côté box — signalé, pas corrigé ici.

   Les deux autres défauts étaient bien dans l'app :
   - « Depuis réseau » lisait l'OPPOSÉ du compteur d'export au lieu de totalAcquisition,
     qui a son propre compteur. Dès que l'export était positif, la tuile affichait
     0,0 kWh en permanence, quelle que soit l'électricité réellement achetée ;
   - les deux tuiles étaient libellées « aujourd'hui » alors que ces compteurs sont
     CUMULÉS depuis l'origine, jamais remis à zéro.

   Corrigé : EnergyData porte gridExportKwh / gridImportKwh, transportés tels quels — l'app
   ne maquille pas une donnée que la box publie fausse. Un plafond de plausibilité
   (10 GWh, ~3000 ans de consommation) distingue « mesure » de « compteur cassé » : au-delà
   la tuile affiche « — · compteur box invalide ». Imprimer le nombre le présenterait comme
   une mesure ; l'effacer laisserait croire à zéro.

2) Thème : 126 sites basculés des constantes claires vers les variantes contextuelles
   (EtmTokens.bg → bgOf(context), card → surfaceOf, ink → inkOf, muted → mutedOf,
   faint → faintOf, line → lineOf) dans ac_screen, things_screen, energy_screen,
   favorites_screen, main_drawer, ev_charging_card, energy_flow_widget. Ces écrans
   restaient en palette claire quel que soit le sélecteur.

   9 sites RESTENT en constante, et c'est délibéré : ce sont des tables statiques et des
   helpers de haut niveau (categoryInfoMap, _options des favoris, _modeColor, peintres du
   flux) où la couleur est une identité d'icône et où aucun BuildContext n'est en portée.
   Les faire suivre le thème demande de threader le contexte jusqu'à ces tables — un
   refactor à part entière, pas un correctif de fin de soirée.

Tests 80/80, analyze 0 erreur.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HajXLUczEyZd22JeewRfff
2026-08-25 23:13:20 +02:00

2064 lines
84 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/modbus_models.dart'; // ModbusRtuMaster, SerialPort
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;
/// Dernière charge utile de `JSONRPC.Hello`. Porte `experiences[]` et `version`.
Map<String, dynamic>? _helloPayload;
Timer? _heartbeatTimer;
Timer? _simulationTimer;
bool _isSimulation = false;
// ── Auth ─────────────────────────────────────────────────────────────────────
String _token = '';
String _username = '';
String _password = '';
// ── Locale de connexion (JSONRPC.Hello `o:locale`) ───────────────────────────
// nymea gère la locale PAR CONNEXION (jsonrpcserverimplementation.cpp:239-241,
// QHash<QUuid, QLocale> m_clientLocales) : deux clients de la même box peuvent
// avoir des langues différentes. Sans ce paramètre, la box retombe sur son
// défaut persisté (`en_US`), quelle que soit la langue de l'app.
//
// ⚠️ Ne RIEN attendre du vocabulaire matériel : mesuré sur .75, sur 784 chaînes
// affichables, l'allemand en change une et le français zéro (aucun catalogue .qm
// fr n'est livré par les plugins installés). Les `displayName` de ThingClass,
// états et actions sont à traiter comme des identifiants anglais.
String _requestedLocale = 'fr_FR';
String _serverLocale = ''; // locale réellement retenue par la box
/// Locale demandée à la box au prochain `Hello` (format `fr_FR`).
String get requestedLocale => _requestedLocale;
set requestedLocale(String v) {
if (v.isEmpty || v == _requestedLocale) return;
_requestedLocale = v;
_log('🌐 Locale demandée changée → $v (effective au prochain Hello)',
force: true);
}
/// Locale confirmée par la box au dernier `Hello` — vide si jamais connecté.
/// Diverge de [requestedLocale] si la box n'a pas su interpréter la valeur
/// (ex. `zz_ZZ` → `C`).
String get serverLocale => _serverLocale;
// ── 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;
/// Charge utile de `JSONRPC.Hello`, pour les versions d'API d'expérience.
Map<String, dynamic>? get helloPayload => _helloPayload;
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.
// La locale part à CHAQUE Hello, donc aussi après une reconnexion subie :
// `m_clientLocales` est indexé par clientId et repart au défaut de la box
// à chaque nouvelle socket (jsonrpcserverimplementation.cpp:1085-1086).
final hello = await _sendRequest(
'JSONRPC.Hello',
{'locale': _requestedLocale},
).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';
// Charge utile de Hello conservée telle quelle : elle porte `experiences[]`, seul
// endroit du fil où se lisent les versions d'API (`NymeaEnergy 0.8`). La version du
// PAQUET du moteur (`1.15.2+etm15`) n'y est pas — voir [introspect].
_helloPayload = Map<String, dynamic>.from(p);
// ── 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';
// Locale retenue par la box. Journalisée systématiquement (force) : un .qm
// introuvable côté box produit EXACTEMENT le même rendu qu'une absence de
// traduction. Sans cette trace, un catalogue mal packagé passe inaperçu.
_serverLocale = (p['locale'] as String?) ?? (p['language'] as String?) ?? '';
if (_serverLocale == _requestedLocale) {
_log('🌐 Locale : demandée "$_requestedLocale" → retenue "$_serverLocale"',
force: true);
} else {
_log('🌐 ⚠️ Locale : demandée "$_requestedLocale" mais la box a retenu '
'"$_serverLocale" — valeur non interprétée, ou Hello sans paramètre.',
force: true);
}
_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 = '';
// La locale DEMANDÉE est une préférence d'app : elle survit à la déconnexion.
// Seule la locale CONFIRMÉE est effacée — elle appartient à la connexion morte.
_serverLocale = '';
_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;
case 'NymeaEnergy.LoadTelemetryChanged':
// Émise à chaque changement SIGNIFICATIF **et** toutes les 60 s même sans
// changement (battement de cœur). C'est ce battement qui rend un arbitre figé
// détectable : les trames continuent d'arriver tandis que leur `timestamp`
// cesse d'avancer. Sans lui, « installation stable » et « moteur arrêté »
// seraient le même silence.
_onLoadTelemetryChanged(params);
break;
case 'NymeaEnergy.LoadConfigChanged':
// Émise après TOUTE persistance réussie (SetLoadConfig ou relecture du
// fichier édité hors app). Porte la liste complète à jour : c'est LA
// confirmation qu'une écriture a réellement été appliquée — l'acquittement
// du RPC, lui, ne vaut pas application.
final raw = (params['loadConfigs'] as List?) ?? const [];
_onLoadConfigChanged(raw
.whereType<Map>()
.map((m) => Map<String, dynamic>.from(m))
.toList());
break;
}
}
/// Abonnés à `NymeaEnergy.LoadConfigChanged` (l'écran de configuration des charges).
final List<void Function(List<Map<String, dynamic>>)> _loadConfigListeners = [];
/// S'abonne aux changements de configuration des charges poussés par la box.
/// Renvoie la fonction de désabonnement.
void Function() onLoadConfigChanged(
void Function(List<Map<String, dynamic>>) cb) {
_loadConfigListeners.add(cb);
return () => _loadConfigListeners.remove(cb);
}
void _onLoadConfigChanged(List<Map<String, dynamic>> configs) {
_log('[LoadConfig] ← LoadConfigChanged : ${configs.length} charge(s)',
force: true);
for (final cb in [..._loadConfigListeners]) {
cb(configs);
}
}
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,
// Compteurs CUMULÉS, transportés tels quels : ni corrigés, ni recalculés. Leur
// plausibilité se juge à l'affichage (EnergyData.isPlausibleKwh) — l'app ne
// maquille pas une donnée que la box publie fausse, elle refuse de la présenter
// comme une mesure.
gridExportKwh: totalRetKwh,
gridImportKwh: totalAcqKwh,
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;
}
/// `NymeaEnergy.SetChargingInfo` — mode de recharge du véhicule.
///
/// ## Ce bloc envoyait une requête que la box REJETTE
///
/// Corrigé le 2026-08-25 contre `JSONRPC.Introspect` puis vérifié par un appel réel
/// sur `.75` (plugin `1.15.2+etm15`). Trois erreurs se cumulaient, et chacune suffisait
/// à faire échouer l'appel :
///
/// - le **namespace** `EnergyPlugin.*` n'existe pas — il n'y a que `NymeaEnergy.*` ;
/// - le champ s'appelle `chargingMode`, pas `mode`, et porte l'énumération **complète**
/// (`ChargingModeEco`), pas son suffixe (`Eco`) ;
/// - `targetSoc` et `endTime` s'appellent `targetPercentage` et `endDateTime`.
///
/// Réponse mesurée de la box sur l'ancienne charge utile :
/// `Invalid params: Missing required key: chargingMode`. Les boutons de mode de
/// recharge étaient donc **inopérants** contre cette version.
///
/// ## `minCurrent` n'existe pas dans ce contrat
///
/// L'ancien code envoyait `minCurrent: 6`. Le schéma de `ChargingInfo` ne porte
/// **aucun** champ de courant : la box publie bien les modes
/// `ChargingModeEcoWithMinCurrent` et `ChargingModeEcoMinWithTargetTime`, mais la
/// valeur du courant minimal ne se règle pas ici. Le mode se choisit, son paramètre
/// non — et l'écran doit le dire au lieu d'offrir un réglage sans effet.
///
/// \return `null` si la box a accepté, sinon de quoi l'annoncer. **Ne rien inventer**
/// sur la cause : le détail reste au journal de la box.
Future<String?> setChargingInfo({
required ChargingMode mode,
bool deadline = false,
int targetSoc = 80,
DateTime? endTime,
}) async {
// (mode d'UI, échéance) → énumération publiée par la box.
final String apiMode;
if (mode == ChargingMode.boost) {
apiMode = 'ChargingModeNormal';
} else if (mode == ChargingMode.pv && !deadline) {
apiMode = 'ChargingModeEco';
} else if (mode == ChargingMode.minPv && !deadline) {
apiMode = 'ChargingModeEcoWithMinCurrent';
} else if (mode == ChargingMode.pv && deadline) {
apiMode = 'ChargingModeEcoWithTargetTime';
} else {
apiMode = 'ChargingModeEcoMinWithTargetTime';
}
String? error;
if (_connected && !_isSimulation) {
final evChargerId = _findEvChargerId();
if (evChargerId == null) {
_log('[Recharge] SetChargingInfo: aucune borne trouvée', force: true);
error = 'NoEvCharger';
} else {
try {
final info = <String, dynamic>{
'evChargerId': evChargerId,
'chargingMode': apiMode,
};
// Rien qui ne soit au schéma ne part : `minCurrent` n'existe pas, et une clé
// inconnue ferait rejeter TOUT l'appel (nymea valide avant le handler).
if (deadline && mode != ChargingMode.boost) {
info['targetPercentage'] = targetSoc;
if (endTime != null) {
info['endDateTime'] = endTime.millisecondsSinceEpoch ~/ 1000;
}
}
final r = await _sendRequest(
'NymeaEnergy.SetChargingInfo', {'chargingInfo': info});
final err = r['params']?['energyError'] as String?;
if (r['status'] != 'success') {
error = (r['error'] as String?) ?? r['status'] as String?;
} else if (err != null && err != 'EnergyErrorNoError') {
error = err;
}
_log('[Recharge] SetChargingInfo($apiMode) ← ${error ?? 'OK'}', force: true);
} catch (e) {
_log('[Recharge] SetChargingInfo: $e', force: true);
error = e.toString();
}
}
}
// L'état local ne suit que si la box a accepté — sinon l'UI montrerait un mode
// que la borne n'a jamais reçu.
if (error == null) {
_energyData = _energyData.copyWith(chargingMode: mode);
notifyListeners();
}
return error;
}
// ═══════════════════════════════════════════════════════════════════════════
// 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);
}
}
/// `NymeaEnergy.SetLoadConfig` — **réel**, vérifié contre `.75` le 2026-08-25.
///
/// Remplace l'ENSEMBLE des charges : rejet en bloc (`energyError`) si une seule
/// entrée est invalide ; persistance et application atomiques.
///
/// [payloads] doit provenir de `LoadConfigEntry.patched()` — c'est-à-dire des maps
/// reçues de [getLoadConfig] avec les seuls champs modifiés réécrits. **Ne jamais
/// passer une re-sérialisation d'un modèle typé** : le plugin sérialise via le
/// méta-objet, donc la réponse porte les charges utiles vides des mécanismes
/// inutilisés (`sgReady: {}`, `powerLevels: []`) qu'une reconstruction amputerait.
///
/// L'acquittement ne vaut PAS application : la confirmation est la notification
/// `NymeaEnergy.LoadConfigChanged`, émise après toute persistance réussie.
///
/// \return `null` si la box a accepté, sinon le code d'erreur qu'elle a renvoyé.
Future<String?> setLoadConfig(List<Map<String, dynamic>> payloads) async {
if (_isSimulation || !_connected) {
_log('[LoadConfig] sim/déconnecté — SetLoadConfig ignoré', force: true);
return 'Disconnected';
}
try {
final r = await _sendRequest(
'NymeaEnergy.SetLoadConfig', {'loadConfigs': payloads});
final err = r['params']?['energyError'] as String?;
_log('[LoadConfig] SetLoadConfig(${payloads.length} charge(s)) ← '
'${err ?? r['status']}', force: true);
if (err == null || err == 'EnergyErrorNoError') return null;
return err;
} catch (e) {
_log('[LoadConfig] SetLoadConfig: $e', force: true);
return e.toString();
}
}
/// `NymeaEnergy.GetLoadConfig` — **réel**, vérifié contre `.75`.
///
/// Renvoie les charges utiles BRUTES, telles que la box les a émises, y compris les
/// charges `enabled: false` (éditables depuis l'app) et les charges utiles vides des
/// mécanismes inutilisés. Ces maps sont la source de vérité : les conserver intactes.
Future<List<Map<String, dynamic>>> getLoadConfig() async {
if (_isSimulation || !_connected) return const [];
try {
final r = await _sendRequest('NymeaEnergy.GetLoadConfig', {});
final raw = (r['params']?['loadConfigs'] as List?) ?? const [];
final out = raw
.whereType<Map>()
.map((m) => Map<String, dynamic>.from(m))
.toList();
_log('[LoadConfig] GetLoadConfig → ${out.length} charge(s)', force: true);
return out;
} catch (e) {
_log('[LoadConfig] GetLoadConfig: $e', force: true);
rethrow;
}
}
/// `NymeaEnergy.GetLoadTelemetry` — instantané de l'état runtime de l'arbitrage.
///
/// Le `Get` **ne recalcule rien** : il rend l'objet qui a été publié au dernier cycle.
/// La charge utile est identique à celle de la notification `LoadTelemetryChanged`.
///
/// \return la charge utile brute, ou `null` si **cette box n'expose pas la méthode**
/// (plugin antérieur au lot télémétrie). Les deux cas se distinguent : une box sans la
/// méthode répond `status: error` / `"No such method"`, ce qui n'est pas une panne à
/// signaler mais une version à annoncer. Une erreur de transport, elle, remonte.
Future<Map<String, dynamic>?> getLoadTelemetry() async {
if (_isSimulation || !_connected) return null;
final r = await _sendRequest('NymeaEnergy.GetLoadTelemetry', {});
if (r['status'] != 'success') {
_loadTelemetrySupported = false;
_log('[Télémétrie] GetLoadTelemetry ← ${r['error'] ?? r['status']} '
'— cette box n\'expose pas la méthode', force: true);
return null;
}
_loadTelemetrySupported = true;
final params = r['params'];
if (params is! Map) return null;
final out = Map<String, dynamic>.from(params);
_log('[Télémétrie] GetLoadTelemetry → ${(out['loads'] as List?)?.length ?? 0} '
'charge(s), cycle ${out['timestamp'] ?? 'AUCUN'}', force: true);
return out;
}
/// Faux dès qu'un `GetLoadTelemetry` a été refusé par cette box.
bool _loadTelemetrySupported = true;
bool get loadTelemetrySupported => _loadTelemetrySupported;
/// `AirConditioning.GetZones` — les zones de climatisation déclarées sur la box.
///
/// ## Ce que l'introspection a révélé, et qui contredit une hypothèse de travail
///
/// L'expérience `AirConditioning` n'est pas une surface réduite qu'il faudrait griser :
/// elle publie **huit** méthodes, toutes en écriture — `AddZone`, `RemoveZone`,
/// `SetZoneName`, `SetZoneThings`, `SetZoneStandbySetpoint`,
/// `SetZoneSetpointOverride`, `SetZoneWeekSchedule`, `GetZones` (relevé sur `.75`,
/// `AirConditioning 1.1`). Une zone porte ses thermostats, ses vannes, ses sondes,
/// une consigne de veille, une dérogation avec mode et durée, et un programme
/// hebdomadaire complet.
///
/// Les champs de `ZoneInfo` sont tous `r:` — mais cela ne veut PAS dire « lecture
/// seule » au sens fonctionnel : ils se règlent par les setters dédiés ci-dessus, pas
/// en réécrivant l'objet. Confondre les deux ferait griser un écran entier qui est en
/// réalité pleinement pilotable.
///
/// **Et c'est bien là que vivent thermostats et vannes** — pas dans `LoadConfig`.
/// LM-202 : un thermostat ouvre une vanne, il ne porte pas la consommation, qui reste
/// à la machine. Les déclarer comme charges produirait des entrées à 0 W dans le
/// waterfall pendant que la PAC est comptée ailleurs. Les zones décrivent **où va la
/// chaleur**, pas **combien on en achète**.
///
/// \return les zones, ou liste vide. Sur `.75` au 2026-08-25 : **aucune zone déclarée**.
Future<List<Map<String, dynamic>>> getZones() async {
if (_isSimulation || !_connected) return const [];
try {
final r = await _sendRequest('AirConditioning.GetZones', {});
final raw = (r['params']?['zones'] as List?) ?? const [];
final out = raw
.whereType<Map>()
.map((m) => Map<String, dynamic>.from(m))
.toList();
_log('[Zones] GetZones → ${out.length} zone(s)', force: true);
return out;
} catch (e) {
_log('[Zones] GetZones: $e', force: true);
return const [];
}
}
/// `JSONRPC.Introspect` — le **schéma que cette box accepte**, tel qu'elle le publie.
///
/// C'est la seule source honnête pour décider ce qu'un écran active ou grise. Le parc
/// est hétérogène : une table de versions codée en dur mentirait dans les deux sens —
/// champ disponible resté gris sur une box récente, champ absent présenté comme actif
/// sur une ancienne. Dérivé du schéma, un champ que le plugin se met à exposer s'active
/// **seul**, sans nouvelle version de l'app.
///
/// Relevé sur `.75` (plugin `1.15.2+etm15`) : 20 méthodes `NymeaEnergy.*`, 8 méthodes
/// `AirConditioning.*`. Les préfixes de champ portent le sens — `o:` optionnel et
/// écrivable, `r:` lecture seule.
///
/// \return la charge utile brute d'introspection, ou `null` si elle n'a pas pu être
/// lue. `null` ne veut PAS dire « champs absents » : il veut dire « on ne sait pas »,
/// et les écrans doivent l'annoncer ainsi plutôt que de griser au hasard.
Future<Map<String, dynamic>?> introspect() async {
if (_isSimulation || !_connected) return null;
try {
final r = await _sendRequest('JSONRPC.Introspect', {});
final params = r['params'];
if (r['status'] != 'success' || params is! Map) {
_log('[Schéma] Introspect ← ${r['error'] ?? r['status']}', force: true);
return null;
}
final out = Map<String, dynamic>.from(params);
final methods = (out['methods'] as Map?)?.length ?? 0;
_log('[Schéma] Introspect → $methods méthode(s)', force: true);
return out;
} catch (e) {
_log('[Schéma] Introspect: $e', force: true);
return null;
}
}
/// `NymeaEnergy.ClearLoadFault` — lève le verrou de défaut d'une charge (ECS-410).
///
/// **L'acquittement ne prouve rien.** Vérifié sur `.75` le 2026-08-25 : appelée sur une
/// charge parfaitement saine, la méthode répond `EnergyErrorNoError`. Le seul constat
/// recevable est `available` repassé à vrai dans la télémétrie **relue**.
///
/// \return `null` si la box a accepté, sinon le code d'erreur (`loadId` inconnu →
/// `EnergyErrorInvalidParameter`).
Future<String?> clearLoadFault(String loadId) async {
if (_isSimulation || !_connected) return 'Disconnected';
try {
final r = await _sendRequest(
'NymeaEnergy.ClearLoadFault', {'loadId': loadId});
final err = r['params']?['energyError'] as String?;
_log('[Télémétrie] ClearLoadFault($loadId) ← ${err ?? r['status']} '
'— ne vaut PAS levée : à constater sur la télémétrie', force: true);
if (err == null || err == 'EnergyErrorNoError') return null;
return err;
} catch (e) {
_log('[Télémétrie] ClearLoadFault: $e', force: true);
return e.toString();
}
}
/// Abonnés à `NymeaEnergy.LoadTelemetryChanged`.
final List<void Function(Map<String, dynamic>)> _loadTelemetryListeners = [];
/// S'abonne à la télémétrie poussée. Renvoie la fonction de désabonnement.
void Function() onLoadTelemetryChanged(void Function(Map<String, dynamic>) cb) {
_loadTelemetryListeners.add(cb);
return () => _loadTelemetryListeners.remove(cb);
}
void _onLoadTelemetryChanged(Map<String, dynamic> payload) {
_log('[Télémétrie] ← LoadTelemetryChanged : cycle '
'${payload['timestamp'] ?? 'AUCUN'}, '
'${(payload['loads'] as List?)?.length ?? 0} charge(s)');
for (final cb in [..._loadTelemetryListeners]) {
cb(payload);
}
}
/// Trace de routage (charges hors etmvariableload : PAC SG-Ready, batterie).
void logInfo(String msg) => _log(msg, force: true);
// ═══════════════════════════════════════════════════════════════════════════
// MODBUS RTU MASTERS (zone installateur — prérequis meters SDM/abbterra)
// ═══════════════════════════════════════════════════════════════════════════
/// Ports série dispo sur l'hôte (`ModbusRtu.GetSerialPorts`).
Future<List<SerialPort>> getSerialPorts() async {
if (_isSimulation || !_connected) return const [];
try {
final r = await _sendRequest('ModbusRtu.GetSerialPorts', {});
final raw = (r['params']?['serialPorts'] as List?) ?? const [];
return raw
.map((e) => SerialPort.fromJson(e as Map<String, dynamic>))
.toList();
} catch (e) {
_log('GetSerialPorts: $e', force: true);
return const [];
}
}
/// Masters Modbus RTU configurés (`ModbusRtu.GetModbusRtuMasters`).
Future<List<ModbusRtuMaster>> getModbusRtuMasters() async {
if (_isSimulation || !_connected) return const [];
try {
final r = await _sendRequest('ModbusRtu.GetModbusRtuMasters', {});
final raw = (r['params']?['modbusRtuMasters'] as List?) ?? const [];
return raw
.map((e) => ModbusRtuMaster.fromJson(e as Map<String, dynamic>))
.toList();
} catch (e) {
_log('GetModbusRtuMasters: $e', force: true);
return const [];
}
}
/// Crée un master (`ModbusRtu.AddModbusRtuMaster`). Renvoie `null` si succès,
/// sinon un message d'erreur lisible (modbusError mappé / erreur transport).
Future<String?> addModbusRtuMaster({
required String serialPort,
required int baudrate,
required String parity,
required String dataBits,
required String stopBits,
required int numberOfRetries,
required int timeout,
}) =>
_writeMaster('ModbusRtu.AddModbusRtuMaster', {
'serialPort': serialPort,
'baudrate': baudrate,
'parity': parity,
'dataBits': dataBits,
'stopBits': stopBits,
'numberOfRetries': numberOfRetries,
'timeout': timeout,
});
/// Édite un master (`ModbusRtu.ReconfigureModbusRtuMaster`).
Future<String?> reconfigureModbusRtuMaster({
required String modbusUuid,
required String serialPort,
required int baudrate,
required String parity,
required String dataBits,
required String stopBits,
required int numberOfRetries,
required int timeout,
}) =>
_writeMaster('ModbusRtu.ReconfigureModbusRtuMaster', {
'modbusUuid': modbusUuid,
'serialPort': serialPort,
'baudrate': baudrate,
'parity': parity,
'dataBits': dataBits,
'stopBits': stopBits,
'numberOfRetries': numberOfRetries,
'timeout': timeout,
});
/// Supprime un master (`ModbusRtu.RemoveModbusRtuMaster`).
Future<String?> removeModbusRtuMaster(String modbusUuid) =>
_writeMaster('ModbusRtu.RemoveModbusRtuMaster', {'modbusUuid': modbusUuid});
/// Tronc commun Add/Reconfigure/Remove : envoie, lit `modbusError`, mappe.
Future<String?> _writeMaster(
String method, Map<String, dynamic> params) async {
if (_isSimulation || !_connected) {
return 'Indisponible : aucune box connectée (mode démo).';
}
try {
final r = await _sendRequest(method, params);
final err =
(r['params']?['modbusError'] as String?) ?? 'ModbusRtuErrorNoError';
return err == 'ModbusRtuErrorNoError' ? null : modbusErrorMessage(err);
} catch (e) {
_log('$method: $e', force: true);
return 'Erreur de communication avec la box.';
}
}
// ═══════════════════════════════════════════════════════════════════════════
// THINGS / INTEGRATIONS
// ═══════════════════════════════════════════════════════════════════════════
bool _thingsLoaded = false;
bool get thingsLoaded => _thingsLoaded;
/// Catalogue (lecture seule) — fabricants connus de ce nymea (`GetVendors`).
Future<List<NymeaVendor>> getVendors() async {
if (_isSimulation || !_connected) return const [];
try {
final r = await _sendRequest('Integrations.GetVendors', {});
final raw = (r['params']?['vendors'] as List?) ?? const [];
return raw
.map((e) => NymeaVendor.fromJson(e as Map<String, dynamic>))
.toList();
} catch (e) {
_log('GetVendors: $e', force: true);
return const [];
}
}
/// Catalogue (lecture seule) — **toutes** les thing classes connues de ce
/// nymea (`GetThingClasses` sans filtre). Indépendant de `_thingClasses`
/// (qui ne couvre que les classes des things configurés).
Future<List<NymeaThingClass>> getAllThingClasses() async {
if (_isSimulation || !_connected) return const [];
try {
final r = await _sendRequest('Integrations.GetThingClasses', {});
final raw = (r['params']?['thingClasses'] as List?) ?? const [];
return raw
.map((e) => NymeaThingClass.fromJson(e as Map<String, dynamic>))
.toList();
} catch (e) {
_log('GetAllThingClasses: $e', force: true);
return const [];
}
}
/// **Lot C — discovery.** Lance `Integrations.DiscoverThings`. Renvoie
/// `(error, descriptors)` : `error==null` = succès (descriptors peut être
/// **vide** = aucun appareil trouvé, à distinguer d'une erreur).
///
/// ⚠️ À REVALIDER sur SDM réel : le scan « peut prendre un moment » — si le bus
/// réel dépasse le timeout 15 s de `_sendRequest`, l'allonger ici.
Future<({String? error, List<ThingDescriptor> descriptors})> discoverThings(
String thingClassId,
List<Map<String, dynamic>> discoveryParams,
) async {
if (_isSimulation || !_connected) {
return (
error: 'Indisponible : aucune box connectée (mode démo).',
descriptors: <ThingDescriptor>[]
);
}
try {
final r = await _sendRequest('Integrations.DiscoverThings', {
'thingClassId': thingClassId,
if (discoveryParams.isNotEmpty) 'discoveryParams': discoveryParams,
});
final err = (r['params']?['thingError'] as String?) ?? 'ThingErrorNoError';
if (err != 'ThingErrorNoError') {
return (error: thingErrorMessage(err), descriptors: <ThingDescriptor>[]);
}
final raw = (r['params']?['thingDescriptors'] as List?) ?? const [];
return (
error: null,
descriptors: raw
.map((e) => ThingDescriptor.fromJson(e as Map<String, dynamic>))
.toList()
);
} catch (e) {
_log('DiscoverThings: $e', force: true);
return (
error: 'Erreur de communication avec la box.',
descriptors: <ThingDescriptor>[]
);
}
}
/// **Lot C — ajout descriptor-based** (`AddThing` avec `thingDescriptorId`,
/// params pris du descriptor). `null` = succès (things rechargés), sinon
/// message FR. Réservé aux classes `setupMethod=JustAdd` (pas de pairing).
Future<String?> addThingFromDescriptor({
required String thingClassId,
required String thingDescriptorId,
required String name,
}) async {
if (_isSimulation || !_connected) return 'Indisponible (mode démo).';
try {
final r = await _sendRequest('Integrations.AddThing', {
'name': name,
'thingClassId': thingClassId,
'thingDescriptorId': thingDescriptorId,
});
final err = (r['params']?['thingError'] as String?) ?? 'ThingErrorNoError';
if (err == 'ThingErrorNoError') {
await _loadThings(); // refresh things
return null;
}
return thingErrorMessage(err);
} catch (e) {
_log('AddThing(descriptor): $e', force: true);
return 'Erreur de communication avec la box.';
}
}
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();
// (ancien discoverThings sans params/erreur retiré — remplacé par la version
// typée avec discoveryParams + ThingError, plus haut.)
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();
}
}