feat(installateur): lot C — squelette discovery de things (pending SDM validation)

- nymea_models : discoveryParamTypes, isDiscoveryAddable, ThingDescriptor (+alreadyAdded), thingErrorMessage FR
- nymea_service : discoverThings() (0-résultat = "aucun appareil", distinct d'erreur), addThingFromDescriptor()
- thing_catalog : bouton "Découvrir" actif pour Discovery+JustAdd
- thing_discovery_screen (nouveau) : form slaveAddress (sans sélecteur master) -> scan global -> descriptors (master résolu) -> o:thingId = grisé "déjà ajouté" -> AddThing
- retrait d'un discoverThings mort (collision)

NON VALIDÉ SUR MATÉRIEL : 3 points marqués "À REVALIDER SUR SDM RÉEL" (résolution master par paramTypeId, timeout scan, title/description). Merge bloqué jusqu'à descriptor SDM réel sur hems .75.
Chaîne RPC prouvée live (GPIO : 26 descriptors ajoutables).
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 15:15:44 +02:00
parent b7a7213ee4
commit b4a6c97d3d
4 changed files with 636 additions and 23 deletions

View File

@ -223,6 +223,7 @@ class NymeaThingClass {
final String vendorId; // Vendor.id (filtre fabricant)
final String setupMethod; // enum SetupMethod (détail)
final List<String> createMethods; // enum CreateMethods (détail)
final List<Map<String, dynamic>> 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<String, dynamic> 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<String>.from(j['createMethods'] ?? const []),
discoveryParamTypes:
List<Map<String, dynamic>>.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 -ajouter).
class ThingDescriptor {
final String id;
final String title;
final String description;
final String thingClassId;
final List<Map<String, dynamic>> 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<String, dynamic> j) => ThingDescriptor(
id: j['id'] as String? ?? '',
title: j['title'] as String? ?? '',
description: j['description'] as String? ?? '',
thingClassId: j['thingClassId'] as String? ?? '',
params: List<Map<String, dynamic>>.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',
};

View File

@ -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)),
),
),
],
),
);

View File

@ -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<ThingDiscoveryScreen> createState() => _ThingDiscoveryScreenState();
}
class _ThingDiscoveryScreenState extends State<ThingDiscoveryScreen> {
// discoveryParams : paramTypeId -> controller (rendu générique des
// discoveryParamTypes ; pour les SDM = un seul champ slaveAddress).
final Map<String, TextEditingController> _ctl = {};
bool _scanning = false;
bool _scanned = false;
String? _error;
List<ThingDescriptor> _descriptors = const [];
final Set<String> _addedIds = {}; // descriptors ajoutés dans cette session
// Résolution d'affichage : uuid master -> port série (depuis le lot Modbus).
Map<String, String> _masterPort = const {};
// paramTypeId -> name (depuis paramTypes de la classe) pour lire le descriptor.
late final Map<String, String> _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<NymeaService>().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<void> _scan() async {
final params = <Map<String, dynamic>>[];
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<NymeaService>().discoverThings(widget.cls.id, params);
if (!mounted) return;
setState(() {
_scanning = false;
_scanned = true;
_error = res.error;
_descriptors = res.descriptors;
});
}
Future<void> _add(ThingDescriptor d) async {
final name = await _askName(d);
if (name == null || !mounted) return;
final err = await context.read<NymeaService>().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<String?> _askName(ThingDescriptor d) {
final ctl = TextEditingController(
text: d.title.isNotEmpty ? d.title : widget.cls.displayName);
return showDialog<String>(
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<InstallerModeProvider>().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<Widget> _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<String, dynamic> 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 (1247)' : (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))),
),
);
}

View File

@ -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<ThingDescriptor> descriptors})> discoverThings(
String thingClassId,
List<Map<String, dynamic>> discoveryParams,
) async {
if (_isSimulation || !_connected) {
return (
error: 'Indisponible : aucune box connectée (mode démo).',
descriptors: <ThingDescriptor>[]
);
}
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: <ThingDescriptor>[]);
}
final raw = (r['params']?['thingDescriptors'] as List?) ?? const [];
return (
error: null,
descriptors: raw
.map((e) => ThingDescriptor.fromJson(e as Map<String, dynamic>))
.toList()
);
} catch (e) {
_log('DiscoverThings: $e', force: true);
return (
error: 'Erreur de communication avec la box.',
descriptors: <ThingDescriptor>[]
);
}
}
/// **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<String?> 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<void> _loadThings() async {
// Étape 1 : charger les things configurés
try {
@ -1272,15 +1340,8 @@ class NymeaService extends ChangeNotifier {
}
Future<void> refreshThings() => _loadThings();
Future<List<Map<String, dynamic>>> discoverThings(String thingClassId) async {
if (_isSimulation) return [];
try {
final r = await _sendRequest(
'Integrations.DiscoverThings', {'thingClassId': thingClassId});
return List<Map<String, dynamic>>.from(
r['params']?['thingDescriptors'] ?? []);
} catch (_) { return []; }
}
// (ancien discoverThings sans params/erreur retiré remplacé par la version
// typée avec discoveryParams + ThingError, plus haut.)
Future<bool> addThing({
required String thingClassId,