- 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>
459 lines
16 KiB
Dart
459 lines
16 KiB
Dart
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 (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))),
|
||
),
|
||
);
|
||
}
|