Compare commits
3 Commits
3a535e05fa
...
1056d6c746
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1056d6c746 | ||
|
|
b4a6c97d3d | ||
|
|
b7a7213ee4 |
113
RECAP.md
Normal file
113
RECAP.md
Normal file
@ -0,0 +1,113 @@
|
||||
# RECAP — etm-powersync-app · reprise de session
|
||||
|
||||
> État au 2026-06-30. Branche **`feature/beta-add-config`** (9 commits au-dessus de `master`).
|
||||
> **Rien n'est poussé** (push différé). **Aucun merge** vers master/silo.
|
||||
|
||||
---
|
||||
|
||||
## 1. Où on en est (commits, du plus récent au plus ancien)
|
||||
|
||||
| Commit | Contenu |
|
||||
|---|---|
|
||||
| `b4a6c97` | **Lot C — squelette discovery** de things (⚠️ *pending SDM réel*) |
|
||||
| `b7a7213` | Catalogue de things (lecture seule) + filtres (lot A) |
|
||||
| `3a535e0` | Écran **Protocoles** — CRUD masters Modbus RTU |
|
||||
| `0ebbde4` | chore(docs) — specs/contrats/refs rangés dans `docs/` |
|
||||
| `be6694c` | chore — ignore `/screenshots` |
|
||||
| `f755c72` | docs — contrat UI **rev.5** (réserve décision-2 levée) |
|
||||
| `3821bf3` | refactor(energy) — retrait calcul ratios app-side → seam `EnergyRatiosInterim` |
|
||||
| `b5abe42` | docs — contrat UI rev.4 |
|
||||
| `c638ec6` | **Connexions multi-HEMS + Rôles & appareils** (gros commit fondateur) |
|
||||
|
||||
**Source de vérité contrats** : `docs/UI_data_contract.md` (rev.5), `docs/reference/jsonRPC.txt` (introspection — fait foi pour les RPC).
|
||||
|
||||
---
|
||||
|
||||
## 2. Ce qui est LIVRÉ et fonctionnel
|
||||
|
||||
### Connexions multi-HEMS (validé live)
|
||||
- `models/installation.dart`, `services/installation_store.dart` (token **par UUID** via `flutter_secure_storage`, métadonnées SharedPreferences).
|
||||
- `services/connection_manager.dart` : mono-connexion, `switchTo`, `connectNew`, `enterDemo` (démo = active factice), contrôle d'identité DHCP, invalidation des caches au switch.
|
||||
- `nymea_service` : `connect()` réveillé (ws://4444, token par UUID), capture Hello (uuid/name/initialSetupRequired), `authenticate`/`createUser`, `resetState`.
|
||||
- Écran **Installations** (3 états) + écran **Connexion** (auth 2 branches, détection auto).
|
||||
- **Gate de routage** (`buildAppRouter`, redirect + `refreshListenable: merge([cm,svc])`), `hasActiveConnection = isSimulation || (connected && authenticated)`.
|
||||
- En-tête drawer cliquable → Installations.
|
||||
- **Testé en vrai** : login auth contre `.120`, `.75` ouvert, switch, déverrouillage installateur.
|
||||
|
||||
### Rôles & appareils
|
||||
- `EnergySetupProvider` tri-état (present/absent/non-configuré), écran `roles_devices_screen`, `LoadDescriptor` (rév.2 : PAC exclue → SG-Ready), `Energy.SetRootMeter` réel, Get/SetLoadConfig en **stub loggé**.
|
||||
|
||||
### Energy — Phase 1 (interim)
|
||||
- `services/energy_ratios.dart` = **SEAM UNIQUE** `EnergyRatiosInterim` : ratios autoconso/autonomie par Δ-de-cumuls (reseed jour/non-monotone, gardes n/a, clamp). `selfConsumptionPower` net-signé **sans `.abs()`**. Test : `test/energy_ratios_test.dart`.
|
||||
- Calcul ratios app-side **retiré** de `_parsePowerBalance` + getter `autoconsommationW` supprimé.
|
||||
|
||||
### Installateur — Modbus RTU (validé live)
|
||||
- `models/modbus_models.dart`, 5 RPC `ModbusRtu.*` dans `nymea_service`, écran `protocols_screen` (CRUD), route `/settings/system/protocols`.
|
||||
|
||||
### Installateur — Catalogue things lot A (validé live)
|
||||
- `getVendors()` + `getAllThingClasses()`, écran `thing_catalog_screen` (liste groupée + 3 filtres : fabricant/type/recherche + détail read-only), route `/settings/system/things`.
|
||||
|
||||
### Installateur — Discovery lot C (SQUELETTE, NON validé matériel)
|
||||
- `ThingDescriptor`, `discoverThings()`, `addThingFromDescriptor()`, `thing_discovery_screen` (form slaveAddress → scan → descriptors → AddThing).
|
||||
|
||||
---
|
||||
|
||||
## 3. ⚠️ BLOQUEURS / EN ATTENTE
|
||||
|
||||
- **Lot C — merge bloqué** tant qu'on n'a pas un **descriptor SDM réel** sur le bus de hems `.75` (besoin : adaptateur USB-RS485 + un vrai compteur SDM branché ; aujourd'hui le master `/dev/ttyUSB0` est `connected:false`). **3 points marqués `À REVALIDER SUR SDM RÉEL`** dans `thing_discovery_screen.dart` / `nymea_service.dart` :
|
||||
1. résolution master/slave par **heuristique de nom** de paramTypes (`'master'`/`'modbus'`/`'slave'`) ;
|
||||
2. **timeout** scan (`_sendRequest` = 15 s, à allonger si bus réel plus lent) ;
|
||||
3. `title`/`description` du descriptor (peuvent être vides/génériques).
|
||||
- **Push** : jamais fait (volontaire).
|
||||
|
||||
---
|
||||
|
||||
## 4. Lots / phases NON FAITS (file d'attente)
|
||||
|
||||
- **Lot B** — ajout direct `CreateMethodUser + JustAdd` (form paramTypes). Sous-ensemble = 22 classes (ABB B2x, ABB Terra AC RTU/TCP, génériques/simulés nymea). ⚠️ **Les Eastron SDM N'EN SONT PAS** (ils sont Discovery → lot C). Piège param : nom du master varie (`modbusMasterUuid` vs `rtuMaster`). Reporté **après** C.
|
||||
- **Lot D** — discovery → pairing (OAuth/PushButton/UserAndPassword).
|
||||
- **Connexions Étape 3** — découverte **mDNS** native (`multicast_dns`, natif only ; web = ajout manuel). Différée.
|
||||
- **Connexions — branche CreateUser** (box neuve) : codée mais **non testée live** (pas de box vierge/factory-reset sous la main).
|
||||
- **Energy Phase 2/3/4** (session dédiée, voir mémoire) : Phase 2 = states ratios côté energymanager (autre repo) ; Phase 3 = persistance Influx ; Phase 4 = refonte flow-card §1.1 + bandes + retrait définitif du calcul app-side. Contrat = `docs/UI_data_contract.md`.
|
||||
|
||||
### Bugs latents notés (hors lot)
|
||||
- `dashboard_screen.dart` : `onRefresh: () => service.startSimulation()` → sur box réelle, pull-to-refresh bascule en démo. À corriger au câblage du refresh réel.
|
||||
- Affichage signe : sans `.abs()`, conso reste net-signée → Maison (flow-card) & ligne conso (graphe) montrent le signe brut **jusqu'à la Phase 4** (voulu).
|
||||
|
||||
---
|
||||
|
||||
## 5. Banc de test
|
||||
|
||||
| Cible | Détail |
|
||||
|---|---|
|
||||
| **hems** `.75` | nymea 1.15.2, **ouverte** (`authenticationRequired:false`). Master RTU `/dev/ttyUSB0` présent mais bus **hors-ligne**. Sert aux sondes read-only sans auth. |
|
||||
| **box 2** `192.168.1.120` | **auth requise** (`initialSetupRequired:false` → login). Vraie installation avec **SDM120 PV**. |
|
||||
| **InfluxDB** | local sur `etm-powersync-dev`, **v1.6.7** (PAS Influx 3 Core), DB `nymeatest`, RP tiers déjà présentes. |
|
||||
| **Tél Android** | Redmi Note 9S, **adb WiFi** `192.168.1.107:5555`. |
|
||||
|
||||
**PIN installateur par défaut = `1234`.**
|
||||
|
||||
### Comment lancer / tester
|
||||
- **Réel (réseau)** : `flutter run -d 192.168.1.107:5555` (Android WiFi). **Pas Chrome** pour le réseau (CORS LAN). Voir mémoire `web-lan-cors`.
|
||||
- **Linux natif** : ❌ bloqué (Flutter **snap** sans linker `ld.lld`). Web headless = page blanche (CanvasKit). Captures fiables = `adb exec-out screencap -p`.
|
||||
- **Sonde RPC sans GUI** : script `dart:io` `WebSocket.connect('ws://<host>:4444')` + JSON-RPC (ex. utilisé pour valider Modbus/discovery contre `.75`).
|
||||
|
||||
---
|
||||
|
||||
## 6. Faits techniques à ne pas reperdre
|
||||
|
||||
- **Signes nymea** (power balance agrégé) : `production` **positif**, `consumption` **NÉGATIF**, `acquisition` **net-signé** (négatif = export). `currentPower` thing-level = **négatif pour un producteur** (convention conso). **Ne jamais deviner/`.abs()` le signe.**
|
||||
- **Cumuls** (`totalAcquisition` / `totalReturn`) **séparés** → autoconso/autonomie dérivables des logs. UUID Hello **brace-wrapped** `{...}` (normalisé dans `_finishConnect`).
|
||||
- **Discovery SDM** : discoveryParam = **`slaveAddress` uniquement** (Int, déf 1). Le master n'est **PAS** une entrée — il **ressort** dans `descriptor.params`. Ajout final = `AddThing(thingDescriptorId)`.
|
||||
- **CreateMethod** = {User, Auto, Discovery} · **SetupMethod** = {JustAdd, DisplayPin, EnterPin, PushButton, UserAndPassword, OAuth}. Orthogonaux (une classe JustAdd peut exiger Discovery).
|
||||
|
||||
---
|
||||
|
||||
## 7. Reprise — prochaines actions probables
|
||||
|
||||
1. Brancher un **SDM sur `.75`** → sonde discovery réelle → lever les 3 points + **débloquer le merge du lot C**.
|
||||
2. Puis **lot B** (ABB + génériques) ou **lot D** (pairing).
|
||||
3. Côté energy : ouvrir la **session dédiée Phase 4** (contrat `docs/UI_data_contract.md`).
|
||||
4. Décider du **push** de `feature/beta-add-config`.
|
||||
|
||||
> Mémoires persistantes liées : `web-lan-cors`, `energy-chart-redesign`, `deploy`.
|
||||
@ -23,6 +23,7 @@ import 'screens/installations/connection_screen.dart';
|
||||
import 'screens/installations/installations_screen.dart';
|
||||
import 'screens/settings/app_settings_screen.dart';
|
||||
import 'screens/settings/protocols_screen.dart';
|
||||
import 'screens/settings/thing_catalog_screen.dart';
|
||||
import 'screens/settings/appearance_screen.dart';
|
||||
import 'screens/settings/screens_settings_screen.dart';
|
||||
import 'screens/things_screen.dart';
|
||||
@ -127,7 +128,7 @@ GoRouter buildAppRouter(ConnectionManager cm, NymeaService svc) => GoRouter(
|
||||
),
|
||||
// Routes installateur stubées
|
||||
GoRoute(path: '/settings/system', builder: (c, s) => _StubScreen('Configuration système')),
|
||||
GoRoute(path: '/settings/system/things', builder: (c, s) => _StubScreen('Configuration Things')),
|
||||
GoRoute(path: '/settings/system/things', builder: (c, s) => const ThingCatalogScreen()),
|
||||
GoRoute(path: '/settings/system/network', builder: (c, s) => _StubScreen('Système & réseau')),
|
||||
GoRoute(path: '/settings/system/protocols',builder: (c, s) => const ProtocolsScreen()),
|
||||
GoRoute(path: '/settings/system/mqtt', builder: (c, s) => _StubScreen('MQTT / Web server')),
|
||||
|
||||
@ -191,6 +191,26 @@ class NymeaActionType {
|
||||
);
|
||||
}
|
||||
|
||||
/// Fabricant (`Integrations.GetVendors` → `Vendor`). Plusieurs vendors peuvent
|
||||
/// partager un `displayName` (ex. deux « ABB ») → identité = [id].
|
||||
class NymeaVendor {
|
||||
final String id;
|
||||
final String name;
|
||||
final String displayName;
|
||||
|
||||
const NymeaVendor({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.displayName,
|
||||
});
|
||||
|
||||
factory NymeaVendor.fromJson(Map<String, dynamic> j) => NymeaVendor(
|
||||
id: j['id'] as String? ?? '',
|
||||
name: j['name'] as String? ?? '',
|
||||
displayName: j['displayName'] as String? ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
class NymeaThingClass {
|
||||
final String id;
|
||||
final String name;
|
||||
@ -200,6 +220,10 @@ class NymeaThingClass {
|
||||
final List<NymeaActionType> actionTypes;
|
||||
final List<Map<String, dynamic>> paramTypes;
|
||||
final List<NymeaStateType> settingsTypes;
|
||||
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,
|
||||
@ -210,8 +234,18 @@ class NymeaThingClass {
|
||||
this.actionTypes = const [],
|
||||
this.paramTypes = const [],
|
||||
this.settingsTypes = const [],
|
||||
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? ?? '',
|
||||
@ -227,6 +261,11 @@ class NymeaThingClass {
|
||||
settingsTypes: (j['settingsTypes'] as List? ?? [])
|
||||
.map((s) => NymeaStateType.fromJson(s as Map<String, dynamic>))
|
||||
.toList(),
|
||||
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
|
||||
@ -310,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',
|
||||
};
|
||||
|
||||
@ -66,6 +66,8 @@ const Map<String, ThingCategory> interfaceToCategoryMap = {
|
||||
'airconditioner': ThingCategory.hvac,
|
||||
'pump': ThingCategory.hvac,
|
||||
'heatpump': ThingCategory.hvac,
|
||||
'simpleheatpump': ThingCategory.hvac, // interfaces réelles (hems)
|
||||
'smartgridheatpump': ThingCategory.hvac,
|
||||
'ventilation': ThingCategory.hvac,
|
||||
'boiler': ThingCategory.hvac,
|
||||
'light': ThingCategory.lighting,
|
||||
|
||||
720
lib/screens/settings/thing_catalog_screen.dart
Normal file
720
lib/screens/settings/thing_catalog_screen.dart
Normal file
@ -0,0 +1,720 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../models/nymea_models.dart';
|
||||
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.
|
||||
///
|
||||
/// Aucun `AddThing`/`DiscoverThings`/`PairThing` ici (lots B/C). Catégories de
|
||||
/// type **dérivées des interfaces réelles** (thing_category.dart), pas d'enum
|
||||
/// inventé. Filtrage 100 % app-side sur les données déjà chargées.
|
||||
class ThingCatalogScreen extends StatefulWidget {
|
||||
const ThingCatalogScreen({super.key});
|
||||
|
||||
@override
|
||||
State<ThingCatalogScreen> createState() => _ThingCatalogScreenState();
|
||||
}
|
||||
|
||||
class _ThingCatalogScreenState extends State<ThingCatalogScreen> {
|
||||
bool _loading = true;
|
||||
List<NymeaThingClass> _classes = const [];
|
||||
Map<String, NymeaVendor> _vendorById = const {};
|
||||
|
||||
// Filtres (combinables).
|
||||
String? _vendor; // displayName fabricant
|
||||
ThingCategory? _category;
|
||||
String _search = '';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _load());
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
if (!mounted) return;
|
||||
setState(() => _loading = true);
|
||||
final svc = context.read<NymeaService>();
|
||||
final classes = await svc.getAllThingClasses();
|
||||
final vendors = await svc.getVendors();
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_classes = classes;
|
||||
_vendorById = {for (final v in vendors) v.id: v};
|
||||
_loading = false;
|
||||
});
|
||||
}
|
||||
|
||||
String _vendorName(NymeaThingClass c) =>
|
||||
_vendorById[c.vendorId]?.displayName ?? '(inconnu)';
|
||||
|
||||
/// Fabricants réellement présents (dédupliqués par displayName).
|
||||
List<String> get _vendorOptions {
|
||||
final set = _classes.map(_vendorName).toSet().toList()..sort();
|
||||
return set;
|
||||
}
|
||||
|
||||
/// Catégories réellement présentes, dans l'ordre de l'enum.
|
||||
List<ThingCategory> get _categoryOptions {
|
||||
final present = _classes.map((c) => c.category).toSet();
|
||||
return ThingCategory.values.where(present.contains).toList();
|
||||
}
|
||||
|
||||
List<NymeaThingClass> get _filtered {
|
||||
final q = _norm(_search);
|
||||
return _classes.where((c) {
|
||||
if (_vendor != null && _vendorName(c) != _vendor) return false;
|
||||
if (_category != null && c.category != _category) return false;
|
||||
if (q.isNotEmpty && !_norm(c.displayName).contains(q)) return false;
|
||||
return true;
|
||||
}).toList()
|
||||
..sort((a, b) => a.displayName.compareTo(b.displayName));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final installer = context.watch<InstallerModeProvider>().isUnlocked;
|
||||
if (!installer) return const _LockedScaffold();
|
||||
|
||||
final filtered = _filtered;
|
||||
// Regroupement par catégorie pour l'affichage.
|
||||
final groups = <ThingCategory, List<NymeaThingClass>>{};
|
||||
for (final c in filtered) {
|
||||
groups.putIfAbsent(c.category, () => []).add(c);
|
||||
}
|
||||
final orderedCats =
|
||||
ThingCategory.values.where(groups.containsKey).toList();
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: EtmTokens.bgOf(context),
|
||||
appBar: AppBar(
|
||||
title: const Text('Ajouter un thing'),
|
||||
backgroundColor: EtmTokens.surfaceOf(context),
|
||||
foregroundColor: EtmTokens.inkOf(context),
|
||||
elevation: 0,
|
||||
actions: [
|
||||
IconButton(
|
||||
tooltip: 'Rafraîchir',
|
||||
icon: const Icon(Icons.refresh_rounded),
|
||||
onPressed: _load,
|
||||
),
|
||||
],
|
||||
),
|
||||
body: _loading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: Column(
|
||||
children: [
|
||||
_FilterBar(
|
||||
search: _search,
|
||||
onSearch: (v) => setState(() => _search = v),
|
||||
vendor: _vendor,
|
||||
vendors: _vendorOptions,
|
||||
onVendor: (v) => setState(() => _vendor = v),
|
||||
category: _category,
|
||||
categories: _categoryOptions,
|
||||
onCategory: (c) => setState(() => _category = c),
|
||||
total: _classes.length,
|
||||
shown: filtered.length,
|
||||
),
|
||||
Expanded(
|
||||
child: filtered.isEmpty
|
||||
? const _EmptyResults()
|
||||
: ListView(
|
||||
padding: const EdgeInsets.fromLTRB(16, 4, 16, 24),
|
||||
children: [
|
||||
for (final cat in orderedCats) ...[
|
||||
_GroupHeader(categoryInfoMap[cat]!),
|
||||
for (final c in groups[cat]!)
|
||||
_ClassTile(
|
||||
cls: c,
|
||||
vendorName: _vendorName(c),
|
||||
onTap: () => Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => _ClassDetailScreen(
|
||||
cls: c, vendorName: _vendorName(c)),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Normalisation recherche (casse + accents) ───────────────────────────────
|
||||
|
||||
String _norm(String s) {
|
||||
s = s.toLowerCase();
|
||||
const map = {
|
||||
'à': 'a', 'â': 'a', 'ä': 'a', 'é': 'e', 'è': 'e', 'ê': 'e', 'ë': 'e',
|
||||
'î': 'i', 'ï': 'i', 'ô': 'o', 'ö': 'o', 'ù': 'u', 'û': 'u', 'ü': 'u',
|
||||
'ç': 'c',
|
||||
};
|
||||
map.forEach((k, v) => s = s.replaceAll(k, v));
|
||||
return s;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Barre de filtres
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
class _FilterBar extends StatelessWidget {
|
||||
final String search;
|
||||
final ValueChanged<String> onSearch;
|
||||
final String? vendor;
|
||||
final List<String> vendors;
|
||||
final ValueChanged<String?> onVendor;
|
||||
final ThingCategory? category;
|
||||
final List<ThingCategory> categories;
|
||||
final ValueChanged<ThingCategory?> onCategory;
|
||||
final int total;
|
||||
final int shown;
|
||||
|
||||
const _FilterBar({
|
||||
required this.search,
|
||||
required this.onSearch,
|
||||
required this.vendor,
|
||||
required this.vendors,
|
||||
required this.onVendor,
|
||||
required this.category,
|
||||
required this.categories,
|
||||
required this.onCategory,
|
||||
required this.total,
|
||||
required this.shown,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.fromLTRB(16, 10, 16, 8),
|
||||
color: EtmTokens.surfaceOf(context),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
onChanged: onSearch,
|
||||
style:
|
||||
EtmTokens.sans(size: 14, color: EtmTokens.inkOf(context)),
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Rechercher un modèle…',
|
||||
prefixIcon: Icon(Icons.search_rounded,
|
||||
size: 20, color: EtmTokens.faintOf(context)),
|
||||
isDense: true,
|
||||
filled: true,
|
||||
fillColor: EtmTokens.bg2Of(context),
|
||||
contentPadding: const EdgeInsets.symmetric(vertical: 10),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(EtmTokens.radiusCtrl),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
_VendorDropdown(
|
||||
vendor: vendor, vendors: vendors, onVendor: onVendor),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
SizedBox(
|
||||
height: 34,
|
||||
child: ListView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
children: [
|
||||
_TypeChip(
|
||||
label: 'Tous',
|
||||
selected: category == null,
|
||||
onTap: () => onCategory(null),
|
||||
),
|
||||
for (final cat in categories)
|
||||
_TypeChip(
|
||||
label: categoryInfoMap[cat]!.label,
|
||||
icon: categoryInfoMap[cat]!.icon,
|
||||
selected: category == cat,
|
||||
onTap: () => onCategory(category == cat ? null : cat),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text('$shown / $total modèles',
|
||||
style:
|
||||
EtmTokens.sans(size: 11, color: EtmTokens.faintOf(context))),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _VendorDropdown extends StatelessWidget {
|
||||
final String? vendor;
|
||||
final List<String> vendors;
|
||||
final ValueChanged<String?> onVendor;
|
||||
const _VendorDropdown(
|
||||
{required this.vendor, required this.vendors, required this.onVendor});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: EtmTokens.bg2Of(context),
|
||||
borderRadius: BorderRadius.circular(EtmTokens.radiusCtrl),
|
||||
),
|
||||
child: DropdownButtonHideUnderline(
|
||||
child: DropdownButton<String?>(
|
||||
value: vendor,
|
||||
hint: Text('Fabricant',
|
||||
style:
|
||||
EtmTokens.sans(size: 13, color: EtmTokens.mutedOf(context))),
|
||||
isDense: true,
|
||||
items: [
|
||||
DropdownMenuItem(value: null, child: Text('Tous fabricants')),
|
||||
for (final v in vendors)
|
||||
DropdownMenuItem(value: v, child: Text(v)),
|
||||
],
|
||||
onChanged: onVendor,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TypeChip extends StatelessWidget {
|
||||
final String label;
|
||||
final IconData? icon;
|
||||
final bool selected;
|
||||
final VoidCallback onTap;
|
||||
const _TypeChip(
|
||||
{required this.label,
|
||||
this.icon,
|
||||
required this.selected,
|
||||
required this.onTap});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: selected
|
||||
? EtmTokens.brand
|
||||
: EtmTokens.bg2Of(context),
|
||||
borderRadius: BorderRadius.circular(EtmTokens.radiusPill),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
if (icon != null) ...[
|
||||
Icon(icon,
|
||||
size: 14,
|
||||
color: selected ? Colors.white : EtmTokens.mutedOf(context)),
|
||||
const SizedBox(width: 5),
|
||||
],
|
||||
Text(label,
|
||||
style: EtmTokens.sans(
|
||||
size: 12.5,
|
||||
weight: FontWeight.w600,
|
||||
color: selected
|
||||
? Colors.white
|
||||
: EtmTokens.mutedOf(context))),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Liste
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
class _GroupHeader extends StatelessWidget {
|
||||
final ThingCategoryInfo info;
|
||||
const _GroupHeader(this.info);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Padding(
|
||||
padding: const EdgeInsets.fromLTRB(2, 14, 0, 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(info.icon, size: 16, color: info.color),
|
||||
const SizedBox(width: 8),
|
||||
Text(info.label,
|
||||
style: EtmTokens.sans(
|
||||
size: 13,
|
||||
weight: FontWeight.w700,
|
||||
color: EtmTokens.inkOf(context))),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _ClassTile extends StatelessWidget {
|
||||
final NymeaThingClass cls;
|
||||
final String vendorName;
|
||||
final VoidCallback onTap;
|
||||
const _ClassTile(
|
||||
{required this.cls, required this.vendorName, required this.onTap});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: Container(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
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: [
|
||||
Text(cls.displayName,
|
||||
style: EtmTokens.sans(
|
||||
size: 14,
|
||||
weight: FontWeight.w700,
|
||||
color: EtmTokens.inkOf(context))),
|
||||
const SizedBox(height: 2),
|
||||
Text(vendorName,
|
||||
style: EtmTokens.sans(
|
||||
size: 12, color: EtmTokens.mutedOf(context))),
|
||||
],
|
||||
),
|
||||
),
|
||||
Icon(Icons.chevron_right_rounded,
|
||||
color: EtmTokens.faintOf(context)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _EmptyResults extends StatelessWidget {
|
||||
const _EmptyResults();
|
||||
@override
|
||||
Widget build(BuildContext context) => Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(28),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.search_off_rounded,
|
||||
size: 36, color: EtmTokens.faintOf(context)),
|
||||
const SizedBox(height: 12),
|
||||
Text('Aucun modèle ne correspond',
|
||||
style: EtmTokens.sans(
|
||||
size: 14, color: EtmTokens.mutedOf(context))),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Détail (lecture seule)
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
class _ClassDetailScreen extends StatelessWidget {
|
||||
final NymeaThingClass cls;
|
||||
final String vendorName;
|
||||
const _ClassDetailScreen({required this.cls, required this.vendorName});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final info = cls.categoryDetails;
|
||||
return Scaffold(
|
||||
backgroundColor: EtmTokens.bgOf(context),
|
||||
appBar: AppBar(
|
||||
title: Text(cls.displayName, overflow: TextOverflow.ellipsis),
|
||||
backgroundColor: EtmTokens.surfaceOf(context),
|
||||
foregroundColor: EtmTokens.inkOf(context),
|
||||
elevation: 0,
|
||||
),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 24),
|
||||
children: [
|
||||
_Section('Identité', [
|
||||
_Row('Modèle', cls.displayName),
|
||||
_Row('Fabricant', vendorName),
|
||||
_Row('Catégorie', info.label),
|
||||
_Row('Setup', _setupMethodLabel(cls.setupMethod)),
|
||||
if (cls.createMethods.isNotEmpty)
|
||||
_Row('Création',
|
||||
cls.createMethods.map(_createMethodLabel).join(', ')),
|
||||
]),
|
||||
const SizedBox(height: 14),
|
||||
if (cls.interfaces.isNotEmpty) ...[
|
||||
_SectionTitle('Interfaces (${cls.interfaces.length})'),
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 6,
|
||||
runSpacing: 6,
|
||||
children: [for (final i in cls.interfaces) _Chip(i)],
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
],
|
||||
_SectionTitle('Paramètres requis (${cls.paramTypes.length})'),
|
||||
const SizedBox(height: 8),
|
||||
if (cls.paramTypes.isEmpty)
|
||||
Text('Aucun paramètre.',
|
||||
style:
|
||||
EtmTokens.sans(size: 13, color: EtmTokens.mutedOf(context)))
|
||||
else
|
||||
for (final p in cls.paramTypes) _ParamTile(p),
|
||||
const SizedBox(height: 20),
|
||||
// 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)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ParamTile extends StatelessWidget {
|
||||
final Map<String, dynamic> p;
|
||||
const _ParamTile(this.p);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final name = (p['displayName'] as String?)?.isNotEmpty == true
|
||||
? p['displayName'] as String
|
||||
: (p['name'] as String? ?? '—');
|
||||
final type = (p['type'] as String?) ?? '';
|
||||
final unit = _unitLabel((p['unit'] as String?) ?? '');
|
||||
final def = p['defaultValue'];
|
||||
final readOnly = p['readOnly'] == true;
|
||||
|
||||
final meta = <String>[
|
||||
if (type.isNotEmpty) type,
|
||||
if (unit.isNotEmpty) unit,
|
||||
if (def != null && '$def'.isNotEmpty && '$def' != 'null')
|
||||
'défaut $def',
|
||||
if (readOnly) 'lecture seule',
|
||||
].join(' · ');
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: EtmTokens.surfaceOf(context),
|
||||
borderRadius: BorderRadius.circular(EtmTokens.radiusCtrl),
|
||||
boxShadow: EtmTokens.cardShadowOf(context),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(name,
|
||||
style: EtmTokens.sans(
|
||||
size: 13.5,
|
||||
weight: FontWeight.w600,
|
||||
color: EtmTokens.inkOf(context))),
|
||||
if (meta.isNotEmpty) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(meta,
|
||||
style: EtmTokens.mono(
|
||||
size: 11.5, color: EtmTokens.mutedOf(context))),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Petits composants détail ────────────────────────────────────────────────
|
||||
|
||||
class _Section extends StatelessWidget {
|
||||
final String title;
|
||||
final List<Widget> rows;
|
||||
const _Section(this.title, this.rows);
|
||||
@override
|
||||
Widget build(BuildContext context) => Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: EtmTokens.surfaceOf(context),
|
||||
borderRadius: BorderRadius.circular(EtmTokens.radiusCard),
|
||||
boxShadow: EtmTokens.cardShadowOf(context),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(title,
|
||||
style: EtmTokens.sans(
|
||||
size: 12,
|
||||
weight: FontWeight.w700,
|
||||
color: EtmTokens.faintOf(context),
|
||||
letterSpacing: 0.5)),
|
||||
const SizedBox(height: 6),
|
||||
...rows,
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _SectionTitle extends StatelessWidget {
|
||||
final String text;
|
||||
const _SectionTitle(this.text);
|
||||
@override
|
||||
Widget build(BuildContext context) => Text(text,
|
||||
style: EtmTokens.sans(
|
||||
size: 13,
|
||||
weight: FontWeight.w700,
|
||||
color: EtmTokens.inkOf(context)));
|
||||
}
|
||||
|
||||
class _Row extends StatelessWidget {
|
||||
final String k;
|
||||
final String v;
|
||||
const _Row(this.k, this.v);
|
||||
@override
|
||||
Widget build(BuildContext context) => Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 96,
|
||||
child: Text(k,
|
||||
style: EtmTokens.sans(
|
||||
size: 12.5, color: EtmTokens.mutedOf(context))),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(v,
|
||||
style: EtmTokens.sans(
|
||||
size: 13,
|
||||
weight: FontWeight.w600,
|
||||
color: EtmTokens.inkOf(context))),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _Chip extends StatelessWidget {
|
||||
final String text;
|
||||
const _Chip(this.text);
|
||||
@override
|
||||
Widget build(BuildContext context) => Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 5),
|
||||
decoration: BoxDecoration(
|
||||
color: EtmTokens.bg2Of(context),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(text,
|
||||
style: EtmTokens.mono(
|
||||
size: 11.5, color: EtmTokens.mutedOf(context))),
|
||||
);
|
||||
}
|
||||
|
||||
class _LockedScaffold extends StatelessWidget {
|
||||
const _LockedScaffold();
|
||||
@override
|
||||
Widget build(BuildContext context) => Scaffold(
|
||||
backgroundColor: EtmTokens.bgOf(context),
|
||||
appBar: AppBar(
|
||||
title: const Text('Ajouter un thing'),
|
||||
backgroundColor: EtmTokens.surfaceOf(context),
|
||||
foregroundColor: EtmTokens.inkOf(context),
|
||||
elevation: 0,
|
||||
),
|
||||
body: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.lock_outline_rounded,
|
||||
size: 40, color: EtmTokens.faintOf(context)),
|
||||
const SizedBox(height: 12),
|
||||
Text('Réservé au mode installateur',
|
||||
style: EtmTokens.sans(
|
||||
size: 14, color: EtmTokens.mutedOf(context))),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ── Libellés d'enums (lecture seule) ────────────────────────────────────────
|
||||
|
||||
String _setupMethodLabel(String v) => switch (v) {
|
||||
'SetupMethodJustAdd' => 'Ajout direct',
|
||||
'SetupMethodDisplayPin' => 'PIN affiché sur l\'appareil',
|
||||
'SetupMethodEnterPin' => 'Saisie d\'un PIN',
|
||||
'SetupMethodPushButton' => 'Bouton physique',
|
||||
'SetupMethodUserAndPassword' => 'Identifiant / mot de passe',
|
||||
'SetupMethodOAuth' => 'OAuth (web)',
|
||||
'' => '—',
|
||||
_ => v,
|
||||
};
|
||||
|
||||
String _createMethodLabel(String v) => switch (v) {
|
||||
'CreateMethodJustAdd' => 'Ajout manuel',
|
||||
'CreateMethodDiscovery' => 'Découverte',
|
||||
'CreateMethodAuto' => 'Automatique',
|
||||
_ => v,
|
||||
};
|
||||
|
||||
String _unitLabel(String v) =>
|
||||
(v.isEmpty || v == 'UnitNone') ? '' : v.replaceFirst('Unit', '');
|
||||
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))),
|
||||
),
|
||||
);
|
||||
}
|
||||
@ -1137,6 +1137,106 @@ class NymeaService extends ChangeNotifier {
|
||||
bool _thingsLoaded = false;
|
||||
bool get thingsLoaded => _thingsLoaded;
|
||||
|
||||
/// Catalogue (lecture seule) — fabricants connus de ce nymea (`GetVendors`).
|
||||
Future<List<NymeaVendor>> getVendors() async {
|
||||
if (_isSimulation || !_connected) return const [];
|
||||
try {
|
||||
final r = await _sendRequest('Integrations.GetVendors', {});
|
||||
final raw = (r['params']?['vendors'] as List?) ?? const [];
|
||||
return raw
|
||||
.map((e) => NymeaVendor.fromJson(e as Map<String, dynamic>))
|
||||
.toList();
|
||||
} catch (e) {
|
||||
_log('GetVendors: $e', force: true);
|
||||
return const [];
|
||||
}
|
||||
}
|
||||
|
||||
/// Catalogue (lecture seule) — **toutes** les thing classes connues de ce
|
||||
/// nymea (`GetThingClasses` sans filtre). Indépendant de `_thingClasses`
|
||||
/// (qui ne couvre que les classes des things configurés).
|
||||
Future<List<NymeaThingClass>> getAllThingClasses() async {
|
||||
if (_isSimulation || !_connected) return const [];
|
||||
try {
|
||||
final r = await _sendRequest('Integrations.GetThingClasses', {});
|
||||
final raw = (r['params']?['thingClasses'] as List?) ?? const [];
|
||||
return raw
|
||||
.map((e) => NymeaThingClass.fromJson(e as Map<String, dynamic>))
|
||||
.toList();
|
||||
} catch (e) {
|
||||
_log('GetAllThingClasses: $e', force: true);
|
||||
return const [];
|
||||
}
|
||||
}
|
||||
|
||||
/// **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 {
|
||||
@ -1240,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