etm-powersync-app/lib/services/nymea_service.dart
Patrick Schurig 2c9cca042e fix(borne): une borne injoignable ne montre ni le zéro forgé ni la valeur périmée
Mesuré sur `.75` le 2026-08-30 : quand le lien Modbus de la V2C tombe, la box ne
fige pas les états, elle publie des zéros — `pluggedIn` false, `charging` false,
`currentPower` 0 — alors que la Skoda était branchée et en charge à 4 356 W.
Avant ça, la dernière valeur connue reste publiée comme si elle était actuelle
(vérifié : aucun état ne bouge en 45 s pendant une charge supposée).

L'app refusait déjà d'inventer un zéro ; elle en relayait un. Sous
`connected == false`, `EvChargerLive` met `pluggedIn`, `charging` et
`currentPowerW` à `null` À L'ENTRÉE — un seul endroit, pas un test par écran — et
porte `joignable` + `messageEtat`. La tuile dit « Hors ligne » et reprend la
phrase du plugin, qui porte l'heure de la coupure et la piste WiFi.

Deux garde-fous dans les tests : `joignable == null` n'est PAS hors ligne (une
borne qui ne publie pas `connected` ne dit rien de son lien — supposer la panne
serait le symétrique du zéro forgé), et le MODE reste affiché hors ligne, parce
qu'il est stocké dans la box et non lu sur la borne.

Brief moteur : les zéros à la perte du lien, `chargingState` figé à `Idle`
pendant une charge réelle, et `ChargingModeNormal` qui n'est pas un boost — il
restaure les valeurs manuelles mémorisées (ici arrêt, 6 A) et sort la borne de
l'arbitrage.

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

