diff --git a/lib/models/nymea_models.dart b/lib/models/nymea_models.dart index 3a4ce47..455ec75 100644 --- a/lib/models/nymea_models.dart +++ b/lib/models/nymea_models.dart @@ -223,6 +223,7 @@ class NymeaThingClass { final String vendorId; // Vendor.id (filtre fabricant) final String setupMethod; // enum SetupMethod (détail) final List createMethods; // enum CreateMethods (détail) + final List> discoveryParamTypes; // params de DiscoverThings const NymeaThingClass({ required this.id, @@ -236,8 +237,15 @@ class NymeaThingClass { this.vendorId = '', this.setupMethod = '', this.createMethods = const [], + this.discoveryParamTypes = const [], }); + /// Ajoutable par **discovery direct** (lot C) : createMethod Discovery + setup + /// JustAdd (ni pairing OAuth/PushButton/UserAndPassword → lot D). + bool get isDiscoveryAddable => + createMethods.contains('CreateMethodDiscovery') && + setupMethod == 'SetupMethodJustAdd'; + factory NymeaThingClass.fromJson(Map j) => NymeaThingClass( id: j['id'] as String? ?? '', name: j['name'] as String? ?? '', @@ -256,6 +264,8 @@ class NymeaThingClass { vendorId: j['vendorId'] as String? ?? '', setupMethod: j['setupMethod'] as String? ?? '', createMethods: List.from(j['createMethods'] ?? const []), + discoveryParamTypes: + List>.from(j['discoveryParamTypes'] ?? const []), ); /// Retourne la stateType par son id @@ -339,3 +349,65 @@ class HistoryEntry { const HistoryEntry({required this.timestamp, required this.value}); } + +/// Résultat de discovery (`Integrations.DiscoverThings` → `ThingDescriptor`). +/// `id` = thingDescriptorId à passer à `AddThing`. `params` pré-remplis par le +/// plugin. `thingId` présent ⇒ thing **déjà ajouté** (ne pas ré-ajouter). +class ThingDescriptor { + final String id; + final String title; + final String description; + final String thingClassId; + final List> params; + final String? thingId; + + const ThingDescriptor({ + required this.id, + this.title = '', + this.description = '', + this.thingClassId = '', + this.params = const [], + this.thingId, + }); + + bool get alreadyAdded => thingId != null && thingId!.isNotEmpty; + + factory ThingDescriptor.fromJson(Map j) => ThingDescriptor( + id: j['id'] as String? ?? '', + title: j['title'] as String? ?? '', + description: j['description'] as String? ?? '', + thingClassId: j['thingClassId'] as String? ?? '', + params: List>.from(j['params'] ?? const []), + thingId: j['thingId'] as String?, + ); + + /// Valeur d'un param par son `paramTypeId` (pour résoudre slaveAddress/master). + dynamic paramValue(String paramTypeId) { + for (final p in params) { + if (p['paramTypeId'] == paramTypeId) return p['value']; + } + return null; + } +} + +/// Mappe un `ThingError` fil → message lisible (FR). Chaîne vide pour NoError. +String thingErrorMessage(String code) => switch (code) { + 'ThingErrorNoError' => '', + 'ThingErrorThingClassNotFound' => 'Type d\'appareil introuvable.', + 'ThingErrorThingDescriptorNotFound' => + 'Résultat de découverte expiré — relancez la recherche.', + 'ThingErrorMissingParameter' => 'Paramètre manquant.', + 'ThingErrorInvalidParameter' => 'Paramètre invalide.', + 'ThingErrorSetupFailed' => 'Échec de l\'installation de l\'appareil.', + 'ThingErrorDuplicateUuid' => 'Cet appareil est déjà ajouté.', + 'ThingErrorCreationMethodNotSupported' => + 'Ajout direct non supporté pour ce type.', + 'ThingErrorSetupMethodNotSupported' => + 'Cette méthode d\'installation n\'est pas supportée.', + 'ThingErrorHardwareNotAvailable' => + 'Matériel indisponible (bus/port hors-ligne ?).', + 'ThingErrorHardwareFailure' => 'Défaillance matérielle.', + 'ThingErrorAuthenticationFailure' => 'Échec d\'authentification.', + 'ThingErrorTimeout' => 'Délai dépassé — appareil injoignable.', + _ => 'Erreur : $code', + }; diff --git a/lib/screens/settings/thing_catalog_screen.dart b/lib/screens/settings/thing_catalog_screen.dart index 0f8dd49..b3c7286 100644 --- a/lib/screens/settings/thing_catalog_screen.dart +++ b/lib/screens/settings/thing_catalog_screen.dart @@ -6,6 +6,7 @@ import '../../models/thing_category.dart'; import '../../providers/installer_mode_provider.dart'; import '../../services/nymea_service.dart'; import '../../theme/etm_tokens.dart'; +import 'thing_discovery_screen.dart'; /// Écran **Ajouter un thing** (LECTURE SEULE) — parcourir les thing classes que /// CE nymea connaît déjà (`GetThingClasses` + `GetVendors`), avec filtres. @@ -487,22 +488,43 @@ class _ClassDetailScreen extends StatelessWidget { 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), + // Lot C : discovery+JustAdd → bouton actif « Découvrir ». Sinon (ajout + // direct lot B, ou pairing lot D) → désactivé « bientôt ». + if (cls.isDiscoveryAddable) + SizedBox( + width: double.infinity, + child: FilledButton.icon( + style: FilledButton.styleFrom( + backgroundColor: EtmTokens.brand, + padding: const EdgeInsets.symmetric(vertical: 14), + ), + onPressed: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => ThingDiscoveryScreen(cls: cls), + ), + ), + icon: const Icon(Icons.search_rounded, color: Colors.white), + label: Text('Découvrir des appareils', + style: EtmTokens.sans( + size: 14, weight: FontWeight.w700, color: Colors.white)), + ), + ) + else + 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, // ajout direct (lot B) / pairing (lot D) + child: Text('Ajouter · bientôt', + style: EtmTokens.sans( + size: 14, weight: FontWeight.w700, color: Colors.white)), ), - 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)), ), - ), ], ), ); diff --git a/lib/screens/settings/thing_discovery_screen.dart b/lib/screens/settings/thing_discovery_screen.dart new file mode 100644 index 0000000..aa3bdda --- /dev/null +++ b/lib/screens/settings/thing_discovery_screen.dart @@ -0,0 +1,458 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:provider/provider.dart'; + +import '../../models/nymea_models.dart'; +import '../../providers/installer_mode_provider.dart'; +import '../../services/nymea_service.dart'; +import '../../theme/etm_tokens.dart'; + +/// **Lot C** — ajout d'un thing par **DISCOVERY** (cible : Eastron SDM). +/// +/// Flux : form discoveryParams (SDM = `slaveAddress`, AUCUN sélecteur de master, +/// scan global) → `DiscoverThings` → liste des descriptors (title + adresse + +/// master résolu depuis les params) → `AddThing(thingDescriptorId)`. +/// +/// Discovery → `setupMethod JustAdd` uniquement (pas de pairing → lot D). +class ThingDiscoveryScreen extends StatefulWidget { + final NymeaThingClass cls; + const ThingDiscoveryScreen({super.key, required this.cls}); + + @override + State createState() => _ThingDiscoveryScreenState(); +} + +class _ThingDiscoveryScreenState extends State { + // discoveryParams : paramTypeId -> controller (rendu générique des + // discoveryParamTypes ; pour les SDM = un seul champ slaveAddress). + final Map _ctl = {}; + + bool _scanning = false; + bool _scanned = false; + String? _error; + List _descriptors = const []; + final Set _addedIds = {}; // descriptors ajoutés dans cette session + + // Résolution d'affichage : uuid master -> port série (depuis le lot Modbus). + Map _masterPort = const {}; + // paramTypeId -> name (depuis paramTypes de la classe) pour lire le descriptor. + late final Map _paramName = { + for (final p in widget.cls.paramTypes) + (p['id'] as String? ?? ''): (p['name'] as String? ?? ''), + }; + + @override + void initState() { + super.initState(); + for (final p in widget.cls.discoveryParamTypes) { + final id = p['id'] as String? ?? ''; + _ctl[id] = TextEditingController(text: '${p['defaultValue'] ?? ''}'); + } + WidgetsBinding.instance.addPostFrameCallback((_) async { + final masters = await context.read().getModbusRtuMasters(); + if (!mounted) return; + setState(() => _masterPort = {for (final m in masters) m.modbusUuid: m.serialPort}); + }); + } + + @override + void dispose() { + for (final c in _ctl.values) { + c.dispose(); + } + super.dispose(); + } + + // ── Discovery ────────────────────────────────────────────────────────────── + + Future _scan() async { + final params = >[]; + for (final p in widget.cls.discoveryParamTypes) { + final id = p['id'] as String? ?? ''; + final txt = _ctl[id]?.text.trim() ?? ''; + if (txt.isEmpty) continue; + final isInt = (p['type'] == 'Int' || p['type'] == 'Uint'); + params.add({'paramTypeId': id, 'value': isInt ? int.tryParse(txt) ?? 0 : txt}); + } + + setState(() { + _scanning = true; + _error = null; + }); + final res = + await context.read().discoverThings(widget.cls.id, params); + if (!mounted) return; + setState(() { + _scanning = false; + _scanned = true; + _error = res.error; + _descriptors = res.descriptors; + }); + } + + Future _add(ThingDescriptor d) async { + final name = await _askName(d); + if (name == null || !mounted) return; + final err = await context.read().addThingFromDescriptor( + thingClassId: widget.cls.id, + thingDescriptorId: d.id, + name: name, + ); + if (!mounted) return; + final messenger = ScaffoldMessenger.of(context); + if (err == null) { + setState(() => _addedIds.add(d.id)); + messenger.showSnackBar(SnackBar(content: Text('« $name » ajouté.'))); + } else { + messenger.showSnackBar( + SnackBar(content: Text(err), backgroundColor: EtmTokens.danger)); + } + } + + Future _askName(ThingDescriptor d) { + final ctl = TextEditingController( + text: d.title.isNotEmpty ? d.title : widget.cls.displayName); + return showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Nom de l\'appareil'), + content: TextField( + controller: ctl, + autofocus: true, + decoration: const InputDecoration(hintText: 'Nom'), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx), + child: const Text('Annuler')), + TextButton( + onPressed: () { + final n = ctl.text.trim(); + if (n.isNotEmpty) Navigator.pop(ctx, n); + }, + child: const Text('Ajouter'), + ), + ], + ), + ); + } + + // ── Résolution descriptor (⚠️ à revalider sur SDM réel : noms/forme params) ─ + + String _descMaster(ThingDescriptor d) { + for (final entry in _paramName.entries) { + final n = entry.value.toLowerCase(); + if (n.contains('master') || n.contains('modbus')) { + final uuid = '${d.paramValue(entry.key) ?? ''}'; + if (uuid.isEmpty) return '—'; + return _masterPort[uuid] ?? uuid; // port série si connu, sinon uuid + } + } + return '—'; + } + + String? _descSlave(ThingDescriptor d) { + for (final entry in _paramName.entries) { + if (entry.value.toLowerCase().contains('slave')) { + final v = d.paramValue(entry.key); + return v == null ? null : '$v'; + } + } + return null; + } + + @override + Widget build(BuildContext context) { + final installer = context.watch().isUnlocked; + if (!installer) return const _Locked(); + + return Scaffold( + backgroundColor: EtmTokens.bgOf(context), + appBar: AppBar( + title: Text('Découvrir · ${widget.cls.displayName}', + overflow: TextOverflow.ellipsis), + backgroundColor: EtmTokens.surfaceOf(context), + foregroundColor: EtmTokens.inkOf(context), + elevation: 0, + ), + body: ListView( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 32), + children: [ + // Form discovery (générique sur discoveryParamTypes). + for (final p in widget.cls.discoveryParamTypes) + _ParamField( + label: _paramLabel(p), + controller: _ctl[p['id'] as String? ?? '']!, + isInt: p['type'] == 'Int' || p['type'] == 'Uint', + ), + const SizedBox(height: 4), + Text( + 'Le scan interroge tous les masters RTU configurés (pas de sélection ' + 'de master). Le master répondant apparaît dans chaque résultat.', + style: EtmTokens.sans( + size: 11.5, color: EtmTokens.faintOf(context), height: 1.3), + ), + const SizedBox(height: 14), + SizedBox( + width: double.infinity, + child: FilledButton.icon( + style: FilledButton.styleFrom( + backgroundColor: EtmTokens.brand, + padding: const EdgeInsets.symmetric(vertical: 14), + ), + onPressed: _scanning ? null : _scan, + icon: _scanning + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, color: Colors.white)) + : const Icon(Icons.search_rounded, color: Colors.white), + label: Text(_scanning ? 'Recherche…' : 'Lancer la recherche', + style: EtmTokens.sans( + size: 14, weight: FontWeight.w700, color: Colors.white)), + ), + ), + const SizedBox(height: 18), + ..._results(), + ], + ), + ); + } + + List _results() { + if (!_scanned) return const []; + if (_error != null) { + return [_Banner(_error!, error: true)]; + } + if (_descriptors.isEmpty) { + // 0 descriptor + NoError = aucun appareil (distinct d'une erreur). + return [ + _Banner( + 'Aucun appareil trouvé. Vérifiez le câblage du bus, l\'adresse Modbus ' + 'et que le master RTU est connecté.', + ), + ]; + } + return [ + Text('${_descriptors.length} appareil(s) trouvé(s)', + style: EtmTokens.sans( + size: 13, + weight: FontWeight.w700, + color: EtmTokens.inkOf(context))), + const SizedBox(height: 10), + for (final d in _descriptors) + _DescriptorCard( + descriptor: d, + master: _descMaster(d), + slave: _descSlave(d), + // déjà ajouté (o:thingId présent) OU ajouté dans cette session + added: d.alreadyAdded || _addedIds.contains(d.id), + onAdd: () => _add(d), + ), + ]; + } + + String _paramLabel(Map p) { + final dn = p['displayName'] as String?; + if (dn != null && dn.isNotEmpty) return dn; + final n = (p['name'] as String? ?? '').toLowerCase(); + return n == 'slaveaddress' ? 'Adresse Modbus (1–247)' : (p['name'] as String? ?? 'Paramètre'); + } +} + +// ═══════════════════════════════════════════════════════════════════════════ + +class _ParamField extends StatelessWidget { + final String label; + final TextEditingController controller; + final bool isInt; + const _ParamField( + {required this.label, required this.controller, required this.isInt}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, + style: EtmTokens.sans( + size: 12, + weight: FontWeight.w600, + color: EtmTokens.mutedOf(context))), + const SizedBox(height: 6), + TextField( + controller: controller, + keyboardType: isInt ? TextInputType.number : TextInputType.text, + inputFormatters: + isInt ? [FilteringTextInputFormatter.digitsOnly] : null, + style: EtmTokens.mono(size: 14, color: EtmTokens.inkOf(context)), + decoration: InputDecoration( + isDense: true, + filled: true, + fillColor: EtmTokens.bg2Of(context), + contentPadding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 12), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(EtmTokens.radiusCtrl), + borderSide: BorderSide.none, + ), + ), + ), + ], + ), + ); + } +} + +class _DescriptorCard extends StatelessWidget { + final ThingDescriptor descriptor; + final String master; + final String? slave; + final bool added; + final VoidCallback onAdd; + const _DescriptorCard({ + required this.descriptor, + required this.master, + required this.slave, + required this.added, + required this.onAdd, + }); + + @override + Widget build(BuildContext context) { + // ⚠️ REVALIDER SDM réel : title/description peuvent être vides ou génériques. + final title = descriptor.title.isNotEmpty + ? descriptor.title + : descriptor.description.isNotEmpty + ? descriptor.description + : 'Appareil'; + return Opacity( + opacity: added ? 0.55 : 1, + child: Container( + margin: const EdgeInsets.only(bottom: 10), + 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: [ + Row( + children: [ + Flexible( + child: Text(title, + overflow: TextOverflow.ellipsis, + style: EtmTokens.sans( + size: 14, + weight: FontWeight.w700, + color: EtmTokens.inkOf(context))), + ), + if (added) ...[ + const SizedBox(width: 8), + _Badge('déjà ajouté'), + ], + ], + ), + const SizedBox(height: 3), + Text( + [ + if (slave != null) 'addr $slave', + 'master $master', + ].join(' · '), + style: EtmTokens.mono( + size: 11.5, color: EtmTokens.mutedOf(context)), + ), + ], + ), + ), + if (!added) + FilledButton( + style: FilledButton.styleFrom( + backgroundColor: EtmTokens.brand, + padding: + const EdgeInsets.symmetric(horizontal: 14, vertical: 8), + ), + onPressed: onAdd, + child: Text('Ajouter', + style: EtmTokens.sans( + size: 13, weight: FontWeight.w700, color: Colors.white)), + ), + ], + ), + ), + ); + } +} + +class _Badge extends StatelessWidget { + final String text; + const _Badge(this.text); + @override + Widget build(BuildContext context) => Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: EtmTokens.eco.withValues(alpha: 0.14), + borderRadius: BorderRadius.circular(EtmTokens.radiusPill), + ), + child: Text(text, + style: EtmTokens.sans( + size: 10.5, weight: FontWeight.w700, color: EtmTokens.eco)), + ); +} + +class _Banner extends StatelessWidget { + final String text; + final bool error; + const _Banner(this.text, {this.error = false}); + @override + Widget build(BuildContext context) { + final c = error ? EtmTokens.danger : EtmTokens.brand; + return Container( + padding: const EdgeInsets.all(13), + decoration: BoxDecoration( + color: c.withValues(alpha: 0.10), + borderRadius: BorderRadius.circular(EtmTokens.radiusCtrl), + border: Border.all(color: c.withValues(alpha: 0.25)), + ), + child: Row( + children: [ + Icon(error ? Icons.error_outline_rounded : Icons.info_outline_rounded, + size: 18, color: c), + const SizedBox(width: 10), + Expanded( + child: Text(text, + style: EtmTokens.sans( + size: 12.5, color: EtmTokens.inkOf(context), height: 1.3)), + ), + ], + ), + ); + } +} + +class _Locked extends StatelessWidget { + const _Locked(); + @override + Widget build(BuildContext context) => Scaffold( + backgroundColor: EtmTokens.bgOf(context), + appBar: AppBar( + title: const Text('Découvrir'), + backgroundColor: EtmTokens.surfaceOf(context), + foregroundColor: EtmTokens.inkOf(context), + elevation: 0, + ), + body: Center( + child: Text('Réservé au mode installateur', + style: + EtmTokens.sans(size: 14, color: EtmTokens.mutedOf(context))), + ), + ); +} diff --git a/lib/services/nymea_service.dart b/lib/services/nymea_service.dart index ef8d96e..d102215 100644 --- a/lib/services/nymea_service.dart +++ b/lib/services/nymea_service.dart @@ -1169,6 +1169,74 @@ class NymeaService extends ChangeNotifier { } } + /// **Lot C — discovery.** Lance `Integrations.DiscoverThings`. Renvoie + /// `(error, descriptors)` : `error==null` = succès (descriptors peut être + /// **vide** = aucun appareil trouvé, à distinguer d'une erreur). + /// + /// ⚠️ À REVALIDER sur SDM réel : le scan « peut prendre un moment » — si le bus + /// réel dépasse le timeout 15 s de `_sendRequest`, l'allonger ici. + Future<({String? error, List descriptors})> discoverThings( + String thingClassId, + List> discoveryParams, + ) async { + if (_isSimulation || !_connected) { + return ( + error: 'Indisponible : aucune box connectée (mode démo).', + descriptors: [] + ); + } + try { + final r = await _sendRequest('Integrations.DiscoverThings', { + 'thingClassId': thingClassId, + if (discoveryParams.isNotEmpty) 'discoveryParams': discoveryParams, + }); + final err = (r['params']?['thingError'] as String?) ?? 'ThingErrorNoError'; + if (err != 'ThingErrorNoError') { + return (error: thingErrorMessage(err), descriptors: []); + } + final raw = (r['params']?['thingDescriptors'] as List?) ?? const []; + return ( + error: null, + descriptors: raw + .map((e) => ThingDescriptor.fromJson(e as Map)) + .toList() + ); + } catch (e) { + _log('DiscoverThings: $e', force: true); + return ( + error: 'Erreur de communication avec la box.', + descriptors: [] + ); + } + } + + /// **Lot C — ajout descriptor-based** (`AddThing` avec `thingDescriptorId`, + /// params pris du descriptor). `null` = succès (things rechargés), sinon + /// message FR. Réservé aux classes `setupMethod=JustAdd` (pas de pairing). + Future addThingFromDescriptor({ + required String thingClassId, + required String thingDescriptorId, + required String name, + }) async { + if (_isSimulation || !_connected) return 'Indisponible (mode démo).'; + try { + final r = await _sendRequest('Integrations.AddThing', { + 'name': name, + 'thingClassId': thingClassId, + 'thingDescriptorId': thingDescriptorId, + }); + final err = (r['params']?['thingError'] as String?) ?? 'ThingErrorNoError'; + if (err == 'ThingErrorNoError') { + await _loadThings(); // refresh things + return null; + } + return thingErrorMessage(err); + } catch (e) { + _log('AddThing(descriptor): $e', force: true); + return 'Erreur de communication avec la box.'; + } + } + Future _loadThings() async { // ── Étape 1 : charger les things configurés ────────────────────────────── try { @@ -1272,15 +1340,8 @@ class NymeaService extends ChangeNotifier { } Future refreshThings() => _loadThings(); - Future>> discoverThings(String thingClassId) async { - if (_isSimulation) return []; - try { - final r = await _sendRequest( - 'Integrations.DiscoverThings', {'thingClassId': thingClassId}); - return List>.from( - r['params']?['thingDescriptors'] ?? []); - } catch (_) { return []; } - } + // (ancien discoverThings sans params/erreur retiré — remplacé par la version + // typée avec discoveryParams + ThingError, plus haut.) Future addThing({ required String thingClassId,