etm-powersync-app/lib/screens/settings/thing_discovery_screen.dart
Patrick Schurig b4a6c97d3d 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>
2026-06-28 15:15:44 +02:00

459 lines
16 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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))),
),
);
}