2527 lines
106 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/daily_balance.dart';
import '../models/energy_data.dart'; // EnergyData, HistoryPoint, PowerBalanceEntry
import '../models/ev_charger_live.dart';
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.ChargingInfoAdded':
case 'NymeaEnergy.ChargingInfoChanged':
// La box confirme le mode qu'elle porte VRAIMENT. C'est la seule source du mode
// affiché : l'app ne le déduit plus de la commande qu'elle vient d'envoyer.
final info = params['chargingInfo'];
if (info is Map) {
final m = Map<String, dynamic>.from(info);
final id = m['evChargerId'] as String?;
if (id != null) {
_chargingInfos[id] = m;
notifyListeners();
}
}
break;
case 'NymeaEnergy.ChargingInfoRemoved':
final id = params['evChargerId'] as String?;
if (id != null && _chargingInfos.remove(id) != null) notifyListeners();
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(),
]);
// Après les Things : la borne se trouve par son interface, et `GetChargingInfos` ne
// dit rien tant qu'on ne sait pas à quel Thing rattacher chaque entrée.
await refreshChargingInfos();
}
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).
///
/// ## ⚠️ Les entrées de log ne portent PAS le préfixe `currentPower`
///
/// `GetPowerBalance` rend `currentPowerProduction` / `currentPowerConsumption` ; les
/// entrées de log, elles, s'appellent **`production`, `consumption`, `acquisition`,
/// `storage`**. Réutiliser les clés de l'un pour l'autre ne produit **aucune erreur** :
/// tout tombe sur le défaut `0`, et l'historique paraît vide alors que la base est
/// pleine. C'est exactement ce qui a fait croire à un défaut de la box côté plugin — et
/// c'est pour ça que ce piège est écrit ici plutôt que retenu.
///
/// Vérifié sur `.75` le 2026-08-27, avec les clés ci-dessous : **7 960 échantillons sur
/// 90 jours** en `SampleRate15Mins`, dont 4 975 avec une production non nulle. Rien ne
/// bloque l'historique 90 jours de ce côté.
///
/// > Ce n'est pas un cas isolé : deux réponses de la même box peuvent employer deux
/// > conventions sans qu'aucun signal ne le dise, et l'erreur est toujours **muette**.
/// > Le motif et ses occurrences connues sont dans `docs/UI_data_contract.md` §9.
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'),
totalProductionKwh: d('totalProduction'),
totalConsumptionKwh:d('totalConsumption'),
totalReturnKwh: d('totalReturn'),
totalAcquisitionKwh:d('totalAcquisition'),
);
}).toList();
} catch (e) {
_log('fetchPowerBalanceLogs: $e');
return [];
}
}
/// **Le bilan du jour** — de minuit local à maintenant.
///
/// ## Ce qu'il corrige
///
/// Le tableau de bord n'avait aucune vue journalière : deux pourcentages instantanés et
/// deux compteurs cumulés **depuis l'origine**, dont deux portaient le mot
/// « aujourd'hui ». Cette méthode fournit la vraie journée.
///
/// ## Les trois pièges, déjà payés une fois chacun
///
/// 1. **Minuit LOCAL, et via les logs.** `GetPowerBalance` ne rend que des totaux depuis
/// l'origine : il ne sait rien dire d'une journée. Seul `GetPowerBalanceLogs` permet
/// de prendre deux points et de les soustraire. `DateTime(y, m, d)` donne bien minuit
/// dans le fuseau du téléphone — pas UTC.
/// 2. **Les entrées de log ne portent PAS le préfixe `currentPower`.** Elles s'appellent
/// `production`, `consumption`, `acquisition`, `storage`, à la différence de
/// `GetPowerBalance`. Se tromper de clés ne produit **aucune erreur** : tout tombe sur
/// le défaut `0`, et l'écran affiche une journée vide sur une installation qui a
/// produit. C'est exactement ce qui a fait croire à un défaut de la box côté plugin.
/// Le décodage est déjà correct dans [fetchPowerBalanceLogs] ; cette méthode s'appuie
/// dessus plutôt que de refaire un décodage qui pourrait diverger.
/// 3. **Les cumulés peuvent être corrompus.** `.75` a publié 9,1e+33 kWh exportés. Un
/// bilan bâti là-dessus serait absurde sans le paraître, d'où le contrôle de
/// plausibilité sur les deux bornes AVANT toute soustraction.
///
/// \return `null` quand la journée n'est **pas mesurable** — jamais un bilan à zéro.
/// « Rien produit » et « on ne sait pas » sont deux états différents, et le second ne
/// doit pas se déguiser en premier.
Future<DailyBalance?> fetchDailyBalance({DateTime? jour}) async {
final maintenant = jour ?? DateTime.now();
final minuit =
DateTime(maintenant.year, maintenant.month, maintenant.day);
// Le quart d'heure est le taux le plus fin peuplé sur la durée d'une journée, et il
// place assez de points pour qu'un début de fenêtre tardif se voie.
final entries = await fetchPowerBalanceLogs(
from: minuit,
to: maintenant,
sampleRate: 'SampleRate15Mins',
);
// Une différence a besoin de deux points. Un seul échantillon n'est pas « une journée
// à zéro », c'est une journée qui n'a pas encore de quoi être mesurée.
if (entries.length < 2) {
_log('[Bilan] ${entries.length} échantillon(s) depuis minuit — non mesurable',
force: true);
return null;
}
final tries = [...entries]
..sort((a, b) => a.timestamp.compareTo(b.timestamp));
final a = tries.first;
final b = tries.last;
// Compteurs corrompus : on refuse AVANT de soustraire. Deux valeurs absurdes peuvent
// avoir une différence d'apparence saine.
final bornes = [
a.totalProductionKwh, b.totalProductionKwh,
a.totalConsumptionKwh, b.totalConsumptionKwh,
a.totalReturnKwh, b.totalReturnKwh,
a.totalAcquisitionKwh, b.totalAcquisitionKwh,
];
if (!bornes.every(EnergyData.isPlausibleKwh)) {
_log('[Bilan] compteurs cumulés inexploitables — non mesurable', force: true);
return null;
}
double delta(double fin, double debut) => fin - debut;
final bilan = DailyBalance(
from: a.timestamp,
to: b.timestamp,
sampleCount: tries.length,
productionKwh: delta(b.totalProductionKwh, a.totalProductionKwh),
consumptionKwh: delta(b.totalConsumptionKwh, a.totalConsumptionKwh),
importKwh: delta(b.totalAcquisitionKwh, a.totalAcquisitionKwh),
exportKwh: delta(b.totalReturnKwh, a.totalReturnKwh),
);
// Un compteur qui RECULE = remise à zéro ou redémarrage au milieu de la fenêtre. Le
// bilan serait négatif, donc faux, et un `max(0, …)` le rendrait faux en silence.
if (bilan.productionKwh < 0 ||
bilan.consumptionKwh < 0 ||
bilan.importKwh < 0 ||
bilan.exportKwh < 0) {
_log('[Bilan] compteur en recul entre les deux bornes — non mesurable',
force: true);
return null;
}
_log(
'[Bilan] ${tries.length} échantillons · '
'prod ${bilan.productionKwh.toStringAsFixed(1)} · '
'conso ${bilan.consumptionKwh.toStringAsFixed(1)} · '
'import ${bilan.importKwh.toStringAsFixed(1)} · '
'export ${bilan.exportKwh.toStringAsFixed(1)} kWh · '
'identité ${bilan.identityHolds ? "OK" : "FAUSSE"}',
force: true);
return bilan;
}
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();
// Les compteurs cumulés de la box sont en kWh (vérifié sur `.75`) : la simulation
// doit produire la même unité, sinon elle ment d'un facteur mille et un écran qui
// marche en simulation casse contre une vraie box.
totalProd += prod * stepHours / 1000;
totalConso += conso * stepHours / 1000;
totalRet += ret * stepHours / 1000;
totalAcq += acq * stepHours / 1000;
entries.add(PowerBalanceEntry(
timestamp: t,
productionW: prod,
consumptionW: conso,
acquisitionW: acq,
storageW: storage,
totalProductionKwh: totalProd,
totalConsumptionKwh: totalConso,
totalReturnKwh: totalRet,
totalAcquisitionKwh: 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.GetChargingInfos` — l'objet de recharge **tel que la box le porte**.
///
/// Lecture indispensable avant toute écriture : `SetChargingInfo` reconstruit l'objet
/// entier et un champ absent retombe sur son défaut C++ (cf. [setChargingInfo]). On ne
/// peut donc pas « écrire un champ » sans avoir lu les autres.
///
/// \return l'objet de cette borne, ou `null` si la box n'en porte pas — auquel cas il
/// n'y a rien à préserver, et c'est une information, pas un échec.
Future<Map<String, dynamic>?> getChargingInfo(String evChargerId) async {
if (_isSimulation || !_connected) return null;
try {
final r = await _sendRequest('NymeaEnergy.GetChargingInfos', {});
final list = r['params']?['chargingInfos'];
if (list is! List) return null;
for (final e in list) {
if (e is Map && e['evChargerId'] == evChargerId) {
return Map<String, dynamic>.from(e);
}
}
return null;
} catch (e) {
_log('[Recharge] GetChargingInfos: $e', force: true);
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.
///
/// ## Écriture PARTIELLE : la box promet le contraire de ce qu'elle fait
///
/// `JSONRPC.Introspect` décrit la méthode ainsi : *« Only given properties will be set,
/// others will be untouched. »* **C'est faux.** `ChargingInfo` est reconstruit
/// intégralement à chaque écriture (`unpack<ChargingInfo>`), et un champ absent du JSON
/// ne reste pas en place : il retombe sur le **défaut du membre C++**. Pour
/// `targetPercentage`, ce défaut vaut **80**.
///
/// Et la substitution est **muette** : réponse `EnergyErrorNoError`, aucun
/// avertissement (le seul du chemin d'écriture ne se déclenche qu'au-delà de 100), et
/// la notification `ChargingInfoChanged` qui suit porte 80 comme si l'utilisateur
/// l'avait choisi.
///
/// Reproduit sur `.75` le 2026-08-27, plugin `+etm22` : `targetPercentage` valait `0`,
/// un `SetChargingInfo` portant le seul `chargingMode` l'a laissé à **80**.
///
/// D'où **lire, patcher, réécrire l'objet complet** — le même chemin que `priority`
/// dans `LoadConfig`. Le seul champ qu'on ne renvoie PAS est `chargingState`, marqué
/// `r:` au schéma : le réémettre ferait rejeter l'appel entier.
///
/// Conséquence pour l'écran : `targetPercentage` **n'est une cible choisie par
/// l'utilisateur que s'il l'a lui-même écrite**. 80 peut être l'empreinte d'une
/// écriture qui ne parlait pas de cible du tout.
///
/// \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,
/// Borne visée. Omis, l'écriture part sur la **première** borne configurée — ce qui
/// n'a de sens que sur une installation qui n'en a qu'une. Le banc en porte trois.
String? evChargerId,
}) 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 vise = evChargerId ?? _findEvChargerId();
if (vise == null) {
_log('[Recharge] SetChargingInfo: aucune borne trouvée', force: true);
error = 'NoEvCharger';
} else {
try {
// LIRE d'abord — une écriture partielle n'existe pas ici, tout champ omis
// retombe sur son défaut C++. Une lecture qui échoue laisse `courant` vide :
// on écrit alors le strict nécessaire, ce qui n'est pas pire que l'ancien
// comportement et reste honnête sur ce qu'on sait.
final courant = await getChargingInfo(vise) ?? const {};
final info = <String, dynamic>{
...courant,
'evChargerId': vise,
'chargingMode': apiMode,
};
// `chargingState` est en lecture seule (`r:`) au schéma : le renvoyer ferait
// rejeter TOUT l'appel. Même règle pour toute clé hors schéma — nymea valide
// avant d'atteindre le handler.
info.remove('chargingState');
if (deadline && mode != ChargingMode.boost) {
info['targetPercentage'] = targetSoc;
if (endTime != null) {
info['endDateTime'] = endTime.millisecondsSinceEpoch ~/ 1000;
}
} else if (!info.containsKey('targetPercentage')) {
// Sans échéance, l'app ne CHOISIT pas de cible — mais l'omettre en
// choisirait une (80) à la place de l'utilisateur, sans rien lui dire. On
// repose donc explicitement la valeur que la box portait déjà ; si elle n'en
// portait aucune, `0` est le neutre, pas `80`.
info['targetPercentage'] = 0;
}
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'app ne tient PLUS d'état de mode local, même après un succès.
//
// Elle le faisait, et c'était la racine de la fiction : `EnergyData.chargingMode`
// partait de `pv` par défaut, n'était jamais relu sur la box, et n'avançait qu'ici —
// donc l'écran affichait « Mode PV » sur une borne que personne n'avait réglée, et
// « Boost » après une commande que la borne pouvait avoir appliquée autrement.
//
// Le mode affiché vient désormais de `GetChargingInfos` et de la notification
// `ChargingInfoChanged`. On relit après une écriture acceptée : c'est la box qui dit
// ce qu'elle porte, pas l'app qui se souvient de ce qu'elle a demandé.
if (error == null) await refreshChargingInfos();
return error;
}
// ── Ce que la borne publie, et rien de plus ─────────────────────────────────
/// `evChargerId` → charge utile `ChargingInfo` telle que la box la porte.
///
/// Alimenté par [refreshChargingInfos] et par les notifications
/// `ChargingInfoAdded/Changed/Removed`. Vide tant que rien n'a été lu — et « vide » doit
/// se lire « pas encore lu », jamais « aucun mode ».
final Map<String, Map<String, dynamic>> _chargingInfos = {};
/// Relit `NymeaEnergy.GetChargingInfos` pour TOUTES les bornes.
Future<void> refreshChargingInfos() async {
if (_isSimulation || !_connected) return;
try {
final r = await _sendRequest('NymeaEnergy.GetChargingInfos', {});
final list = r['params']?['chargingInfos'];
if (list is! List) return;
_chargingInfos.clear();
for (final e in list) {
if (e is Map) {
final m = Map<String, dynamic>.from(e);
final id = m['evChargerId'] as String?;
if (id != null) _chargingInfos[id] = m;
}
}
_log('[Recharge] GetChargingInfos → ${_chargingInfos.length} borne(s)',
force: true);
notifyListeners();
} catch (e) {
_log('[Recharge] GetChargingInfos: $e', force: true);
}
}
/// Mode de recharge que la box porte pour cette borne, ou `null` si rien n'a été lu.
///
/// Accepte indifféremment un `thingId` et un identifiant de charge `GetLoadConfig` : le
/// premier arrive **sans** accolades (`4d1adafd-…`), le second **avec** (`{4d1adafd-…}`),
/// et c'est la même borne. Comparer les deux tels quels ne rend jamais rien — en
/// silence, ce qui est le pire des cas : l'écran conclurait « mode non lu » sur une borne
/// parfaitement lue.
///
/// Deuxième occurrence connue du même motif — cf. `docs/UI_data_contract.md` §9, et la
/// première : les clés de `GetPowerBalanceLogs`, sans le préfixe `currentPower`.
String? chargingModeOf(String id) {
final nu = id.replaceAll('{', '').replaceAll('}', '').toLowerCase();
for (final e in _chargingInfos.entries) {
final k = e.key.replaceAll('{', '').replaceAll('}', '').toLowerCase();
if (k == nu) return e.value['chargingMode'] as String?;
}
return null;
}
/// Porte de test — injecte des Things et leurs classes sans transport.
///
/// Nécessaire pour éprouver ce qui dépend d'un **état de Thing** : la borne se trouve
/// par son interface, et ses états (`pluggedIn`, `charging`, `currentPower`) se lisent
/// par le nom déclaré dans la `ThingClass`.
@visibleForTesting
void adoptThingsForTest(
List<NymeaThing> things, List<NymeaThingClass> classes) {
_things
..clear()
..addAll(things);
_thingClasses
..clear()
..addAll(classes);
notifyListeners();
}
/// Porte de test — même usage que `LoadConfigProvider.adoptForTest` : éprouver
/// l'affichage sur une charge utile réelle sans box.
@visibleForTesting
void adoptChargingInfosForTest(List<Map<String, dynamic>> infos) {
_chargingInfos.clear();
for (final m in infos) {
final id = m['evChargerId'] as String?;
if (id != null) _chargingInfos[id] = m;
}
notifyListeners();
}
/// Valeur d'un état du Thing **par son nom** (`pluggedIn`, `currentPower`, …).
///
/// Les états voyagent par `stateTypeId` (un UUID) ; le nom vit dans la `ThingClass`. Sans
/// la classe, on ne peut rien nommer — et `null` est alors la seule réponse honnête.
Object? _stateByName(NymeaThing thing, String name) {
NymeaThingClass? cls;
for (final c in _thingClasses) {
if (c.id == thing.thingClassId) {
cls = c;
break;
}
}
if (cls == null) return null;
String? id;
for (final st in cls.stateTypes) {
if (st.name == name) {
id = st.id;
break;
}
}
if (id == null) return null;
for (final s in thing.states) {
if (s.stateTypeId == id) return s.value;
}
return null;
}
/// La première borne de recharge configurée, **telle que la box la publie**.
///
/// `null` = aucune borne sur cette installation. Chaque champ de [EvChargerLive] est
/// nullable pour la même raison : une borne qui ne publie pas `currentPower` ne consomme
/// pas 0 W, elle ne le dit pas. Cf. LM-1009 §C1.
EvChargerLive? get evCharger => evChargerFor(_findEvChargerId());
/// La borne d'identifiant donné, `thingId` **ou** id de charge `GetLoadConfig` (les
/// accolades sont réconciliées — cf. `docs/UI_data_contract.md` §9).
///
/// Le banc en porte trois : viser « la première » depuis un écran qui parle d'une borne
/// précise afficherait — et écrirait — sur la mauvaise.
EvChargerLive? evChargerFor(String? wanted) {
if (wanted == null) return null;
final nu = wanted.replaceAll('{', '').replaceAll('}', '').toLowerCase();
String? id;
for (final t in _things) {
if (t.id.replaceAll('{', '').replaceAll('}', '').toLowerCase() == nu) {
id = t.id;
break;
}
}
if (id == null) return null;
NymeaThing? thing;
for (final t in _things) {
if (t.id == id) {
thing = t;
break;
}
}
if (thing == null) return null;
final info = _chargingInfos[id];
final power = _stateByName(thing, 'currentPower');
final joignable = _stateByName(thing, 'connected') as bool?;
final msg = _stateByName(thing, 'statusMessage');
// Borne injoignable : la box publie des ZÉROS (pluggedIn false, charging false,
// currentPower 0) là où elle ne sait plus rien — mesuré sur la V2C le 2026-08-30.
// Les relayer ferait dire à l'écran « aucun véhicule branché » pendant une charge.
// On les jette ici, à l'entrée, plutôt que dans chaque écran.
final horsLigne = joignable == false;
return EvChargerLive(
thingId: id,
name: thing.name,
pluggedIn: horsLigne ? null : _stateByName(thing, 'pluggedIn') as bool?,
charging: horsLigne ? null : _stateByName(thing, 'charging') as bool?,
currentPowerW: (!horsLigne && power is num) ? power.toDouble() : null,
apiMode: info?['chargingMode'] as String?,
joignable: joignable,
messageEtat: (msg is String && msg.isNotEmpty) ? msg : null,
);
}
// ═══════════════════════════════════════════════════════════════════════════
// CONFIG ÉNERGIE — rôles & charges pilotées (écran Rôles & appareils)
// ═══════════════════════════════════════════════════════════════════════════
/// `NymeaEnergy.GetBatteryLevelConsideration` — la **réserve batterie**.
///
/// ## Un seuil dont la portée s'élargit
///
/// Fraction de 0 à 1 : sous ce niveau de charge, la batterie est considérée comme
/// réservée. Aujourd'hui la règle ne gouverne que la recharge du véhicule. Une fois
/// montée dans le budget, elle gouvernera **toute l'installation** : sous le seuil, le
/// budget de surplus est **annulé** — pas réduit — donc ni ECS, ni PAC, ni bornes.
///
/// ## Pourquoi il ne peut pas rester invisible
///
/// Le défaut d'usine **était** à 0,9 ; il est passé à **0,2**. Sur une installation dont
/// la batterie plafonne à 50 %, 0,9 gèle tout — et le symptôme, chez un client, est un
/// HEMS qui ne pilote plus rien par grand soleil, **sans erreur ni panne**. Le nouveau
/// défaut ne rattrape pas les box déjà déployées, qui gardent la valeur persistée, et le
/// seuil reste réglable jusqu'à 1,0. Un seuil qui peut produire ça n'est pas un
/// paramètre d'usine : c'est un réglage d'installation, et il doit se voir.
///
/// \return la fraction lue, ou `null` si la box ne l'expose pas.
Future<double?> getBatteryLevelConsideration() async {
if (_isSimulation || !_connected) return null;
try {
final r = await _sendRequest('NymeaEnergy.GetBatteryLevelConsideration', {});
final v = (r['params']?['batteryLevelConsideration'] as num?)?.toDouble();
_log('[Batterie] réserve → ${v ?? 'non exposée'}', force: true);
return v;
} catch (e) {
_log('[Batterie] GetBatteryLevelConsideration: $e', force: true);
return null;
}
}
/// `NymeaEnergy.SetBatteryLevelConsideration`.
///
/// \param fraction niveau de réserve, de 0 à 1.
/// \return `null` si la box a accepté, sinon le code d'erreur.
Future<String?> setBatteryLevelConsideration(double fraction) async {
if (_isSimulation || !_connected) return 'Disconnected';
try {
final r = await _sendRequest('NymeaEnergy.SetBatteryLevelConsideration',
{'batteryLevelConsideration': fraction});
final err = r['params']?['energyError'] as String?;
_log('[Batterie] réserve ← $fraction : ${err ?? r['status']}', force: true);
if (err == null || err == 'EnergyErrorNoError') return null;
return err;
} catch (e) {
_log('[Batterie] SetBatteryLevelConsideration: $e', force: true);
return e.toString();
}
}
/// `Energy.GetRootMeter` — le compteur principal **tel que la box le connaît**.
///
/// ## Ce qu'il répare
///
/// L'app ne l'appelait **jamais** : elle savait poser le compteur racine
/// (`SetRootMeter`) mais pas le relire. Son état en mémoire repartait donc vide à chaque
/// démarrage, et le rôle « compteur réseau » réapparaissait « à configurer » alors que la
/// box l'avait bel et bien — vérifié sur `.75`, qui rend
/// `549449c1-…` (Fronius Three Phase Meter) sans discontinuer.
///
/// Rien n'était perdu : c'était une configuration **jamais lue**. La nuance compte, parce
/// qu'elle change le geste — il n'y a pas à reconfigurer, il y a à afficher.
///
/// \return le ThingId du compteur racine, ou `null` si la box n'en a pas.
Future<String?> getRootMeter() async {
if (_isSimulation || !_connected) return null;
try {
final r = await _sendRequest('Energy.GetRootMeter', {});
final id = r['params']?['rootMeterThingId'] as String?;
_log('[RootMeter] GetRootMeter → ${id ?? 'aucun'}', force: true);
return (id == null || id.isEmpty) ? null : id;
} catch (e) {
_log('[RootMeter] GetRootMeter: $e', force: true);
return null;
}
}
/// 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 {
// Le nettoyage de `rankOrigin: "auto"` qui vivait ici est RETIRÉ le 2026-08-29 :
// l'écho verbatim inchangé passe (`reconcileRankOrigin()` accepte `"auto"` quand le
// persisté le porte déjà, pour ne pas rompre la neutralité Get → Set). La marque
// survit donc désormais aux gestes qui ne la concernent pas, et le badge ne s'éteint
// plus sur une borne que personne n'a classée.
//
// Ce qui reste vrai, et que le §10 du contrat de données garde : un écho **périmé** —
// `"auto"` renvoyé alors que le persisté ne le porte plus — est refusé, à juste titre.
// C'est ce cas-là que l'app avait mesuré en croyant mesurer l'autre.
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();
}
}