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>
231 lines
7.9 KiB
Dart
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)}…';
|
|
}
|