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:
parent
b7a7213ee4
commit
b4a6c97d3d
@ -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 ré-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',
|
||||
};
|
||||
|
||||
@ -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)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
458
lib/screens/settings/thing_discovery_screen.dart
Normal file
458
lib/screens/settings/thing_discovery_screen.dart
Normal 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 (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))),
|
||||
),
|
||||
);
|
||||
}
|
||||
@ -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,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user