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
2221 lines
92 KiB
Dart
2221 lines
92 KiB
Dart
import 'dart:async';
|
||
import 'dart:developer' as dev;
|
||
import 'dart:convert';
|
||
import 'dart:io';
|
||
import 'dart:math';
|
||
import 'package:flutter/foundation.dart';
|
||
import 'package:shared_preferences/shared_preferences.dart';
|
||
import 'package:web_socket_channel/web_socket_channel.dart';
|
||
import '../models/energy_data.dart'; // EnergyData, HistoryPoint, PowerBalanceEntry
|
||
import '../models/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();
|
||
}
|
||
} |