- supprime selfRate/autoRate de _parsePowerBalance, le getter autoconsommationW, les .abs() interim - ratios dérivés par Δ-de-cumuls (§2.1/§8.1) dans lib/services/energy_ratios.dart - reseed jour-roulant & Δ non-monotone ; gardes : dén≤0→null, pas de NaN, clamp [0,100] - selfConsumptionPower net-signé sans .abs() (§2.2) ; signes nymea bruts préservés (§7.1) - seam swappable vers un state plugin en Phase 2 (une seule fonction) Interim (Phase 1). Gestion du signe d'affichage renvoyée à la Phase 4 (§1.1). Réf. UI_DATA_CONTRACT.md rev.5. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1549 lines
61 KiB
Dart
1549 lines
61 KiB
Dart
import 'dart:async';
|
||
import 'dart:developer' as dev;
|
||
import 'dart:convert';
|
||
import 'dart:io';
|
||
import 'dart:math';
|
||
import 'package:flutter/foundation.dart';
|
||
import 'package:shared_preferences/shared_preferences.dart';
|
||
import 'package:web_socket_channel/web_socket_channel.dart';
|
||
import '../models/energy_data.dart'; // EnergyData, HistoryPoint, PowerBalanceEntry
|
||
import '../models/nymea_models.dart';
|
||
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;
|
||
Timer? _heartbeatTimer;
|
||
Timer? _simulationTimer;
|
||
bool _isSimulation = false;
|
||
|
||
// ── Auth ─────────────────────────────────────────────────────────────────────
|
||
String _token = '';
|
||
String _username = '';
|
||
String _password = '';
|
||
|
||
// ── 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;
|
||
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
|
||
final hello = await _sendRequest('JSONRPC.Hello', {})
|
||
.timeout(const Duration(seconds: 8));
|
||
|
||
return _finishConnect(hello);
|
||
}
|
||
|
||
// ── Finalisation commune ─────────────────────────────────────────────────────
|
||
Future<bool> _finishConnect(Map<String, dynamic> welcome) async {
|
||
final p = welcome['params'] as Map<String, dynamic>? ?? welcome;
|
||
_nymeaVersion = p['version'] as String?
|
||
?? p['serverVersion'] as String?
|
||
?? 'nymea';
|
||
|
||
// ── 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';
|
||
_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 = '';
|
||
_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;
|
||
}
|
||
}
|
||
|
||
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,
|
||
daySelfConsumptionWh: selfConsoKwh * 1000,
|
||
// n/a (seam → null) mappé à 0 pour le champ non-nullable (UX §2 item 4).
|
||
selfConsumptionRate: ratios.autoconsommation ?? 0.0,
|
||
autonomyRate: ratios.autonomie ?? 0.0,
|
||
dayGains: totalRetKwh * 0.13 + selfConsoKwh * 0.22, // estimation tarifaire
|
||
);
|
||
notifyListeners();
|
||
}
|
||
|
||
void _parseRootMeter(Map<String, dynamic> response) {
|
||
// Dans nymea 1.14, tout est dans PowerBalance — méthode conservée pour compat
|
||
}
|
||
|
||
Future<void> refreshEnergy() => _loadEnergyStatus();
|
||
|
||
/// Récupère les logs de bilan énergétique via Energy.GetPowerBalanceLogs.
|
||
///
|
||
/// [sampleRate] : "SampleRate15Mins" | "SampleRate1Hour" |
|
||
/// "SampleRate3Hours" | "SampleRate1Day" | "SampleRate1Week"
|
||
/// Timestamps envoyés en **secondes** (convention nymea Energy API).
|
||
Future<List<PowerBalanceEntry>> fetchPowerBalanceLogs({
|
||
required DateTime from,
|
||
required DateTime to,
|
||
String sampleRate = 'SampleRate15Mins',
|
||
}) async {
|
||
if (_isSimulation) return _simulatePowerBalance(from, to, sampleRate);
|
||
try {
|
||
final r = await _sendRequest('Energy.GetPowerBalanceLogs', {
|
||
'sampleRate': sampleRate,
|
||
'from': from.millisecondsSinceEpoch ~/ 1000,
|
||
'to': to.millisecondsSinceEpoch ~/ 1000,
|
||
});
|
||
final logs =
|
||
(r['params']?['powerBalanceLogEntries'] ?? r['powerBalanceLogEntries'])
|
||
as List? ?? [];
|
||
return logs.map<PowerBalanceEntry>((e) {
|
||
final m = e as Map<String, dynamic>;
|
||
double d(String k) => (m[k] as num?)?.toDouble() ?? 0.0;
|
||
return PowerBalanceEntry(
|
||
timestamp: DateTime.fromMillisecondsSinceEpoch(
|
||
(m['timestamp'] as num).toInt() * 1000),
|
||
// Signes nymea BRUTS — ne corriger aucun signe (§7.1). L'autoconso
|
||
// instantanée se dérive via la formule canonique du seam
|
||
// (EnergyRatiosInterim.selfConsumptionPower), jamais par .abs().
|
||
productionW: d('production'),
|
||
consumptionW: d('consumption'),
|
||
acquisitionW: d('acquisition'),
|
||
storageW: d('storage'),
|
||
totalProductionWh: d('totalProduction'),
|
||
totalConsumptionWh:d('totalConsumption'),
|
||
totalReturnWh: d('totalReturn'),
|
||
totalAcquisitionWh:d('totalAcquisition'),
|
||
);
|
||
}).toList();
|
||
} catch (e) {
|
||
_log('fetchPowerBalanceLogs: $e');
|
||
return [];
|
||
}
|
||
}
|
||
|
||
List<PowerBalanceEntry> _simulatePowerBalance(
|
||
DateTime from, DateTime to, String sampleRate) {
|
||
final entries = <PowerBalanceEntry>[];
|
||
const steps = 48;
|
||
final stepDur = to.difference(from) ~/ steps;
|
||
final stepHours = stepDur.inMinutes / 60.0;
|
||
double totalProd = 50000, totalConso = 80000,
|
||
totalRet = 20000, totalAcq = 50000;
|
||
|
||
for (int i = 0; i <= steps; i++) {
|
||
final t = from.add(stepDur * i);
|
||
// Courbe solaire en demi-sinus sur la journée (pic à midi)
|
||
final hFrac = (t.hour + t.minute / 60.0) / 24.0;
|
||
final prod = (2200 * sin(hFrac * pi)).clamp(0.0, 2200).toDouble();
|
||
final conso = 600 + 400 * sin(i * pi / 10 + 1.0);
|
||
final excess = prod - conso;
|
||
final storage = excess.clamp(-300.0, 300.0).toDouble();
|
||
final acq = (conso - prod - storage).clamp(0.0, double.infinity).toDouble();
|
||
final ret = (excess - storage).clamp(0.0, double.infinity).toDouble();
|
||
totalProd += prod * stepHours;
|
||
totalConso += conso * stepHours;
|
||
totalRet += ret * stepHours;
|
||
totalAcq += acq * stepHours;
|
||
entries.add(PowerBalanceEntry(
|
||
timestamp: t,
|
||
productionW: prod,
|
||
consumptionW: conso,
|
||
acquisitionW: acq,
|
||
storageW: storage,
|
||
totalProductionWh: totalProd,
|
||
totalConsumptionWh: totalConso,
|
||
totalReturnWh: totalRet,
|
||
totalAcquisitionWh: totalAcq,
|
||
));
|
||
}
|
||
return entries;
|
||
}
|
||
|
||
/// Rafraîchit things + classes + énergie
|
||
Future<void> refresh() async {
|
||
await Future.wait([_loadThings(), _loadEnergyStatus()]);
|
||
}
|
||
|
||
Future<void> _loadEnergyHistory() async {
|
||
try {
|
||
final now = DateTime.now();
|
||
final from = DateTime(now.year, now.month, now.day)
|
||
.millisecondsSinceEpoch ~/ 1000;
|
||
final to = now.millisecondsSinceEpoch ~/ 1000;
|
||
final r = await _sendRequest('Energy.GetEnergyLogs', {
|
||
'sampleRate': 'SampleRateHour',
|
||
'from': from,
|
||
'to': to,
|
||
});
|
||
final logs = r['params']?['logs'] as List? ?? [];
|
||
_historyPoints = logs.map((l) {
|
||
final lm = l as Map<String, dynamic>;
|
||
return HistoryPoint(
|
||
time: DateTime.fromMillisecondsSinceEpoch(
|
||
(lm['timestamp'] as int) * 1000),
|
||
pvWh: (lm['totalEnergyProduced'] as num?)?.toDouble() ?? 0,
|
||
homeWh: (lm['totalEnergyConsumed'] as num?)?.toDouble() ?? 0,
|
||
gridWh: (lm['totalEnergyReturned'] as num?)?.toDouble() ?? 0,
|
||
);
|
||
}).toList();
|
||
notifyListeners();
|
||
} catch (e) { _log('GetEnergyLogs: $e'); }
|
||
}
|
||
|
||
/// Returns the thingId of the first configured EV charger, or null if none.
|
||
String? _findEvChargerId() {
|
||
for (final thing in _things) {
|
||
NymeaThingClass? cls;
|
||
try {
|
||
cls = _thingClasses.firstWhere((c) => c.id == thing.thingClassId);
|
||
} catch (_) {
|
||
continue;
|
||
}
|
||
if (cls.interfaces.any((i) => i.toLowerCase() == 'evcharger')) {
|
||
return thing.id;
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/// Send EnergyPlugin.SetChargingInfo with full mode + optional deadline params.
|
||
///
|
||
/// [mode] : UI mode (pv → Eco, minPv → EcoWithMinCurrent, boost → Normal)
|
||
/// [deadline] : activate deadline variant (*WithTargetTime) — ignored for boost
|
||
/// [targetSoc] : target battery SOC % (1-100), required when deadline=true
|
||
/// [endTime] : desired arrival/completion time, required when deadline=true
|
||
Future<void> setChargingInfo({
|
||
required ChargingMode mode,
|
||
bool deadline = false,
|
||
int targetSoc = 80,
|
||
DateTime? endTime,
|
||
}) async {
|
||
// Map (mode, deadline) → API mode string + optional minCurrent
|
||
final String apiMode;
|
||
int? minCurrent;
|
||
|
||
if (mode == ChargingMode.boost) {
|
||
apiMode = 'Normal';
|
||
} else if (mode == ChargingMode.pv && !deadline) {
|
||
apiMode = 'Eco';
|
||
} else if (mode == ChargingMode.minPv && !deadline) {
|
||
apiMode = 'EcoWithMinCurrent';
|
||
minCurrent = 6;
|
||
} else if (mode == ChargingMode.pv && deadline) {
|
||
apiMode = 'EcoWithTargetTime';
|
||
} else {
|
||
// minPv + deadline
|
||
apiMode = 'EcoMinWithTargetTime';
|
||
minCurrent = 6;
|
||
}
|
||
|
||
if (_connected && !_isSimulation) {
|
||
final evChargerId = _findEvChargerId();
|
||
if (evChargerId != null) {
|
||
try {
|
||
final info = <String, dynamic>{
|
||
'evChargerId': evChargerId,
|
||
'mode': apiMode,
|
||
};
|
||
if (minCurrent != null) info['minCurrent'] = minCurrent;
|
||
if (deadline && mode != ChargingMode.boost) {
|
||
info['targetSoc'] = targetSoc;
|
||
info['endTime'] = endTime != null
|
||
? endTime.millisecondsSinceEpoch ~/ 1000
|
||
: null;
|
||
}
|
||
await _sendRequest('EnergyPlugin.SetChargingInfo', {'chargingInfo': info});
|
||
} catch (e) { _log('SetChargingInfo: $e'); }
|
||
} else {
|
||
_log('SetChargingInfo: no EV charger thing found');
|
||
}
|
||
}
|
||
_energyData = _energyData.copyWith(chargingMode: mode);
|
||
notifyListeners();
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
// CONFIG ÉNERGIE — rôles & charges pilotées (écran Rôles & appareils)
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
|
||
/// gridMeter → **réel** `Energy.SetRootMeter` (natif, testable contre `hems`).
|
||
Future<void> setRootMeter(String thingId) async {
|
||
if (_isSimulation || !_connected) {
|
||
_log('[RootMeter] sim/déconnecté — SetRootMeter ignoré ($thingId)',
|
||
force: true);
|
||
return;
|
||
}
|
||
try {
|
||
final r = await _sendRequest(
|
||
'Energy.SetRootMeter', {'rootMeterThingId': thingId});
|
||
final err = r['params']?['energyError'];
|
||
_log('[RootMeter] Energy.SetRootMeter($thingId) ← ${err ?? r['status']}',
|
||
force: true);
|
||
} catch (e) {
|
||
_log('[RootMeter] SetRootMeter: $e', force: true);
|
||
}
|
||
}
|
||
|
||
/// **STUB** — `NymeaEnergy.SetLoadConfig` n'existe pas encore côté plugin
|
||
/// (l'energymanager l'exposera plus tard). En attendant on logge **le JSON
|
||
/// exact qui partira**, sans rien casser ni bloquer.
|
||
Future<void> setLoadConfig(List<Map<String, dynamic>> descriptors) async {
|
||
_log('[LoadConfig STUB] NymeaEnergy.SetLoadConfig — '
|
||
'${descriptors.length} charge(s) etmvariableload', force: true);
|
||
for (final d in descriptors) {
|
||
final pretty = const JsonEncoder.withIndent(' ').convert(d);
|
||
_log('[LoadConfig STUB] LoadDescriptor:\n$pretty', force: true);
|
||
}
|
||
}
|
||
|
||
/// **STUB** — `NymeaEnergy.GetLoadConfig` absent côté plugin. Retourne vide,
|
||
/// ne bloque pas le démarrage.
|
||
Future<List<Map<String, dynamic>>> getLoadConfig() async {
|
||
_log('[LoadConfig STUB] NymeaEnergy.GetLoadConfig → [] '
|
||
'(méthode plugin absente)', force: true);
|
||
return const [];
|
||
}
|
||
|
||
/// Trace de routage (charges hors etmvariableload : PAC SG-Ready, batterie).
|
||
void logInfo(String msg) => _log(msg, force: true);
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
// THINGS / INTEGRATIONS
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
|
||
bool _thingsLoaded = false;
|
||
bool get thingsLoaded => _thingsLoaded;
|
||
|
||
Future<void> _loadThings() async {
|
||
// ── Étape 1 : charger les things configurés ──────────────────────────────
|
||
try {
|
||
_log('📦 GetThings → envoi...', force: true);
|
||
final r = await _sendRequest('Integrations.GetThings', {});
|
||
_log('📦 GetThings ← status=${r["status"]} keys=${r.keys.toList()}', force: true);
|
||
|
||
final thingsRaw = r['params']?['things']
|
||
?? r['result']?['things']
|
||
?? r['things'];
|
||
|
||
if (thingsRaw is List) {
|
||
_things = thingsRaw
|
||
.map((t) => NymeaThing.fromJson(t as Map<String, dynamic>))
|
||
.where((t) => t.id.isNotEmpty) // exclure entrées invalides
|
||
.toList();
|
||
_log('📦 ✅ ${_things.length} things configurés:', force: true);
|
||
for (final t in _things) {
|
||
_log(' 📱 "${t.name}" | classId=${t.thingClassId.substring(0, 8)}… | ${t.setupStatus}', force: true);
|
||
}
|
||
} else {
|
||
_log('📦 ⚠️ GetThings: clé "things" absente — keys=${r.keys.toList()}', force: true);
|
||
}
|
||
} catch (e, st) {
|
||
_log('📦 ❌ GetThings erreur: $e\n$st', force: true);
|
||
}
|
||
|
||
// ── Étape 2 : charger UNIQUEMENT les classes des things présents ──────────
|
||
// On filtre sur les thingClassIds réellement utilisés → évite les 80+ plugins
|
||
if (_things.isNotEmpty) {
|
||
try {
|
||
final neededClassIds = _things
|
||
.map((t) => t.thingClassId)
|
||
.where((id) => id.isNotEmpty)
|
||
.toSet()
|
||
.toList();
|
||
|
||
_log('📦 GetThingClasses pour ${neededClassIds.length} classes...', force: true);
|
||
|
||
// nymea supporte un filtre optionnel thingClassIds
|
||
final r = await _sendRequest('Integrations.GetThingClasses', {
|
||
'thingClassIds': neededClassIds,
|
||
});
|
||
|
||
final classesRaw = r['params']?['thingClasses']
|
||
?? r['result']?['thingClasses']
|
||
?? r['thingClasses'];
|
||
|
||
if (classesRaw is List && classesRaw.isNotEmpty) {
|
||
_thingClasses = classesRaw
|
||
.map((c) => NymeaThingClass.fromJson(c as Map<String, dynamic>))
|
||
.toList();
|
||
_log('📦 ✅ ThingClasses: ${_thingClasses.length}', force: true);
|
||
for (final c in _thingClasses) {
|
||
_log(' 📋 "${c.name}" | id=${c.id.substring(0, 8)}… | interfaces=${c.interfaces}', force: true);
|
||
}
|
||
} else {
|
||
// Fallback : nymea < 1.6 ne supporte peut-être pas le filtre → tout charger
|
||
_log('📦 Filtre non supporté, fallback GetThingClasses sans filtre...', force: true);
|
||
await _loadAllThingClassesFiltered(neededClassIds);
|
||
}
|
||
} catch (e) {
|
||
_log('📦 ❌ GetThingClasses erreur: $e', force: true);
|
||
// Fallback si le filtre cause une erreur
|
||
await _loadAllThingClassesFiltered(
|
||
_things.map((t) => t.thingClassId).toSet().toList()
|
||
);
|
||
}
|
||
}
|
||
|
||
_thingsLoaded = true;
|
||
// Initialise le SOC batterie depuis les états des things déjà chargés
|
||
_syncBatterySOCFromThings();
|
||
notifyListeners();
|
||
}
|
||
|
||
/// Fallback : charge toutes les classes puis filtre côté client
|
||
Future<void> _loadAllThingClassesFiltered(List<String> neededIds) async {
|
||
try {
|
||
final r = await _sendRequest('Integrations.GetThingClasses', {});
|
||
final classesRaw = r['params']?['thingClasses']
|
||
?? r['result']?['thingClasses']
|
||
?? r['thingClasses'];
|
||
|
||
if (classesRaw is List) {
|
||
final all = classesRaw
|
||
.map((c) => NymeaThingClass.fromJson(c as Map<String, dynamic>))
|
||
.toList();
|
||
// Garder seulement les classes dont on a besoin
|
||
_thingClasses = all
|
||
.where((c) => neededIds.contains(c.id))
|
||
.toList();
|
||
_log('📦 Fallback: ${all.length} classes totales → ${_thingClasses.length} utilisées', force: true);
|
||
for (final c in _thingClasses) {
|
||
_log(' 📋 "${c.name}" interfaces=${c.interfaces}', force: true);
|
||
}
|
||
}
|
||
} catch (e) {
|
||
_log('📦 ❌ Fallback GetThingClasses erreur: $e', force: true);
|
||
}
|
||
}
|
||
Future<void> refreshThings() => _loadThings();
|
||
|
||
Future<List<Map<String, dynamic>>> discoverThings(String thingClassId) async {
|
||
if (_isSimulation) return [];
|
||
try {
|
||
final r = await _sendRequest(
|
||
'Integrations.DiscoverThings', {'thingClassId': thingClassId});
|
||
return List<Map<String, dynamic>>.from(
|
||
r['params']?['thingDescriptors'] ?? []);
|
||
} catch (_) { return []; }
|
||
}
|
||
|
||
Future<bool> addThing({
|
||
required String thingClassId,
|
||
required String thingDescriptorId,
|
||
required String name,
|
||
}) async {
|
||
if (_isSimulation) return false;
|
||
try {
|
||
final r = await _sendRequest('Integrations.AddThing', {
|
||
'thingClassId': thingClassId,
|
||
'name': name,
|
||
'thingDescriptorId': thingDescriptorId,
|
||
});
|
||
if (r['status'] == 'success') { await _loadThings(); return true; }
|
||
return false;
|
||
} catch (_) { return false; }
|
||
}
|
||
|
||
Future<bool> removeThing(String thingId) async {
|
||
if (_isSimulation) return false;
|
||
try {
|
||
final r = await _sendRequest(
|
||
'Integrations.RemoveThing', {'thingId': thingId});
|
||
if (r['status'] == 'success') {
|
||
_things.removeWhere((t) => t.id == thingId);
|
||
notifyListeners();
|
||
return true;
|
||
}
|
||
return false;
|
||
} catch (_) { return false; }
|
||
}
|
||
|
||
Future<bool> renameThing(String thingId, String newName) async {
|
||
if (_isSimulation) return false;
|
||
try {
|
||
final r = await _sendRequest(
|
||
'Integrations.EditThing', {'thingId': thingId, 'name': newName});
|
||
if (r['status'] == 'success') {
|
||
final idx = _things.indexWhere((t) => t.id == thingId);
|
||
if (idx >= 0) {
|
||
_things[idx] = _things[idx].copyWith(name: newName);
|
||
notifyListeners();
|
||
}
|
||
return true;
|
||
}
|
||
return false;
|
||
} catch (_) { return false; }
|
||
}
|
||
|
||
Future<bool> setThingSettings(
|
||
String thingId, String settingTypeId, dynamic value) async {
|
||
if (_isSimulation) return true;
|
||
try {
|
||
final r = await _sendRequest('Integrations.SetThingSettings', {
|
||
'thingId': thingId,
|
||
'settings': [{'paramTypeId': settingTypeId, 'value': value}],
|
||
});
|
||
return r['params']?['thingError'] == 'ThingErrorNoError';
|
||
} catch (e) {
|
||
_log('SetThingSettings: $e');
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/// Exécute une action sur un thing.
|
||
/// Retourne un [NymeaActionResult] avec le thingError et un message humain.
|
||
Future<NymeaActionResult> executeAction({
|
||
required String thingId,
|
||
required String actionTypeId,
|
||
Map<String, dynamic> params = const {},
|
||
}) async {
|
||
if (_isSimulation) {
|
||
return const NymeaActionResult(success: true);
|
||
}
|
||
try {
|
||
final r = await _sendRequest('Integrations.ExecuteAction', {
|
||
'thingId': thingId,
|
||
'actionTypeId': actionTypeId,
|
||
// Format nymea : liste [{paramTypeId, value}]
|
||
'params': params.entries
|
||
.map((e) => {'paramTypeId': e.key, 'value': e.value})
|
||
.toList(),
|
||
});
|
||
final thingError =
|
||
r['params']?['thingError'] as String? ?? 'ThingErrorNoError';
|
||
final displayMessage = r['params']?['displayMessage'] as String?;
|
||
final success = r['status'] == 'success' &&
|
||
(thingError.isEmpty || thingError == 'ThingErrorNoError');
|
||
return NymeaActionResult(
|
||
success: success,
|
||
thingError: thingError,
|
||
displayMessage: displayMessage,
|
||
);
|
||
} catch (e) {
|
||
_log('ExecuteAction: $e');
|
||
return NymeaActionResult(
|
||
success: false,
|
||
thingError: 'ThingErrorHardwareNotAvailable',
|
||
displayMessage: e.toString(),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// Modifie directement la valeur d'un état writable via Integrations.SetStateValue.
|
||
/// Certains integrateurs nymea supportent cette méthode (nymea ≥ 1.10).
|
||
Future<bool> setStateValue(
|
||
String thingId, String stateTypeId, dynamic value) async {
|
||
if (_isSimulation) {
|
||
// Mise à jour locale pour la simulation
|
||
final idx = _things.indexWhere((t) => t.id == thingId);
|
||
if (idx >= 0) {
|
||
final states = List<NymeaStateValue>.from(_things[idx].states);
|
||
final si = states.indexWhere((s) => s.stateTypeId == stateTypeId);
|
||
if (si >= 0) {
|
||
states[si] = NymeaStateValue(stateTypeId: stateTypeId, value: value);
|
||
} else {
|
||
states.add(NymeaStateValue(stateTypeId: stateTypeId, value: value));
|
||
}
|
||
_things[idx] = _things[idx].copyWith(states: states);
|
||
notifyListeners();
|
||
}
|
||
return true;
|
||
}
|
||
try {
|
||
final r = await _sendRequest('Integrations.SetStateValue', {
|
||
'thingId': thingId,
|
||
'stateTypeId': stateTypeId,
|
||
'value': value,
|
||
});
|
||
final thingError =
|
||
r['params']?['thingError'] as String? ?? 'ThingErrorNoError';
|
||
return r['status'] == 'success' &&
|
||
(thingError.isEmpty || thingError == 'ThingErrorNoError');
|
||
} catch (e) {
|
||
_log('SetStateValue: $e');
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/// Récupère l'historique d'un état via Logging.GetLogEntries.
|
||
///
|
||
/// [stateTypeName] = nom de l'état (ex: "currentPower", "temperature").
|
||
/// [sampleRate] = "SampleRate1Min" | "SampleRate15Mins" | "SampleRate1Hour" |
|
||
/// "SampleRate3Hours" | "SampleRate1Day".
|
||
/// Timestamps [from] et [to] convertis en ms côté protocole.
|
||
Future<List<HistoryEntry>> fetchHistory({
|
||
required String thingId,
|
||
required String stateTypeName,
|
||
required DateTime from,
|
||
required DateTime to,
|
||
String sampleRate = 'SampleRate15Mins',
|
||
}) async {
|
||
if (_isSimulation) {
|
||
// SOC simulé : charge dans la journée (solaire) puis décharge le soir.
|
||
// Valeurs en % (0-100) pour être normalisables côté graphe.
|
||
final entries = <HistoryEntry>[];
|
||
const steps = 48;
|
||
final step = to.difference(from) ~/ steps;
|
||
for (int i = 0; i <= steps; i++) {
|
||
final t = from.add(step * i);
|
||
final hour = t.hour + t.minute / 60.0;
|
||
// Montée solaire 8h-15h → 20% → 85%, descente soir → 20%
|
||
final double soc;
|
||
if (hour < 8) {
|
||
soc = 20 + 5 * (hour / 8);
|
||
} else if (hour < 15) {
|
||
soc = 25 + 60 * ((hour - 8) / 7);
|
||
} else {
|
||
soc = 85 - 65 * ((hour - 15) / 9);
|
||
}
|
||
entries.add(HistoryEntry(timestamp: t, value: soc.clamp(5.0, 95.0)));
|
||
}
|
||
return entries;
|
||
}
|
||
try {
|
||
// Source nymea : "state-{thingId}-{stateTypeName}"
|
||
final source = 'state-$thingId-$stateTypeName';
|
||
_log('📊 fetchHistory: source=$source sampleRate=$sampleRate', force: true);
|
||
final r = await _sendRequest('Logging.GetLogEntries', {
|
||
'sources': [source],
|
||
'startTime': from.millisecondsSinceEpoch,
|
||
'endTime': to.millisecondsSinceEpoch,
|
||
'sampleRate': sampleRate,
|
||
'sortOrder': 'Qt::AscendingOrder',
|
||
});
|
||
final logEntries =
|
||
(r['params']?['logEntries'] ?? r['logEntries']) as List? ?? [];
|
||
_log('📊 fetchHistory: ${logEntries.length} entrées reçues pour $source', force: true);
|
||
if (logEntries.isNotEmpty) {
|
||
// Log le premier entry pour vérifier le format
|
||
_log('📊 fetchHistory: exemple entry[0] = ${logEntries.first}', force: true);
|
||
}
|
||
final entries = logEntries.map<HistoryEntry>((e) {
|
||
final ts = DateTime.fromMillisecondsSinceEpoch(
|
||
(e['timestamp'] as num).toInt());
|
||
final values = (e['values'] as Map?)?.cast<String, dynamic>() ?? {};
|
||
// Essaie d'abord le nom de l'état, puis toutes les clés disponibles
|
||
num? raw = values[stateTypeName] as num?;
|
||
if (raw == null && values.isNotEmpty) {
|
||
final nums = values.values.whereType<num>();
|
||
if (nums.isNotEmpty) {
|
||
raw = nums.first;
|
||
_log('📊 fetchHistory: clé "$stateTypeName" absente, fallback sur première valeur num (clés: ${values.keys.toList()})', force: true);
|
||
}
|
||
}
|
||
final v = raw?.toDouble() ?? 0.0;
|
||
return HistoryEntry(timestamp: ts, value: v);
|
||
}).toList();
|
||
return entries;
|
||
} catch (e) {
|
||
_log('fetchHistory: $e', force: true);
|
||
return [];
|
||
}
|
||
}
|
||
|
||
Future<dynamic> getStateValue(String thingId, String stateTypeId) async {
|
||
if (_isSimulation) return null;
|
||
try {
|
||
final r = await _sendRequest('Integrations.GetStateValue', {
|
||
'thingId': thingId, 'stateTypeId': stateTypeId,
|
||
});
|
||
return r['params']?['value'];
|
||
} catch (_) { return null; }
|
||
}
|
||
|
||
Future<List<Map<String, dynamic>>> getRules() async {
|
||
if (_isSimulation) return [];
|
||
try {
|
||
final r = await _sendRequest('Rules.GetRules', {});
|
||
return List<Map<String, dynamic>>.from(r['params']?['rules'] ?? []);
|
||
} catch (_) { return []; }
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
// FAVORITES
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
|
||
void addFavorite(FavoriteWidget widget) {
|
||
if (!_favoriteWidgets.any((f) => f.id == widget.id)) {
|
||
_favoriteWidgets.add(widget);
|
||
notifyListeners();
|
||
_saveFavorites();
|
||
}
|
||
}
|
||
|
||
void removeFavorite(String id) {
|
||
_favoriteWidgets.removeWhere((f) => f.id == id);
|
||
notifyListeners();
|
||
_saveFavorites();
|
||
}
|
||
|
||
void reorderFavorites(int oldIndex, int newIndex) {
|
||
if (newIndex > oldIndex) newIndex--;
|
||
final item = _favoriteWidgets.removeAt(oldIndex);
|
||
_favoriteWidgets.insert(newIndex, item);
|
||
notifyListeners();
|
||
_saveFavorites();
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
// SIMULATION
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
|
||
void startSimulation() {
|
||
disconnect();
|
||
_connected = true;
|
||
_isSimulation = true;
|
||
_nymeaVersion = 'demo 1.8.2';
|
||
_generateSimulatedThings();
|
||
_generateHistory();
|
||
if (_favoriteWidgets.isEmpty) {
|
||
_favoriteWidgets = [
|
||
FavoriteWidget(id: 'fw_pv', type: FavoriteType.pvPower, title: 'Production PV'),
|
||
FavoriteWidget(id: 'fw_home', type: FavoriteType.homePower, title: 'Consommation'),
|
||
FavoriteWidget(id: 'fw_soc', type: FavoriteType.batterySOC, title: 'Batterie'),
|
||
FavoriteWidget(id: 'fw_rates', type: FavoriteType.rates, title: 'Taux'),
|
||
FavoriteWidget(id: 'fw_ev', type: FavoriteType.evCharger, title: 'Borne EV'),
|
||
];
|
||
}
|
||
_simulationTimer?.cancel();
|
||
_simulationTimer = Timer.periodic(
|
||
const Duration(seconds: 2), (_) => _simulateData());
|
||
_simulateData();
|
||
notifyListeners();
|
||
}
|
||
|
||
void _simulateData() {
|
||
final now = DateTime.now();
|
||
final hour = now.hour + now.minute / 60.0;
|
||
final rng = Random();
|
||
double pv = 0;
|
||
if (hour >= 7 && hour <= 20) {
|
||
pv = (3800 * sin((hour - 7) * pi / 13) + rng.nextDouble() * 80 - 40)
|
||
.clamp(0, 4500);
|
||
}
|
||
final home = 200 + rng.nextDouble() * 600;
|
||
final battery = pv > home + 200 ? (pv - home - 200).clamp(0.0, 2000.0) : 0.0;
|
||
final batDischarge = home > pv + 100 && _energyData.batterySOC > 10
|
||
? min(home - pv, 1500.0) : 0.0;
|
||
final grid = home - pv - battery + batDischarge;
|
||
final soc = (_energyData.batterySOC +
|
||
(battery > 0 ? 0.1 : batDischarge > 0 ? -0.05 : 0)).clamp(5.0, 100.0);
|
||
_energyData = _energyData.copyWith(
|
||
pvPower: pv, homePower: home,
|
||
batteryPower: battery > 0 ? battery : (batDischarge > 0 ? -batDischarge : 0),
|
||
gridPower: grid, batterySOC: soc, temperature: 7,
|
||
dayProductionWh: pv * 0.5, daySelfConsumptionWh: home * 0.4,
|
||
dayGridInjectionWh: grid < 0 ? -grid * 0.3 : 0,
|
||
selfConsumptionRate: pv > 0 ? (min(pv, home) / pv * 100).clamp(0.0, 100.0) : 0.0,
|
||
autonomyRate: home > 0 ? ((pv + batDischarge) / home * 100).clamp(0.0, 100.0) : 0.0,
|
||
dayGains: (pv * 0.5 / 1000) * 0.13,
|
||
);
|
||
notifyListeners();
|
||
}
|
||
|
||
void _generateHistory() {
|
||
final now = DateTime.now();
|
||
_historyPoints = List.generate(24, (i) {
|
||
final h = i.toDouble();
|
||
final pv = (h >= 7 && h <= 20)
|
||
? (3000 * sin((h - 7) * pi / 13)).clamp(0.0, 4500.0) : 0.0;
|
||
return HistoryPoint(
|
||
time: DateTime(now.year, now.month, now.day, i),
|
||
pvWh: pv, homeWh: 200 + Random().nextDouble() * 400,
|
||
gridWh: (200 - pv * 0.3).clamp(0, 500),
|
||
);
|
||
});
|
||
}
|
||
|
||
void _generateSimulatedThings() {
|
||
_things = [
|
||
NymeaThing(id: 'sim-inverter', name: 'Onduleur SolarEdge', thingClassId: 'solaredge',
|
||
setupStatus: 'ThingSetupStatusComplete', paramValues: [],
|
||
states: [NymeaStateValue(stateTypeId: 'power', value: 4200.0)]),
|
||
NymeaThing(id: 'sim-meter', name: 'Compteur Linky', thingClassId: 'linky',
|
||
setupStatus: 'ThingSetupStatusComplete', paramValues: [],
|
||
states: [NymeaStateValue(stateTypeId: 'power', value: 180.0)]),
|
||
NymeaThing(id: 'sim-battery', name: 'Batterie BYD', thingClassId: 'byd',
|
||
setupStatus: 'ThingSetupStatusComplete', paramValues: [],
|
||
states: [NymeaStateValue(stateTypeId: 'soc', value: 42.0)]),
|
||
NymeaThing(id: 'sim-ev', name: 'Borne Wallbox', thingClassId: 'wallbox',
|
||
setupStatus: 'ThingSetupStatusComplete', paramValues: [],
|
||
states: [NymeaStateValue(stateTypeId: 'power', value: 3600.0)]),
|
||
NymeaThing(id: 'sim-pac', name: 'PAC Atlantic', thingClassId: 'atlantic-pac',
|
||
setupStatus: 'ThingSetupStatusComplete', paramValues: [],
|
||
states: [NymeaStateValue(stateTypeId: 'sgReadyState', value: 'normal')]),
|
||
NymeaThing(id: 'sim-dhw', name: 'Chauffe-eau Atlantic', thingClassId: 'atlantic-dhw',
|
||
setupStatus: 'ThingSetupStatusComplete', paramValues: [],
|
||
states: [NymeaStateValue(stateTypeId: 'power', value: false)]),
|
||
NymeaThing(id: 'sim-ac', name: 'Climatiseur Salon', thingClassId: 'mitsubishi',
|
||
setupStatus: 'ThingSetupStatusComplete', paramValues: [],
|
||
states: [NymeaStateValue(stateTypeId: 'power', value: false)]),
|
||
];
|
||
|
||
// Classes simulées avec leurs interfaces — nécessaires pour le filtrage de rôles EMS
|
||
_thingClasses = [
|
||
const NymeaThingClass(
|
||
id: 'solaredge', name: 'solaredge', displayName: 'SolarEdge',
|
||
interfaces: ['solarinverter', 'inverter', 'energymeter'],
|
||
),
|
||
const NymeaThingClass(
|
||
id: 'linky', name: 'linky', displayName: 'Linky',
|
||
interfaces: ['energymeter', 'smartmeter', 'meter'],
|
||
),
|
||
const NymeaThingClass(
|
||
id: 'byd', name: 'byd', displayName: 'BYD Battery',
|
||
interfaces: ['battery', 'energystorage', 'batterymonitor'],
|
||
),
|
||
const NymeaThingClass(
|
||
id: 'wallbox', name: 'wallbox', displayName: 'Wallbox',
|
||
interfaces: ['evcharger'],
|
||
),
|
||
const NymeaThingClass(
|
||
id: 'atlantic-pac', name: 'atlantic-pac', displayName: 'PAC Atlantic',
|
||
interfaces: ['sgready', 'heatpump'],
|
||
),
|
||
const NymeaThingClass(
|
||
id: 'atlantic-dhw', name: 'atlantic-dhw', displayName: 'Chauffe-eau',
|
||
interfaces: ['simpleheatpump', 'smartplug'],
|
||
),
|
||
const NymeaThingClass(
|
||
id: 'mitsubishi', name: 'mitsubishi', displayName: 'Mitsubishi AC',
|
||
interfaces: ['airconditioning'],
|
||
),
|
||
];
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_heartbeatTimer?.cancel();
|
||
_simulationTimer?.cancel();
|
||
_socket?.destroy();
|
||
_wsSub?.cancel();
|
||
_wsChannel?.sink.close();
|
||
super.dispose();
|
||
}
|
||
} |