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>
112 lines
3.8 KiB
Dart
112 lines
3.8 KiB
Dart
import 'dart:convert';
|
|
|
|
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
|
import 'package:shared_preferences/shared_preferences.dart';
|
|
|
|
import '../models/installation.dart';
|
|
|
|
/// Persistance des installations enregistrées, **indexée par Server UUID**.
|
|
///
|
|
/// Séparation des supports (décision verrouillée) :
|
|
/// • **métadonnées** (`uuid/name/host/port/lastSeen`) → SharedPreferences ;
|
|
/// • **token** (credential) → `flutter_secure_storage` (Keychain/Keystore),
|
|
/// clé `etm_token_<uuid>`.
|
|
///
|
|
/// Aucune connexion ici : c'est le magasin que pilotera le `ConnectionManager`
|
|
/// (Étape 2). Le piège mono→multi (réutiliser le token d'une autre box) est
|
|
/// évité par construction : le token est lu/écrit **par UUID**.
|
|
class InstallationStore {
|
|
static const _listKey = 'etm_installations_v1';
|
|
static const _tokenPrefix = 'etm_token_';
|
|
|
|
final FlutterSecureStorage _secure;
|
|
|
|
InstallationStore({FlutterSecureStorage? secureStorage})
|
|
: _secure = secureStorage ?? const FlutterSecureStorage();
|
|
|
|
final Map<String, Installation> _byUuid = {};
|
|
|
|
/// Installations connues, les plus récemment vues d'abord.
|
|
List<Installation> get installations {
|
|
final list = _byUuid.values.toList();
|
|
list.sort((a, b) {
|
|
final ad = a.lastSeen, bd = b.lastSeen;
|
|
if (ad == null && bd == null) return a.name.compareTo(b.name);
|
|
if (ad == null) return 1;
|
|
if (bd == null) return -1;
|
|
return bd.compareTo(ad);
|
|
});
|
|
return list;
|
|
}
|
|
|
|
Installation? byUuid(String uuid) => _byUuid[uuid];
|
|
|
|
/// Charge les métadonnées (prefs) puis hydrate chaque token (secure storage).
|
|
Future<void> load() async {
|
|
final prefs = await SharedPreferences.getInstance();
|
|
_byUuid.clear();
|
|
final raw = prefs.getString(_listKey);
|
|
if (raw == null) return;
|
|
|
|
final decoded = jsonDecode(raw);
|
|
if (decoded is! List) return;
|
|
for (final entry in decoded) {
|
|
if (entry is! Map) continue;
|
|
final meta = Installation.fromJson(Map<String, dynamic>.from(entry));
|
|
// Le token est un bonus : son indispo (web, plateforme sans secure
|
|
// storage) ne doit jamais empêcher le chargement des métadonnées.
|
|
final token = await _readToken(meta.uuid);
|
|
_byUuid[meta.uuid] = token == null ? meta : meta.copyWith(token: token);
|
|
}
|
|
}
|
|
|
|
/// Crée ou met à jour une installation (métadonnées + token si présent).
|
|
Future<void> upsert(Installation inst) async {
|
|
_byUuid[inst.uuid] = inst;
|
|
await _persistMeta();
|
|
if (inst.hasToken) {
|
|
await _writeToken(inst.uuid, inst.token!);
|
|
}
|
|
}
|
|
|
|
/// Retire une installation et **efface son token** du secure storage.
|
|
Future<void> remove(String uuid) async {
|
|
_byUuid.remove(uuid);
|
|
await _persistMeta();
|
|
await _deleteToken(uuid);
|
|
}
|
|
|
|
/// Token stocké pour cet UUID (lecture directe secure storage, tolérante).
|
|
Future<String?> tokenFor(String uuid) => _readToken(uuid);
|
|
|
|
// ── Accès secure storage tolérants aux pannes (async + faillible) ──────────
|
|
|
|
Future<String?> _readToken(String uuid) async {
|
|
try {
|
|
return await _secure.read(key: _tokenPrefix + uuid);
|
|
} catch (_) {
|
|
return null; // dégradation propre : pas de token, pas de crash
|
|
}
|
|
}
|
|
|
|
Future<void> _writeToken(String uuid, String token) async {
|
|
try {
|
|
await _secure.write(key: _tokenPrefix + uuid, value: token);
|
|
} catch (_) {
|
|
// best-effort : la session reste valide en mémoire pour cette exécution
|
|
}
|
|
}
|
|
|
|
Future<void> _deleteToken(String uuid) async {
|
|
try {
|
|
await _secure.delete(key: _tokenPrefix + uuid);
|
|
} catch (_) {/* idempotent */}
|
|
}
|
|
|
|
Future<void> _persistMeta() async {
|
|
final prefs = await SharedPreferences.getInstance();
|
|
final list = _byUuid.values.map((i) => i.toJson()).toList();
|
|
await prefs.setString(_listKey, jsonEncode(list));
|
|
}
|
|
}
|