diff --git a/lib/main.dart b/lib/main.dart index 0a615b3..2cfdfcb 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -23,6 +23,7 @@ import 'screens/installations/connection_screen.dart'; import 'screens/installations/installations_screen.dart'; import 'screens/settings/app_settings_screen.dart'; import 'screens/settings/protocols_screen.dart'; +import 'screens/settings/thing_catalog_screen.dart'; import 'screens/settings/appearance_screen.dart'; import 'screens/settings/screens_settings_screen.dart'; import 'screens/things_screen.dart'; @@ -127,7 +128,7 @@ GoRouter buildAppRouter(ConnectionManager cm, NymeaService svc) => GoRouter( ), // Routes installateur stubées GoRoute(path: '/settings/system', builder: (c, s) => _StubScreen('Configuration système')), - GoRoute(path: '/settings/system/things', builder: (c, s) => _StubScreen('Configuration Things')), + GoRoute(path: '/settings/system/things', builder: (c, s) => const ThingCatalogScreen()), GoRoute(path: '/settings/system/network', builder: (c, s) => _StubScreen('Système & réseau')), GoRoute(path: '/settings/system/protocols',builder: (c, s) => const ProtocolsScreen()), GoRoute(path: '/settings/system/mqtt', builder: (c, s) => _StubScreen('MQTT / Web server')), diff --git a/lib/models/nymea_models.dart b/lib/models/nymea_models.dart index cd7602f..3a4ce47 100644 --- a/lib/models/nymea_models.dart +++ b/lib/models/nymea_models.dart @@ -191,6 +191,26 @@ class NymeaActionType { ); } +/// Fabricant (`Integrations.GetVendors` → `Vendor`). Plusieurs vendors peuvent +/// partager un `displayName` (ex. deux « ABB ») → identité = [id]. +class NymeaVendor { + final String id; + final String name; + final String displayName; + + const NymeaVendor({ + required this.id, + required this.name, + required this.displayName, + }); + + factory NymeaVendor.fromJson(Map j) => NymeaVendor( + id: j['id'] as String? ?? '', + name: j['name'] as String? ?? '', + displayName: j['displayName'] as String? ?? '', + ); +} + class NymeaThingClass { final String id; final String name; @@ -200,6 +220,9 @@ class NymeaThingClass { final List actionTypes; final List> paramTypes; final List settingsTypes; + final String vendorId; // Vendor.id (filtre fabricant) + final String setupMethod; // enum SetupMethod (détail) + final List createMethods; // enum CreateMethods (détail) const NymeaThingClass({ required this.id, @@ -210,6 +233,9 @@ class NymeaThingClass { this.actionTypes = const [], this.paramTypes = const [], this.settingsTypes = const [], + this.vendorId = '', + this.setupMethod = '', + this.createMethods = const [], }); factory NymeaThingClass.fromJson(Map j) => NymeaThingClass( @@ -227,6 +253,9 @@ class NymeaThingClass { settingsTypes: (j['settingsTypes'] as List? ?? []) .map((s) => NymeaStateType.fromJson(s as Map)) .toList(), + vendorId: j['vendorId'] as String? ?? '', + setupMethod: j['setupMethod'] as String? ?? '', + createMethods: List.from(j['createMethods'] ?? const []), ); /// Retourne la stateType par son id diff --git a/lib/models/thing_category.dart b/lib/models/thing_category.dart index 840b483..9bad398 100644 --- a/lib/models/thing_category.dart +++ b/lib/models/thing_category.dart @@ -66,6 +66,8 @@ const Map interfaceToCategoryMap = { 'airconditioner': ThingCategory.hvac, 'pump': ThingCategory.hvac, 'heatpump': ThingCategory.hvac, + 'simpleheatpump': ThingCategory.hvac, // interfaces réelles (hems) + 'smartgridheatpump': ThingCategory.hvac, 'ventilation': ThingCategory.hvac, 'boiler': ThingCategory.hvac, 'light': ThingCategory.lighting, diff --git a/lib/screens/settings/thing_catalog_screen.dart b/lib/screens/settings/thing_catalog_screen.dart new file mode 100644 index 0000000..0f8dd49 --- /dev/null +++ b/lib/screens/settings/thing_catalog_screen.dart @@ -0,0 +1,698 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../../models/nymea_models.dart'; +import '../../models/thing_category.dart'; +import '../../providers/installer_mode_provider.dart'; +import '../../services/nymea_service.dart'; +import '../../theme/etm_tokens.dart'; + +/// Écran **Ajouter un thing** (LECTURE SEULE) — parcourir les thing classes que +/// CE nymea connaît déjà (`GetThingClasses` + `GetVendors`), avec filtres. +/// +/// Aucun `AddThing`/`DiscoverThings`/`PairThing` ici (lots B/C). Catégories de +/// type **dérivées des interfaces réelles** (thing_category.dart), pas d'enum +/// inventé. Filtrage 100 % app-side sur les données déjà chargées. +class ThingCatalogScreen extends StatefulWidget { + const ThingCatalogScreen({super.key}); + + @override + State createState() => _ThingCatalogScreenState(); +} + +class _ThingCatalogScreenState extends State { + bool _loading = true; + List _classes = const []; + Map _vendorById = const {}; + + // Filtres (combinables). + String? _vendor; // displayName fabricant + ThingCategory? _category; + String _search = ''; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) => _load()); + } + + Future _load() async { + if (!mounted) return; + setState(() => _loading = true); + final svc = context.read(); + final classes = await svc.getAllThingClasses(); + final vendors = await svc.getVendors(); + if (!mounted) return; + setState(() { + _classes = classes; + _vendorById = {for (final v in vendors) v.id: v}; + _loading = false; + }); + } + + String _vendorName(NymeaThingClass c) => + _vendorById[c.vendorId]?.displayName ?? '(inconnu)'; + + /// Fabricants réellement présents (dédupliqués par displayName). + List get _vendorOptions { + final set = _classes.map(_vendorName).toSet().toList()..sort(); + return set; + } + + /// Catégories réellement présentes, dans l'ordre de l'enum. + List get _categoryOptions { + final present = _classes.map((c) => c.category).toSet(); + return ThingCategory.values.where(present.contains).toList(); + } + + List get _filtered { + final q = _norm(_search); + return _classes.where((c) { + if (_vendor != null && _vendorName(c) != _vendor) return false; + if (_category != null && c.category != _category) return false; + if (q.isNotEmpty && !_norm(c.displayName).contains(q)) return false; + return true; + }).toList() + ..sort((a, b) => a.displayName.compareTo(b.displayName)); + } + + @override + Widget build(BuildContext context) { + final installer = context.watch().isUnlocked; + if (!installer) return const _LockedScaffold(); + + final filtered = _filtered; + // Regroupement par catégorie pour l'affichage. + final groups = >{}; + for (final c in filtered) { + groups.putIfAbsent(c.category, () => []).add(c); + } + final orderedCats = + ThingCategory.values.where(groups.containsKey).toList(); + + return Scaffold( + backgroundColor: EtmTokens.bgOf(context), + appBar: AppBar( + title: const Text('Ajouter un thing'), + backgroundColor: EtmTokens.surfaceOf(context), + foregroundColor: EtmTokens.inkOf(context), + elevation: 0, + actions: [ + IconButton( + tooltip: 'Rafraîchir', + icon: const Icon(Icons.refresh_rounded), + onPressed: _load, + ), + ], + ), + body: _loading + ? const Center(child: CircularProgressIndicator()) + : Column( + children: [ + _FilterBar( + search: _search, + onSearch: (v) => setState(() => _search = v), + vendor: _vendor, + vendors: _vendorOptions, + onVendor: (v) => setState(() => _vendor = v), + category: _category, + categories: _categoryOptions, + onCategory: (c) => setState(() => _category = c), + total: _classes.length, + shown: filtered.length, + ), + Expanded( + child: filtered.isEmpty + ? const _EmptyResults() + : ListView( + padding: const EdgeInsets.fromLTRB(16, 4, 16, 24), + children: [ + for (final cat in orderedCats) ...[ + _GroupHeader(categoryInfoMap[cat]!), + for (final c in groups[cat]!) + _ClassTile( + cls: c, + vendorName: _vendorName(c), + onTap: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => _ClassDetailScreen( + cls: c, vendorName: _vendorName(c)), + ), + ), + ), + const SizedBox(height: 8), + ], + ], + ), + ), + ], + ), + ); + } +} + +// ── Normalisation recherche (casse + accents) ─────────────────────────────── + +String _norm(String s) { + s = s.toLowerCase(); + const map = { + 'à': 'a', 'â': 'a', 'ä': 'a', 'é': 'e', 'è': 'e', 'ê': 'e', 'ë': 'e', + 'î': 'i', 'ï': 'i', 'ô': 'o', 'ö': 'o', 'ù': 'u', 'û': 'u', 'ü': 'u', + 'ç': 'c', + }; + map.forEach((k, v) => s = s.replaceAll(k, v)); + return s; +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Barre de filtres +// ═══════════════════════════════════════════════════════════════════════════ + +class _FilterBar extends StatelessWidget { + final String search; + final ValueChanged onSearch; + final String? vendor; + final List vendors; + final ValueChanged onVendor; + final ThingCategory? category; + final List categories; + final ValueChanged onCategory; + final int total; + final int shown; + + const _FilterBar({ + required this.search, + required this.onSearch, + required this.vendor, + required this.vendors, + required this.onVendor, + required this.category, + required this.categories, + required this.onCategory, + required this.total, + required this.shown, + }); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.fromLTRB(16, 10, 16, 8), + color: EtmTokens.surfaceOf(context), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: TextField( + onChanged: onSearch, + style: + EtmTokens.sans(size: 14, color: EtmTokens.inkOf(context)), + decoration: InputDecoration( + hintText: 'Rechercher un modèle…', + prefixIcon: Icon(Icons.search_rounded, + size: 20, color: EtmTokens.faintOf(context)), + isDense: true, + filled: true, + fillColor: EtmTokens.bg2Of(context), + contentPadding: const EdgeInsets.symmetric(vertical: 10), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(EtmTokens.radiusCtrl), + borderSide: BorderSide.none, + ), + ), + ), + ), + const SizedBox(width: 10), + _VendorDropdown( + vendor: vendor, vendors: vendors, onVendor: onVendor), + ], + ), + const SizedBox(height: 8), + SizedBox( + height: 34, + child: ListView( + scrollDirection: Axis.horizontal, + children: [ + _TypeChip( + label: 'Tous', + selected: category == null, + onTap: () => onCategory(null), + ), + for (final cat in categories) + _TypeChip( + label: categoryInfoMap[cat]!.label, + icon: categoryInfoMap[cat]!.icon, + selected: category == cat, + onTap: () => onCategory(category == cat ? null : cat), + ), + ], + ), + ), + const SizedBox(height: 4), + Text('$shown / $total modèles', + style: + EtmTokens.sans(size: 11, color: EtmTokens.faintOf(context))), + ], + ), + ); + } +} + +class _VendorDropdown extends StatelessWidget { + final String? vendor; + final List vendors; + final ValueChanged onVendor; + const _VendorDropdown( + {required this.vendor, required this.vendors, required this.onVendor}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10), + decoration: BoxDecoration( + color: EtmTokens.bg2Of(context), + borderRadius: BorderRadius.circular(EtmTokens.radiusCtrl), + ), + child: DropdownButtonHideUnderline( + child: DropdownButton( + value: vendor, + hint: Text('Fabricant', + style: + EtmTokens.sans(size: 13, color: EtmTokens.mutedOf(context))), + isDense: true, + items: [ + DropdownMenuItem(value: null, child: Text('Tous fabricants')), + for (final v in vendors) + DropdownMenuItem(value: v, child: Text(v)), + ], + onChanged: onVendor, + ), + ), + ); + } +} + +class _TypeChip extends StatelessWidget { + final String label; + final IconData? icon; + final bool selected; + final VoidCallback onTap; + const _TypeChip( + {required this.label, + this.icon, + required this.selected, + required this.onTap}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(right: 8), + child: GestureDetector( + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12), + alignment: Alignment.center, + decoration: BoxDecoration( + color: selected + ? EtmTokens.brand + : EtmTokens.bg2Of(context), + borderRadius: BorderRadius.circular(EtmTokens.radiusPill), + ), + child: Row( + children: [ + if (icon != null) ...[ + Icon(icon, + size: 14, + color: selected ? Colors.white : EtmTokens.mutedOf(context)), + const SizedBox(width: 5), + ], + Text(label, + style: EtmTokens.sans( + size: 12.5, + weight: FontWeight.w600, + color: selected + ? Colors.white + : EtmTokens.mutedOf(context))), + ], + ), + ), + ), + ); + } +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Liste +// ═══════════════════════════════════════════════════════════════════════════ + +class _GroupHeader extends StatelessWidget { + final ThingCategoryInfo info; + const _GroupHeader(this.info); + + @override + Widget build(BuildContext context) => Padding( + padding: const EdgeInsets.fromLTRB(2, 14, 0, 8), + child: Row( + children: [ + Icon(info.icon, size: 16, color: info.color), + const SizedBox(width: 8), + Text(info.label, + style: EtmTokens.sans( + size: 13, + weight: FontWeight.w700, + color: EtmTokens.inkOf(context))), + ], + ), + ); +} + +class _ClassTile extends StatelessWidget { + final NymeaThingClass cls; + final String vendorName; + final VoidCallback onTap; + const _ClassTile( + {required this.cls, required this.vendorName, required this.onTap}); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + behavior: HitTestBehavior.opaque, + child: Container( + margin: const EdgeInsets.only(bottom: 8), + padding: const EdgeInsets.all(13), + decoration: BoxDecoration( + color: EtmTokens.surfaceOf(context), + borderRadius: BorderRadius.circular(EtmTokens.radiusCard), + boxShadow: EtmTokens.cardShadowOf(context), + ), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(cls.displayName, + style: EtmTokens.sans( + size: 14, + weight: FontWeight.w700, + color: EtmTokens.inkOf(context))), + const SizedBox(height: 2), + Text(vendorName, + style: EtmTokens.sans( + size: 12, color: EtmTokens.mutedOf(context))), + ], + ), + ), + Icon(Icons.chevron_right_rounded, + color: EtmTokens.faintOf(context)), + ], + ), + ), + ); + } +} + +class _EmptyResults extends StatelessWidget { + const _EmptyResults(); + @override + Widget build(BuildContext context) => Center( + child: Padding( + padding: const EdgeInsets.all(28), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.search_off_rounded, + size: 36, color: EtmTokens.faintOf(context)), + const SizedBox(height: 12), + Text('Aucun modèle ne correspond', + style: EtmTokens.sans( + size: 14, color: EtmTokens.mutedOf(context))), + ], + ), + ), + ); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Détail (lecture seule) +// ═══════════════════════════════════════════════════════════════════════════ + +class _ClassDetailScreen extends StatelessWidget { + final NymeaThingClass cls; + final String vendorName; + const _ClassDetailScreen({required this.cls, required this.vendorName}); + + @override + Widget build(BuildContext context) { + final info = cls.categoryDetails; + return Scaffold( + backgroundColor: EtmTokens.bgOf(context), + appBar: AppBar( + title: Text(cls.displayName, overflow: TextOverflow.ellipsis), + backgroundColor: EtmTokens.surfaceOf(context), + foregroundColor: EtmTokens.inkOf(context), + elevation: 0, + ), + body: ListView( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 24), + children: [ + _Section('Identité', [ + _Row('Modèle', cls.displayName), + _Row('Fabricant', vendorName), + _Row('Catégorie', info.label), + _Row('Setup', _setupMethodLabel(cls.setupMethod)), + if (cls.createMethods.isNotEmpty) + _Row('Création', + cls.createMethods.map(_createMethodLabel).join(', ')), + ]), + const SizedBox(height: 14), + if (cls.interfaces.isNotEmpty) ...[ + _SectionTitle('Interfaces (${cls.interfaces.length})'), + const SizedBox(height: 8), + Wrap( + spacing: 6, + runSpacing: 6, + children: [for (final i in cls.interfaces) _Chip(i)], + ), + const SizedBox(height: 14), + ], + _SectionTitle('Paramètres requis (${cls.paramTypes.length})'), + const SizedBox(height: 8), + if (cls.paramTypes.isEmpty) + Text('Aucun paramètre.', + style: + EtmTokens.sans(size: 13, color: EtmTokens.mutedOf(context))) + else + for (final p in cls.paramTypes) _ParamTile(p), + const SizedBox(height: 20), + // Bouton Ajouter présent mais DÉSACTIVÉ (lot B). Rien câblé. + SizedBox( + width: double.infinity, + child: FilledButton( + style: FilledButton.styleFrom( + backgroundColor: EtmTokens.brand.withValues(alpha: 0.4), + disabledBackgroundColor: + EtmTokens.brand.withValues(alpha: 0.35), + padding: const EdgeInsets.symmetric(vertical: 14), + ), + onPressed: null, // lot B — pas de AddThing dans ce lot + child: Text('Ajouter · bientôt', + style: EtmTokens.sans( + size: 14, weight: FontWeight.w700, color: Colors.white)), + ), + ), + ], + ), + ); + } +} + +class _ParamTile extends StatelessWidget { + final Map p; + const _ParamTile(this.p); + + @override + Widget build(BuildContext context) { + final name = (p['displayName'] as String?)?.isNotEmpty == true + ? p['displayName'] as String + : (p['name'] as String? ?? '—'); + final type = (p['type'] as String?) ?? ''; + final unit = _unitLabel((p['unit'] as String?) ?? ''); + final def = p['defaultValue']; + final readOnly = p['readOnly'] == true; + + final meta = [ + if (type.isNotEmpty) type, + if (unit.isNotEmpty) unit, + if (def != null && '$def'.isNotEmpty && '$def' != 'null') + 'défaut $def', + if (readOnly) 'lecture seule', + ].join(' · '); + + return Container( + margin: const EdgeInsets.only(bottom: 8), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: EtmTokens.surfaceOf(context), + borderRadius: BorderRadius.circular(EtmTokens.radiusCtrl), + boxShadow: EtmTokens.cardShadowOf(context), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(name, + style: EtmTokens.sans( + size: 13.5, + weight: FontWeight.w600, + color: EtmTokens.inkOf(context))), + if (meta.isNotEmpty) ...[ + const SizedBox(height: 2), + Text(meta, + style: EtmTokens.mono( + size: 11.5, color: EtmTokens.mutedOf(context))), + ], + ], + ), + ); + } +} + +// ── Petits composants détail ──────────────────────────────────────────────── + +class _Section extends StatelessWidget { + final String title; + final List rows; + const _Section(this.title, this.rows); + @override + Widget build(BuildContext context) => Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: EtmTokens.surfaceOf(context), + borderRadius: BorderRadius.circular(EtmTokens.radiusCard), + boxShadow: EtmTokens.cardShadowOf(context), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, + style: EtmTokens.sans( + size: 12, + weight: FontWeight.w700, + color: EtmTokens.faintOf(context), + letterSpacing: 0.5)), + const SizedBox(height: 6), + ...rows, + ], + ), + ); +} + +class _SectionTitle extends StatelessWidget { + final String text; + const _SectionTitle(this.text); + @override + Widget build(BuildContext context) => Text(text, + style: EtmTokens.sans( + size: 13, + weight: FontWeight.w700, + color: EtmTokens.inkOf(context))); +} + +class _Row extends StatelessWidget { + final String k; + final String v; + const _Row(this.k, this.v); + @override + Widget build(BuildContext context) => Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 96, + child: Text(k, + style: EtmTokens.sans( + size: 12.5, color: EtmTokens.mutedOf(context))), + ), + Expanded( + child: Text(v, + style: EtmTokens.sans( + size: 13, + weight: FontWeight.w600, + color: EtmTokens.inkOf(context))), + ), + ], + ), + ); +} + +class _Chip extends StatelessWidget { + final String text; + const _Chip(this.text); + @override + Widget build(BuildContext context) => Container( + padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 5), + decoration: BoxDecoration( + color: EtmTokens.bg2Of(context), + borderRadius: BorderRadius.circular(8), + ), + child: Text(text, + style: EtmTokens.mono( + size: 11.5, color: EtmTokens.mutedOf(context))), + ); +} + +class _LockedScaffold extends StatelessWidget { + const _LockedScaffold(); + @override + Widget build(BuildContext context) => Scaffold( + backgroundColor: EtmTokens.bgOf(context), + appBar: AppBar( + title: const Text('Ajouter un thing'), + backgroundColor: EtmTokens.surfaceOf(context), + foregroundColor: EtmTokens.inkOf(context), + elevation: 0, + ), + body: Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.lock_outline_rounded, + size: 40, color: EtmTokens.faintOf(context)), + const SizedBox(height: 12), + Text('Réservé au mode installateur', + style: EtmTokens.sans( + size: 14, color: EtmTokens.mutedOf(context))), + ], + ), + ), + ), + ); +} + +// ── Libellés d'enums (lecture seule) ──────────────────────────────────────── + +String _setupMethodLabel(String v) => switch (v) { + 'SetupMethodJustAdd' => 'Ajout direct', + 'SetupMethodDisplayPin' => 'PIN affiché sur l\'appareil', + 'SetupMethodEnterPin' => 'Saisie d\'un PIN', + 'SetupMethodPushButton' => 'Bouton physique', + 'SetupMethodUserAndPassword' => 'Identifiant / mot de passe', + 'SetupMethodOAuth' => 'OAuth (web)', + '' => '—', + _ => v, + }; + +String _createMethodLabel(String v) => switch (v) { + 'CreateMethodJustAdd' => 'Ajout manuel', + 'CreateMethodDiscovery' => 'Découverte', + 'CreateMethodAuto' => 'Automatique', + _ => v, + }; + +String _unitLabel(String v) => + (v.isEmpty || v == 'UnitNone') ? '' : v.replaceFirst('Unit', ''); diff --git a/lib/services/nymea_service.dart b/lib/services/nymea_service.dart index ecd78b1..ef8d96e 100644 --- a/lib/services/nymea_service.dart +++ b/lib/services/nymea_service.dart @@ -1137,6 +1137,38 @@ class NymeaService extends ChangeNotifier { bool _thingsLoaded = false; bool get thingsLoaded => _thingsLoaded; + /// Catalogue (lecture seule) — fabricants connus de ce nymea (`GetVendors`). + Future> getVendors() async { + if (_isSimulation || !_connected) return const []; + try { + final r = await _sendRequest('Integrations.GetVendors', {}); + final raw = (r['params']?['vendors'] as List?) ?? const []; + return raw + .map((e) => NymeaVendor.fromJson(e as Map)) + .toList(); + } catch (e) { + _log('GetVendors: $e', force: true); + return const []; + } + } + + /// Catalogue (lecture seule) — **toutes** les thing classes connues de ce + /// nymea (`GetThingClasses` sans filtre). Indépendant de `_thingClasses` + /// (qui ne couvre que les classes des things configurés). + Future> getAllThingClasses() async { + if (_isSimulation || !_connected) return const []; + try { + final r = await _sendRequest('Integrations.GetThingClasses', {}); + final raw = (r['params']?['thingClasses'] as List?) ?? const []; + return raw + .map((e) => NymeaThingClass.fromJson(e as Map)) + .toList(); + } catch (e) { + _log('GetAllThingClasses: $e', force: true); + return const []; + } + } + Future _loadThings() async { // ── Étape 1 : charger les things configurés ────────────────────────────── try {