feat(installateur): catalogue de things (lecture seule) + filtres

- nymea_models : NymeaVendor + NymeaThingClass étendu (vendorId, setupMethod, createMethods)
- nymea_service : getVendors() + getAllThingClasses() (typées, erreurs FR, guards sim/déco)
- thing_category : +simpleheatpump/smartgridheatpump -> hvac (interfaces réelles)
- thing_catalog_screen : liste groupée + 3 filtres combinables (fabricant/type/recherche) + détail read-only
- main.dart : /settings/system/things -> ThingCatalogScreen (remplace le stub)

Type dérivé des interfaces réelles (pas d'enum), fabricant résolu par vendorId (doublon ABB géré).
Aucun AddThing/Discovery/Pairing (lots B/C à venir).
Validé contre nymea réel (hems .75) : 6 vendors, 45 classes, 18 interfaces.
Réf. UI_DATA_CONTRACT.md §5.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Patrick Schurig 2026-06-28 14:00:12 +02:00
parent 3a535e05fa
commit b7a7213ee4
5 changed files with 763 additions and 1 deletions

View File

@ -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')),

View File

@ -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<String, dynamic> 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<NymeaActionType> actionTypes;
final List<Map<String, dynamic>> paramTypes;
final List<NymeaStateType> settingsTypes;
final String vendorId; // Vendor.id (filtre fabricant)
final String setupMethod; // enum SetupMethod (détail)
final List<String> 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<String, dynamic> j) => NymeaThingClass(
@ -227,6 +253,9 @@ class NymeaThingClass {
settingsTypes: (j['settingsTypes'] as List? ?? [])
.map((s) => NymeaStateType.fromJson(s as Map<String, dynamic>))
.toList(),
vendorId: j['vendorId'] as String? ?? '',
setupMethod: j['setupMethod'] as String? ?? '',
createMethods: List<String>.from(j['createMethods'] ?? const []),
);
/// Retourne la stateType par son id

View File

@ -66,6 +66,8 @@ const Map<String, ThingCategory> 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,

View File

@ -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<ThingCatalogScreen> createState() => _ThingCatalogScreenState();
}
class _ThingCatalogScreenState extends State<ThingCatalogScreen> {
bool _loading = true;
List<NymeaThingClass> _classes = const [];
Map<String, NymeaVendor> _vendorById = const {};
// Filtres (combinables).
String? _vendor; // displayName fabricant
ThingCategory? _category;
String _search = '';
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) => _load());
}
Future<void> _load() async {
if (!mounted) return;
setState(() => _loading = true);
final svc = context.read<NymeaService>();
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<String> 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<ThingCategory> get _categoryOptions {
final present = _classes.map((c) => c.category).toSet();
return ThingCategory.values.where(present.contains).toList();
}
List<NymeaThingClass> 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<InstallerModeProvider>().isUnlocked;
if (!installer) return const _LockedScaffold();
final filtered = _filtered;
// Regroupement par catégorie pour l'affichage.
final groups = <ThingCategory, List<NymeaThingClass>>{};
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<String> onSearch;
final String? vendor;
final List<String> vendors;
final ValueChanged<String?> onVendor;
final ThingCategory? category;
final List<ThingCategory> categories;
final ValueChanged<ThingCategory?> 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<String> vendors;
final ValueChanged<String?> 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<String?>(
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<String, dynamic> 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 = <String>[
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<Widget> 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', '');

View File

@ -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<List<NymeaVendor>> 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<String, dynamic>))
.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<List<NymeaThingClass>> 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<String, dynamic>))
.toList();
} catch (e) {
_log('GetAllThingClasses: $e', force: true);
return const [];
}
}
Future<void> _loadThings() async {
// Étape 1 : charger les things configurés
try {