etm-powersync-app/lib/services/nymea_service.dart
Patrick Schurig a92e9b2f62 fix(recharge): lire-patcher-réécrire — SetChargingInfo n'est pas une écriture partielle
La box promet le contraire de ce qu'elle fait, et par écrit : sa propre description dans
JSONRPC.Introspect dit « Only given properties will be set, others will be untouched ».
ChargingInfo est en réalité reconstruit entier à chaque appel, et un champ absent retombe
sur son défaut C++ — 80 pour targetPercentage.

Reproduit sur .75 le 2026-08-27 (+etm22), borne 88160e45 : le champ valait 0, un
SetChargingInfo portant le seul chargingMode l'a laissé à 80, réponse EnergyErrorNoError.
Banc restauré.

L'app envoyait targetPercentage uniquement avec une échéance. Changer de mode de charge
reposait donc une cible de 80 % à l'insu de l'utilisateur, sans que rien ne le signale.
Désormais : GetChargingInfos, patch, réécriture de l'objet complet — sans chargingState,
qui est r: au schéma et ferait rejeter l'appel. Sans échéance, on repose explicitement la
valeur que la box portait déjà ; si elle n'en portait aucune, le neutre est 0, pas 80.

Et la cible n'est plus affichée hors échéance : 80 peut être l'empreinte d'une écriture qui
ne parlait pas de cible du tout, la montrer en ferait une intention.

Deux notes de contrat au passage :
- fetchPowerBalanceLogs porte enfin le piège qui a fait croire à un défaut de la box côté
  plugin — les entrées de log ne portent PAS le préfixe currentPower. Réutiliser les clés
  de GetPowerBalance ne produit aucune erreur, tout tombe sur 0. Vérifié ici avec les clés
  de l'app : 7 960 échantillons sur 90 jours en SampleRate15Mins, dont 4 975 de production
  non nulle. Rien ne bloque l'historique 90 jours.
- le défaut d'usine de batteryLevelConsideration est passé de 0,9 à 0,2. La carte reste
  nécessaire : le nouveau défaut ne rattrape pas une box qui a déjà persisté 0,9, et le
  seuil reste réglable jusqu'à 1,0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016vrTVifar2GN5rtUh89Wkq
2026-08-27 07:39:12 +02:00

2221 lines
92 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).
///
/// ## ⚠️ 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é.
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.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,
}) 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 {
// 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(evChargerId) ?? const {};
final info = <String, dynamic>{
...courant,
'evChargerId': evChargerId,
'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'é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)
// ═══════════════════════════════════════════════════════════════════════════
/// `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 {
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();
}
}