From 3a535e05fa78b18e6a12d1ef8ca99679b70c3f37 Mon Sep 17 00:00:00 2001 From: Patrick Schurig Date: Sun, 28 Jun 2026 13:25:58 +0200 Subject: [PATCH] =?UTF-8?q?feat(installateur):=20=C3=A9cran=20Protocoles?= =?UTF-8?q?=20=E2=80=94=20CRUD=20masters=20Modbus=20RTU?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - modbus_models.dart : ModbusRtuMaster, SerialPort, enums parity/dataBits/stopBits, modbusErrorMessage() - nymea_service : 5 RPC typées (GetSerialPorts, GetModbusRtuMasters, Add/Reconfigure/Remove), erreurs ModbusRtuError -> FR, guards sim/déco - protocols_screen : liste + empty state, form add/edit (dropdowns contraints), suppression confirmée (avert. orphelinage), garde-fou installateur - main.dart : route /settings/system/protocols (remplace le stub) Validé contre nymea réel (hems .75) : champs/enums/modbusError confirmés, cas port USB absent géré. Réf. UI_DATA_CONTRACT.md §6. Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/main.dart | 3 +- lib/models/modbus_models.dart | 168 +++++ lib/screens/settings/protocols_screen.dart | 679 +++++++++++++++++++++ lib/services/nymea_service.dart | 99 +++ 4 files changed, 948 insertions(+), 1 deletion(-) create mode 100644 lib/models/modbus_models.dart create mode 100644 lib/screens/settings/protocols_screen.dart diff --git a/lib/main.dart b/lib/main.dart index 97332eb..0a615b3 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -22,6 +22,7 @@ import 'screens/favorites_screen.dart'; 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/appearance_screen.dart'; import 'screens/settings/screens_settings_screen.dart'; import 'screens/things_screen.dart'; @@ -128,7 +129,7 @@ GoRouter buildAppRouter(ConnectionManager cm, NymeaService svc) => GoRouter( 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/network', builder: (c, s) => _StubScreen('Système & réseau')), - GoRoute(path: '/settings/system/protocols',builder: (c, s) => _StubScreen('Protocoles')), + GoRoute(path: '/settings/system/protocols',builder: (c, s) => const ProtocolsScreen()), GoRoute(path: '/settings/system/mqtt', builder: (c, s) => _StubScreen('MQTT / Web server')), GoRoute(path: '/settings/system/plugins', builder: (c, s) => _StubScreen('Plugins')), GoRoute(path: '/settings/system/update', builder: (c, s) => _StubScreen('Mise à jour système')), diff --git a/lib/models/modbus_models.dart b/lib/models/modbus_models.dart new file mode 100644 index 0000000..450955a --- /dev/null +++ b/lib/models/modbus_models.dart @@ -0,0 +1,168 @@ +// Modèles Modbus RTU — calqués exactement sur l'introspection nymea +// (docs/reference/jsonRPC.txt, namespace ModbusRtu). Valeurs d'enum = les +// chaînes fil préfixées ; aucun champ inventé. + +/// Port série exposé par l'hôte (`ModbusRtu.GetSerialPorts` → `SerialPort`). +/// L'identifiant passé en paramètre `serialPort` est [systemLocation]. +class SerialPort { + final String systemLocation; // ex. /dev/ttyUSB0 — c'est l'id à envoyer + final String description; + final String manufacturer; + final String serialNumber; + + const SerialPort({ + required this.systemLocation, + this.description = '', + this.manufacturer = '', + this.serialNumber = '', + }); + + factory SerialPort.fromJson(Map j) => SerialPort( + systemLocation: (j['systemLocation'] as String?) ?? '', + description: (j['description'] as String?) ?? '', + manufacturer: (j['manufacturer'] as String?) ?? '', + serialNumber: (j['serialNumber'] as String?) ?? '', + ); + + String get label => + description.isNotEmpty ? '$systemLocation · $description' : systemLocation; +} + +/// Master Modbus RTU configuré (`ModbusRtu.GetModbusRtuMasters`). +class ModbusRtuMaster { + final String modbusUuid; + final String serialPort; + final int baudrate; + final String parity; // enum SerialPortParity (valeur fil) + final String dataBits; // enum SerialPortDataBits + final String stopBits; // enum SerialPortStopBits + final int numberOfRetries; + final int timeout; // ms + final bool connected; + + const ModbusRtuMaster({ + required this.modbusUuid, + required this.serialPort, + required this.baudrate, + required this.parity, + required this.dataBits, + required this.stopBits, + required this.numberOfRetries, + required this.timeout, + this.connected = false, + }); + + factory ModbusRtuMaster.fromJson(Map j) => ModbusRtuMaster( + modbusUuid: (j['modbusUuid'] as String?) ?? '', + serialPort: (j['serialPort'] as String?) ?? '', + baudrate: (j['baudrate'] as num?)?.toInt() ?? 0, + parity: (j['parity'] as String?) ?? kDefaultParity, + dataBits: (j['dataBits'] as String?) ?? kDefaultDataBits, + stopBits: (j['stopBits'] as String?) ?? kDefaultStopBits, + numberOfRetries: (j['numberOfRetries'] as num?)?.toInt() ?? 0, + timeout: (j['timeout'] as num?)?.toInt() ?? 0, + connected: j['connected'] == true, + ); + + /// Résumé compact « 9600 bps · 8N1 ». + String get summary => + '$baudrate bps · ${dataBitsLabel(dataBits)}${parityShort(parity)}${stopBitsShort(stopBits)}'; +} + +// ── Valeurs autorisées (dropdowns contraints) ──────────────────────────────── + +/// Baudrates standard proposés. À l'édition, la valeur courante du master est +/// ajoutée si non standard (voir l'écran). +const List kBaudrates = [9600, 19200, 38400, 57600, 115200]; + +/// Enum SerialPortParity — sans la sentinelle `Unknown`. +const List kParityValues = [ + 'SerialPortParityNoParity', + 'SerialPortParityEvenParity', + 'SerialPortParityOddParity', + 'SerialPortParitySpaceParity', + 'SerialPortParityMarkParity', +]; + +/// Enum SerialPortDataBits — sans `Unknown`. +const List kDataBitsValues = [ + 'SerialPortDataBitsData5', + 'SerialPortDataBitsData6', + 'SerialPortDataBitsData7', + 'SerialPortDataBitsData8', +]; + +/// Enum SerialPortStopBits — sans `Unknown`. +const List kStopBitsValues = [ + 'SerialPortStopBitsOneStop', + 'SerialPortStopBitsOneAndHalfStop', + 'SerialPortStopBitsTwoStop', +]; + +// Défauts Modbus RTU classiques : 9600 8N1, 3 retries, 1000 ms. +const String kDefaultParity = 'SerialPortParityNoParity'; +const String kDefaultDataBits = 'SerialPortDataBitsData8'; +const String kDefaultStopBits = 'SerialPortStopBitsOneStop'; +const int kDefaultBaudrate = 9600; +const int kDefaultRetries = 3; +const int kDefaultTimeout = 1000; + +// ── Labels d'affichage ─────────────────────────────────────────────────────── + +String parityLabel(String v) => switch (v) { + 'SerialPortParityNoParity' => 'Aucune (N)', + 'SerialPortParityEvenParity' => 'Paire (E)', + 'SerialPortParityOddParity' => 'Impaire (O)', + 'SerialPortParitySpaceParity' => 'Space', + 'SerialPortParityMarkParity' => 'Mark', + _ => v, + }; + +String parityShort(String v) => switch (v) { + 'SerialPortParityNoParity' => 'N', + 'SerialPortParityEvenParity' => 'E', + 'SerialPortParityOddParity' => 'O', + 'SerialPortParitySpaceParity' => 'S', + 'SerialPortParityMarkParity' => 'M', + _ => '?', + }; + +String dataBitsLabel(String v) => switch (v) { + 'SerialPortDataBitsData5' => '5', + 'SerialPortDataBitsData6' => '6', + 'SerialPortDataBitsData7' => '7', + 'SerialPortDataBitsData8' => '8', + _ => '?', + }; + +String stopBitsLabel(String v) => switch (v) { + 'SerialPortStopBitsOneStop' => '1', + 'SerialPortStopBitsOneAndHalfStop' => '1.5', + 'SerialPortStopBitsTwoStop' => '2', + _ => '?', + }; + +String stopBitsShort(String v) => switch (v) { + 'SerialPortStopBitsOneStop' => '1', + 'SerialPortStopBitsOneAndHalfStop' => '1.5', + 'SerialPortStopBitsTwoStop' => '2', + _ => '?', + }; + +/// Mappe un `ModbusRtuError` fil → message lisible (français). Chaîne vide pour +/// `NoError`. +String modbusErrorMessage(String code) => switch (code) { + 'ModbusRtuErrorNoError' => '', + 'ModbusRtuErrorNotAvailable' => 'Service Modbus RTU indisponible sur la box.', + 'ModbusRtuErrorUuidNotFound' => 'Master introuvable (déjà supprimé ?).', + 'ModbusRtuErrorHardwareNotFound' => + 'Port série introuvable (adaptateur débranché ?).', + 'ModbusRtuErrorResourceBusy' => + 'Port série occupé par un autre processus.', + 'ModbusRtuErrorNotSupported' => 'Opération non supportée.', + 'ModbusRtuErrorInvalidTimeoutValue' => + 'Timeout invalide (minimum 10 ms).', + 'ModbusRtuErrorConnectionFailed' => + 'Échec de connexion au port série.', + _ => 'Erreur Modbus : $code', + }; diff --git a/lib/screens/settings/protocols_screen.dart b/lib/screens/settings/protocols_screen.dart new file mode 100644 index 0000000..04bdf0a --- /dev/null +++ b/lib/screens/settings/protocols_screen.dart @@ -0,0 +1,679 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:provider/provider.dart'; + +import '../../models/modbus_models.dart'; +import '../../providers/installer_mode_provider.dart'; +import '../../services/nymea_service.dart'; +import '../../theme/etm_tokens.dart'; + +/// Config renvoyée par le form ajout/édition. +typedef MasterConfig = ({ + String serialPort, + int baudrate, + String parity, + String dataBits, + String stopBits, + int numberOfRetries, + int timeout, +}); + +/// Écran **Protocoles → Modbus RTU master** (zone installateur). +/// +/// CRUD des masters RTU (`ModbusRtu.*`). Prérequis des meters SDM/abbterra +/// (le thing meter prend le master comme parent). Aucun plugin, aucune série. +class ProtocolsScreen extends StatefulWidget { + const ProtocolsScreen({super.key}); + + @override + State createState() => _ProtocolsScreenState(); +} + +class _ProtocolsScreenState extends State { + bool _loading = true; + List _masters = const []; + List _ports = const []; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) => _load()); + } + + Future _load() async { + if (!mounted) return; + setState(() => _loading = true); + final svc = context.read(); + final masters = await svc.getModbusRtuMasters(); + final ports = await svc.getSerialPorts(); + if (!mounted) return; + setState(() { + _masters = masters; + _ports = ports; + _loading = false; + }); + } + + void _snack(String msg, {bool error = false}) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text(msg), + backgroundColor: error ? EtmTokens.danger : null, + )); + } + + Future _openForm({ModbusRtuMaster? existing}) async { + final cfg = await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => _MasterFormScreen(ports: _ports, existing: existing), + ), + ); + if (cfg == null || !mounted) return; + + final svc = context.read(); + final err = existing == null + ? await svc.addModbusRtuMaster( + serialPort: cfg.serialPort, + baudrate: cfg.baudrate, + parity: cfg.parity, + dataBits: cfg.dataBits, + stopBits: cfg.stopBits, + numberOfRetries: cfg.numberOfRetries, + timeout: cfg.timeout, + ) + : await svc.reconfigureModbusRtuMaster( + modbusUuid: existing.modbusUuid, + serialPort: cfg.serialPort, + baudrate: cfg.baudrate, + parity: cfg.parity, + dataBits: cfg.dataBits, + stopBits: cfg.stopBits, + numberOfRetries: cfg.numberOfRetries, + timeout: cfg.timeout, + ); + if (!mounted) return; + if (err == null) { + _snack(existing == null ? 'Master ajouté.' : 'Master mis à jour.'); + _load(); + } else { + _snack(err, error: true); + } + } + + Future _confirmDelete(ModbusRtuMaster m) async { + final ok = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Supprimer ce master ?'), + content: Text( + 'Le master ${m.serialPort} sera supprimé. ⚠️ Les compteurs (SDM, ' + 'abbterra…) rattachés à ce master deviendront inutilisables.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('Annuler')), + TextButton( + onPressed: () => Navigator.pop(ctx, true), + child: Text('Supprimer', + style: EtmTokens.sans( + color: EtmTokens.danger, weight: FontWeight.w700)), + ), + ], + ), + ); + if (ok != true || !mounted) return; + final err = await context.read().removeModbusRtuMaster(m.modbusUuid); + if (!mounted) return; + if (err == null) { + _snack('Master supprimé.'); + _load(); + } else { + _snack(err, error: true); + } + } + + @override + Widget build(BuildContext context) { + final installer = context.watch().isUnlocked; + + // Garde-fou défensif (le drawer masque déjà cette route hors installateur). + if (!installer) { + return _Scaffold( + 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', + textAlign: TextAlign.center, + style: EtmTokens.sans( + size: 14, color: EtmTokens.mutedOf(context))), + ], + ), + ), + ), + ); + } + + return _Scaffold( + onRefresh: _load, + fab: _masters.isEmpty || _loading + ? null + : FloatingActionButton.extended( + backgroundColor: EtmTokens.brand, + onPressed: () => _openForm(), + icon: const Icon(Icons.add, color: Colors.white), + label: Text('Ajouter', + style: EtmTokens.sans( + weight: FontWeight.w700, color: Colors.white)), + ), + body: _loading + ? const Center(child: CircularProgressIndicator()) + : _masters.isEmpty + ? _EmptyState(onAdd: () => _openForm()) + : ListView.separated( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 96), + itemCount: _masters.length, + separatorBuilder: (_, _) => const SizedBox(height: 10), + itemBuilder: (_, i) => _MasterCard( + master: _masters[i], + onEdit: () => _openForm(existing: _masters[i]), + onDelete: () => _confirmDelete(_masters[i]), + ), + ), + ); + } +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Scaffold commun +// ═══════════════════════════════════════════════════════════════════════════ + +class _Scaffold extends StatelessWidget { + final Widget body; + final Widget? fab; + final Future Function()? onRefresh; + const _Scaffold({required this.body, this.fab, this.onRefresh}); + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: EtmTokens.bgOf(context), + appBar: AppBar( + title: const Text('Protocoles · Modbus RTU'), + backgroundColor: EtmTokens.surfaceOf(context), + foregroundColor: EtmTokens.inkOf(context), + elevation: 0, + actions: [ + if (onRefresh != null) + IconButton( + tooltip: 'Rafraîchir', + icon: const Icon(Icons.refresh_rounded), + onPressed: () => onRefresh!(), + ), + ], + ), + floatingActionButton: fab, + body: body, + ); + } +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Liste / empty state / carte +// ═══════════════════════════════════════════════════════════════════════════ + +class _EmptyState extends StatelessWidget { + final VoidCallback onAdd; + const _EmptyState({required this.onAdd}); + + @override + Widget build(BuildContext context) { + return Center( + child: Padding( + padding: const EdgeInsets.all(28), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.cable_rounded, size: 40, color: EtmTokens.faintOf(context)), + const SizedBox(height: 14), + Text('Aucun master Modbus RTU', + style: EtmTokens.sans( + size: 15, + weight: FontWeight.w700, + color: EtmTokens.inkOf(context))), + const SizedBox(height: 6), + Text( + 'Ajoutez un master sur un port série pour y raccorder vos ' + 'compteurs (SDM, abbterra…).', + textAlign: TextAlign.center, + style: EtmTokens.sans( + size: 12.5, color: EtmTokens.mutedOf(context), height: 1.35), + ), + const SizedBox(height: 18), + FilledButton.icon( + style: FilledButton.styleFrom( + backgroundColor: EtmTokens.brand, + padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 12), + ), + onPressed: onAdd, + icon: const Icon(Icons.add, color: Colors.white), + label: Text('Ajouter un master', + style: EtmTokens.sans( + weight: FontWeight.w700, color: Colors.white)), + ), + ], + ), + ), + ); + } +} + +class _MasterCard extends StatelessWidget { + final ModbusRtuMaster master; + final VoidCallback onEdit; + final VoidCallback onDelete; + const _MasterCard( + {required this.master, required this.onEdit, required this.onDelete}); + + @override + Widget build(BuildContext context) { + return 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: [ + Row( + children: [ + Container( + width: 8, + height: 8, + margin: const EdgeInsets.only(right: 10), + decoration: BoxDecoration( + shape: BoxShape.circle, + color: master.connected + ? EtmTokens.eco + : EtmTokens.faintOf(context), + ), + ), + Expanded( + child: Text(master.serialPort, + style: EtmTokens.mono( + size: 14, + weight: FontWeight.w700, + color: EtmTokens.inkOf(context))), + ), + _StateChip( + master.connected ? 'connecté' : 'hors-ligne', + master.connected ? EtmTokens.eco : EtmTokens.faintOf(context), + ), + ], + ), + const SizedBox(height: 8), + Text(master.summary, + style: EtmTokens.sans( + size: 12.5, color: EtmTokens.mutedOf(context))), + const SizedBox(height: 2), + Text('${master.numberOfRetries} retries · timeout ${master.timeout} ms', + style: + EtmTokens.sans(size: 11.5, color: EtmTokens.faintOf(context))), + const SizedBox(height: 6), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton.icon( + onPressed: onEdit, + icon: const Icon(Icons.edit_outlined, size: 18), + label: const Text('Éditer'), + ), + TextButton.icon( + onPressed: onDelete, + style: TextButton.styleFrom(foregroundColor: EtmTokens.danger), + icon: const Icon(Icons.delete_outline_rounded, size: 18), + label: const Text('Supprimer'), + ), + ], + ), + ], + ), + ); + } +} + +class _StateChip extends StatelessWidget { + final String text; + final Color color; + const _StateChip(this.text, this.color); + + @override + Widget build(BuildContext context) => Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.14), + borderRadius: BorderRadius.circular(EtmTokens.radiusPill), + ), + child: Text(text, + style: EtmTokens.sans( + size: 10.5, weight: FontWeight.w700, color: color)), + ); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Form ajout / édition +// ═══════════════════════════════════════════════════════════════════════════ + +class _MasterFormScreen extends StatefulWidget { + final List ports; + final ModbusRtuMaster? existing; + const _MasterFormScreen({required this.ports, this.existing}); + + @override + State<_MasterFormScreen> createState() => _MasterFormScreenState(); +} + +class _MasterFormScreenState extends State<_MasterFormScreen> { + String? _serialPort; + late int _baudrate; + late String _parity; + late String _dataBits; + late String _stopBits; + late TextEditingController _retries; + late TextEditingController _timeout; + String? _error; + + @override + void initState() { + super.initState(); + final e = widget.existing; + _serialPort = e?.serialPort; + _baudrate = e?.baudrate ?? kDefaultBaudrate; + _parity = e?.parity ?? kDefaultParity; + _dataBits = e?.dataBits ?? kDefaultDataBits; + _stopBits = e?.stopBits ?? kDefaultStopBits; + _retries = + TextEditingController(text: '${e?.numberOfRetries ?? kDefaultRetries}'); + _timeout = TextEditingController(text: '${e?.timeout ?? kDefaultTimeout}'); + } + + @override + void dispose() { + _retries.dispose(); + _timeout.dispose(); + super.dispose(); + } + + /// Ports proposés = ports détectés ∪ port courant (édition d'un port absent). + List get _portOptions { + final opts = widget.ports.map((p) => p.systemLocation).toList(); + final cur = widget.existing?.serialPort; + if (cur != null && cur.isNotEmpty && !opts.contains(cur)) opts.add(cur); + return opts; + } + + List get _baudOptions { + final opts = [...kBaudrates]; + if (!opts.contains(_baudrate)) opts.add(_baudrate); + opts.sort(); + return opts; + } + + void _save() { + final port = _serialPort; + if (port == null || port.isEmpty) { + setState(() => _error = 'Choisissez un port série.'); + return; + } + final retries = int.tryParse(_retries.text.trim()) ?? -1; + final timeout = int.tryParse(_timeout.text.trim()) ?? -1; + if (retries < 0) { + setState(() => _error = 'Nombre de tentatives invalide.'); + return; + } + if (timeout < 10) { + setState(() => _error = 'Timeout minimum : 10 ms.'); + return; + } + Navigator.of(context).pop(( + serialPort: port, + baudrate: _baudrate, + parity: _parity, + dataBits: _dataBits, + stopBits: _stopBits, + numberOfRetries: retries, + timeout: timeout, + )); + } + + @override + Widget build(BuildContext context) { + final ports = _portOptions; + return Scaffold( + backgroundColor: EtmTokens.bgOf(context), + appBar: AppBar( + title: Text(widget.existing == null ? 'Nouveau master' : 'Éditer master'), + backgroundColor: EtmTokens.surfaceOf(context), + foregroundColor: EtmTokens.inkOf(context), + elevation: 0, + ), + body: ListView( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 32), + children: [ + if (ports.isEmpty) + _Banner( + 'Aucun port série détecté. Branchez un adaptateur USB-RS485 puis ' + 'rafraîchissez.', + ), + _DropdownField( + label: 'Port série', + value: ports.contains(_serialPort) ? _serialPort : null, + items: [ + for (final p in ports) + DropdownMenuItem(value: p, child: Text(_portLabel(p))), + ], + onChanged: (v) => setState(() => _serialPort = v), + hint: 'Sélectionner…', + ), + _DropdownField( + label: 'Baudrate', + value: _baudrate, + items: [ + for (final b in _baudOptions) + DropdownMenuItem(value: b, child: Text('$b bps')), + ], + onChanged: (v) => setState(() => _baudrate = v ?? _baudrate), + ), + _DropdownField( + label: 'Bits de données', + value: _dataBits, + items: [ + for (final v in kDataBitsValues) + DropdownMenuItem(value: v, child: Text(dataBitsLabel(v))), + ], + onChanged: (v) => setState(() => _dataBits = v ?? _dataBits), + ), + _DropdownField( + label: 'Parité', + value: _parity, + items: [ + for (final v in kParityValues) + DropdownMenuItem(value: v, child: Text(parityLabel(v))), + ], + onChanged: (v) => setState(() => _parity = v ?? _parity), + ), + _DropdownField( + label: 'Bits de stop', + value: _stopBits, + items: [ + for (final v in kStopBitsValues) + DropdownMenuItem(value: v, child: Text(stopBitsLabel(v))), + ], + onChanged: (v) => setState(() => _stopBits = v ?? _stopBits), + ), + _NumberField(label: 'Tentatives (retries)', controller: _retries), + _NumberField(label: 'Timeout (ms, min 10)', controller: _timeout), + if (_error != null) ...[ + const SizedBox(height: 4), + Text(_error!, + style: EtmTokens.sans(size: 12.5, color: EtmTokens.danger)), + ], + const SizedBox(height: 18), + SizedBox( + width: double.infinity, + child: FilledButton( + style: FilledButton.styleFrom( + backgroundColor: EtmTokens.brand, + padding: const EdgeInsets.symmetric(vertical: 14), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(EtmTokens.radiusCtrl), + ), + ), + onPressed: _save, + child: Text(widget.existing == null ? 'Ajouter' : 'Enregistrer', + style: EtmTokens.sans( + size: 14, weight: FontWeight.w700, color: Colors.white)), + ), + ), + ], + ), + ); + } + + String _portLabel(String systemLocation) { + final p = widget.ports + .where((e) => e.systemLocation == systemLocation) + .cast() + .firstWhere((e) => true, orElse: () => null); + return p?.label ?? systemLocation; + } +} + +// ── Champs ──────────────────────────────────────────────────────────────── + +class _DropdownField extends StatelessWidget { + final String label; + final T? value; + final List> items; + final ValueChanged onChanged; + final String? hint; + const _DropdownField({ + required this.label, + required this.value, + required this.items, + required this.onChanged, + this.hint, + }); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: 14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, + style: EtmTokens.sans( + size: 12, + weight: FontWeight.w600, + color: EtmTokens.mutedOf(context))), + const SizedBox(height: 6), + DropdownButtonFormField( + initialValue: value, + isExpanded: true, + hint: hint != null ? Text(hint!) : null, + items: items, + onChanged: onChanged, + 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 _NumberField extends StatelessWidget { + final String label; + final TextEditingController controller; + const _NumberField({required this.label, required this.controller}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: 14), + 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: TextInputType.number, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + 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 _Banner extends StatelessWidget { + final String text; + const _Banner(this.text); + + @override + Widget build(BuildContext context) => Container( + margin: const EdgeInsets.only(bottom: 14), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: EtmTokens.solar.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(EtmTokens.radiusCtrl), + ), + child: Row( + children: [ + const Icon(Icons.info_outline_rounded, + size: 18, color: EtmTokens.heat), + const SizedBox(width: 10), + Expanded( + child: Text(text, + style: EtmTokens.sans( + size: 12.5, color: EtmTokens.inkOf(context), height: 1.3)), + ), + ], + ), + ); +} diff --git a/lib/services/nymea_service.dart b/lib/services/nymea_service.dart index 90be88c..ecd78b1 100644 --- a/lib/services/nymea_service.dart +++ b/lib/services/nymea_service.dart @@ -7,6 +7,7 @@ import 'package:flutter/foundation.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:web_socket_channel/web_socket_channel.dart'; import '../models/energy_data.dart'; // EnergyData, HistoryPoint, PowerBalanceEntry +import '../models/modbus_models.dart'; // ModbusRtuMaster, SerialPort import '../models/nymea_models.dart'; import 'energy_ratios.dart'; // seam interim ratios (§2.1) @@ -1031,6 +1032,104 @@ class NymeaService extends ChangeNotifier { /// Trace de routage (charges hors etmvariableload : PAC SG-Ready, batterie). void logInfo(String msg) => _log(msg, force: true); + // ═══════════════════════════════════════════════════════════════════════════ + // MODBUS RTU MASTERS (zone installateur — prérequis meters SDM/abbterra) + // ═══════════════════════════════════════════════════════════════════════════ + + /// Ports série dispo sur l'hôte (`ModbusRtu.GetSerialPorts`). + Future> getSerialPorts() async { + if (_isSimulation || !_connected) return const []; + try { + final r = await _sendRequest('ModbusRtu.GetSerialPorts', {}); + final raw = (r['params']?['serialPorts'] as List?) ?? const []; + return raw + .map((e) => SerialPort.fromJson(e as Map)) + .toList(); + } catch (e) { + _log('GetSerialPorts: $e', force: true); + return const []; + } + } + + /// Masters Modbus RTU configurés (`ModbusRtu.GetModbusRtuMasters`). + Future> getModbusRtuMasters() async { + if (_isSimulation || !_connected) return const []; + try { + final r = await _sendRequest('ModbusRtu.GetModbusRtuMasters', {}); + final raw = (r['params']?['modbusRtuMasters'] as List?) ?? const []; + return raw + .map((e) => ModbusRtuMaster.fromJson(e as Map)) + .toList(); + } catch (e) { + _log('GetModbusRtuMasters: $e', force: true); + return const []; + } + } + + /// Crée un master (`ModbusRtu.AddModbusRtuMaster`). Renvoie `null` si succès, + /// sinon un message d'erreur lisible (modbusError mappé / erreur transport). + Future addModbusRtuMaster({ + required String serialPort, + required int baudrate, + required String parity, + required String dataBits, + required String stopBits, + required int numberOfRetries, + required int timeout, + }) => + _writeMaster('ModbusRtu.AddModbusRtuMaster', { + 'serialPort': serialPort, + 'baudrate': baudrate, + 'parity': parity, + 'dataBits': dataBits, + 'stopBits': stopBits, + 'numberOfRetries': numberOfRetries, + 'timeout': timeout, + }); + + /// Édite un master (`ModbusRtu.ReconfigureModbusRtuMaster`). + Future reconfigureModbusRtuMaster({ + required String modbusUuid, + required String serialPort, + required int baudrate, + required String parity, + required String dataBits, + required String stopBits, + required int numberOfRetries, + required int timeout, + }) => + _writeMaster('ModbusRtu.ReconfigureModbusRtuMaster', { + 'modbusUuid': modbusUuid, + 'serialPort': serialPort, + 'baudrate': baudrate, + 'parity': parity, + 'dataBits': dataBits, + 'stopBits': stopBits, + 'numberOfRetries': numberOfRetries, + 'timeout': timeout, + }); + + /// Supprime un master (`ModbusRtu.RemoveModbusRtuMaster`). + Future removeModbusRtuMaster(String modbusUuid) => + _writeMaster('ModbusRtu.RemoveModbusRtuMaster', {'modbusUuid': modbusUuid}); + + /// Tronc commun Add/Reconfigure/Remove : envoie, lit `modbusError`, mappe. + Future _writeMaster( + String method, Map params) async { + if (_isSimulation || !_connected) { + return 'Indisponible : aucune box connectée (mode démo).'; + } + try { + final r = await _sendRequest(method, params); + final err = + (r['params']?['modbusError'] as String?) ?? 'ModbusRtuErrorNoError'; + return err == 'ModbusRtuErrorNoError' ? null : modbusErrorMessage(err); + } catch (e) { + _log('$method: $e', force: true); + return 'Erreur de communication avec la box.'; + } + } + // ═══════════════════════════════════════════════════════════════════════════ // THINGS / INTEGRATIONS // ═══════════════════════════════════════════════════════════════════════════