etm-powersync-app/lib/services/installation_store.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

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));
}
}