etm-powersync-app/lib/services/connection_manager.dart
Patrick Schurig c638ec6c52 feat: connexions multi-HEMS + rôles & appareils (beta add/config)
Lot Connexions multi-HEMS :
- modèle Installation + InstallationStore (persistance par UUID ;
  métadonnées SharedPreferences, token via flutter_secure_storage)
- ConnectionManager au-dessus de nymea_service : mono-connexion, switchTo,
  connectNew, enterDemo (démo = active factice), contrôle d'identité DHCP
- nymea_service : connect() réveillé (ws://4444, token par UUID), capture
  Hello (uuid/name/initialSetupRequired), authenticate/createUser, resetState
- écran Installations (3 états) + déverrouillage installateur depuis la racine
- écran Connexion : auth 2 branches (JSONRPC.Authenticate / CreateUser),
  détection auto via initialSetupRequired, segmenteur de repli
- gate de routage (redirect + refreshListenable merge[cm,svc]) ;
  hasActiveConnection = isSimulation || (connected && authenticated)
- en-tête drawer cliquable → Installations
- invalidation des caches au switch (service.resetState + clearForSwitch
  rôles/scheduler/tariff), sans persist()

Lot Rôles & appareils :
- EnergySetupProvider refondu en tri-état (present/absent/non-configuré)
- écran roles_devices_screen (3 zones, drag-priorité, inférence solaire/batterie)
- LoadDescriptor (etmvariableload, rév.2 : PAC exclue → SG-Ready) ;
  Energy.SetRootMeter réel, Get/SetLoadConfig en stub loggé

Correctifs :
- bug signe énergie : production +, consommation NÉGATIVE sur nymea 1.15.2
  → consumptionW/home en .abs() (autoconso n'est plus clouée à 0)
- UUID Hello brace-wrapped normalisé

Tests : flutter analyze 0 erreur ; gate + zéro-fuite au switch (5/5 verts).
Validé en vrai : login auth .120, .75 ouvert, switch, déverrouillage installateur.
Note : embarque aussi le WIP dashboard/thème déjà présent dans l'arbre.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 08:39:08 +02:00

231 lines
7.9 KiB
Dart

import 'package:flutter/foundation.dart';
import '../models/installation.dart';
import 'installation_store.dart';
import 'nymea_service.dart';
/// Gère la **liste** d'installations et la **connexion active unique** au-dessus
/// de [NymeaService] (qui reste mono-connexion). Aucune socket parallèle : un
/// switch = déconnecter l'active proprement, puis connecter la cible.
///
/// La **démo** (`startSimulation`) compte comme une connexion active factice :
/// elle ne doit pas être renvoyée vers Installations par le gate (Étape 6).
class ConnectionManager extends ChangeNotifier {
ConnectionManager(this.service, this.store);
final NymeaService service;
final InstallationStore store;
Installation? _active;
String? _connectingToName;
String? _error;
/// Caches à vider au switch (providers rôles/scheduler/tariff). Enregistrés
/// depuis `main()`. Découplage par callbacks — pas d'import croisé.
final List<void Function()> _cacheClearers = [];
/// Enregistre un cache à vider lors d'un changement d'installation.
void registerCacheClear(void Function() clear) => _cacheClearers.add(clear);
Installation? get active => _active;
String? get connectingToName => _connectingToName;
bool get isTransitioning => _connectingToName != null;
String? get error => _error;
List<Installation> get installations => store.installations;
/// Vrai dès qu'une session **utilisable** existe — démo, ou box réelle
/// **connectée ET authentifiée**. Le transport seul (connecté mais auth en
/// attente) ne compte pas : sinon le gate laisserait passer un dashboard vide.
bool get hasActiveConnection =>
service.isSimulation || (service.connected && service.authenticated);
/// Transport up mais authentification requise → router vers l'écran Connexion.
bool get needsAuth =>
service.connected &&
service.authenticationRequired &&
!service.authenticated;
/// Charge la liste persistée (à appeler au démarrage, Étape 6).
Future<void> load() async {
await store.load();
notifyListeners();
}
/// Bascule vers une installation connue : déconnecte l'active, connecte la
/// cible avec son token (par UUID), contrôle l'identité, persiste.
Future<bool> switchTo(Installation target) async {
_error = null;
_connectingToName = target.name;
notifyListeners();
// 1. Fermer proprement l'active (WS, listeners, requêtes en attente).
service.disconnect();
// 2. Invalider les caches AVANT de connecter la cible — aucune donnée de
// l'ancienne box ne doit subsister (service + providers).
_clearCaches();
// 3. Token par UUID (secure storage, tolérant aux pannes).
final token = await store.tokenFor(target.uuid);
// 4. Connexion ws://host:port (pas de TLS).
final ok = await service.connect(
target.host,
target.port,
protocol: NymeaProtocol.webSocket,
token: token,
);
if (!ok) {
_connectingToName = null;
_error = service.connectionError ?? 'Connexion échouée';
notifyListeners();
return false;
}
// 5. Contrôle d'identité : en DHCP, l'IP attendue peut héberger une AUTRE
// box. On enregistre alors sous le vrai UUID, sans écraser la cible.
final realUuid = service.serverUuid;
if (realUuid.isNotEmpty && realUuid != target.uuid) {
_active = await _persistFromService(host: target.host, port: target.port);
_error = 'Identité différente à ${target.host} : '
'${_shortUuid(realUuid)}${_shortUuid(target.uuid)}';
_connectingToName = null;
notifyListeners();
return true; // transport up, mais sur une autre installation
}
// 6. Persister (host courant + lastSeen + token) sous l'UUID.
_active = await _persistFromService(
host: target.host,
port: target.port,
fallbackName: target.name,
);
_connectingToName = null;
notifyListeners();
return true;
}
/// Connecte une box **nouvelle** (ajout manuel ou découverte) : sans token.
/// Si la box est déjà authentifiée (ouverte), on persiste ; sinon l'UI route
/// vers l'écran Connexion (Étape 5) et l'enregistrement suit l'auth réussie.
Future<bool> connectNew({required String host, int port = 4444}) async {
_error = null;
_connectingToName = host;
notifyListeners();
service.disconnect();
_clearCaches();
final ok = await service.connect(host, port,
protocol: NymeaProtocol.webSocket);
if (!ok) {
_connectingToName = null;
_error = service.connectionError ?? 'Connexion échouée';
notifyListeners();
return false;
}
if (service.authenticated && service.serverUuid.isNotEmpty) {
_active = await _persistFromService(host: host, port: port);
}
_connectingToName = null;
notifyListeners();
return true;
}
/// Login sur la box déjà connectée (transport up, auth requise). Sur succès,
/// persiste l'installation **par UUID** (token dans le secure storage).
Future<bool> authenticate(String username, String password) async {
_error = null;
final ok = await service.authenticate(username, password);
if (ok) {
_active =
await _persistFromService(host: service.host, port: service.port);
} else {
_error = service.connectionError ?? 'Identifiants refusés';
}
notifyListeners();
return ok;
}
/// Crée l'admin d'une box neuve puis login (enchaîné côté service). Sur
/// succès, persiste par UUID. **Non vérifié en live** (pas de box vierge).
Future<bool> createAdmin(String username, String password,
{String? email}) async {
_error = null;
final ok = await service.createUser(username, password, email: email);
if (ok) {
_active =
await _persistFromService(host: service.host, port: service.port);
} else {
_error = service.connectionError ?? 'Création du compte refusée';
}
notifyListeners();
return ok;
}
/// Entre en mode démo (connexion active factice).
void enterDemo() {
// Vider d'abord : real→démo ne doit pas garder la config de la box réelle.
_clearCaches();
service.startSimulation();
_active = null;
_connectingToName = null;
_error = null;
notifyListeners();
}
/// Coupe la session active sans détruire l'enregistrement.
void disconnectActive() {
service.disconnect();
_active = null;
notifyListeners();
}
/// Supprime une installation (action installateur) + son token.
Future<void> removeInstallation(String uuid) async {
await store.remove(uuid);
if (_active?.uuid == uuid) {
service.disconnect();
_active = null;
}
notifyListeners();
}
// ── Interne ────────────────────────────────────────────────────────────────
/// Vide service + providers enregistrés — aucune donnée de la box précédente
/// ne subsiste.
void _clearCaches() {
service.resetState();
for (final clear in _cacheClearers) {
clear();
}
}
/// Construit + persiste l'installation à partir de l'identité Hello courante.
/// Si l'UUID est vide (cas anormal), on ne persiste pas mais on renvoie quand
/// même un enregistrement transitoire pour [active].
Future<Installation> _persistFromService({
required String host,
required int port,
String? fallbackName,
}) async {
final uuid = service.serverUuid;
final inst = Installation(
uuid: uuid,
name: service.serverName.isNotEmpty
? service.serverName
: (fallbackName ?? 'nymea'),
host: host,
port: port,
lastSeen: DateTime.now(),
token: service.token.isNotEmpty ? service.token : null,
);
if (uuid.isNotEmpty) await store.upsert(inst);
return inst;
}
String _shortUuid(String u) => u.length <= 8 ? u : '${u.substring(0, 8)}';
}