feat(installateur): écran Protocoles — CRUD masters Modbus RTU

- 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) <noreply@anthropic.com>
This commit is contained in:
Patrick Schurig 2026-06-28 13:25:58 +02:00
parent 0ebbde49da
commit 3a535e05fa
4 changed files with 948 additions and 1 deletions

View File

@ -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')),

View File

@ -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<String, dynamic> 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<String, dynamic> 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<int> kBaudrates = [9600, 19200, 38400, 57600, 115200];
/// Enum SerialPortParity sans la sentinelle `Unknown`.
const List<String> kParityValues = [
'SerialPortParityNoParity',
'SerialPortParityEvenParity',
'SerialPortParityOddParity',
'SerialPortParitySpaceParity',
'SerialPortParityMarkParity',
];
/// Enum SerialPortDataBits sans `Unknown`.
const List<String> kDataBitsValues = [
'SerialPortDataBitsData5',
'SerialPortDataBitsData6',
'SerialPortDataBitsData7',
'SerialPortDataBitsData8',
];
/// Enum SerialPortStopBits sans `Unknown`.
const List<String> 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',
};

View File

@ -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<ProtocolsScreen> createState() => _ProtocolsScreenState();
}
class _ProtocolsScreenState extends State<ProtocolsScreen> {
bool _loading = true;
List<ModbusRtuMaster> _masters = const [];
List<SerialPort> _ports = const [];
@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 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<void> _openForm({ModbusRtuMaster? existing}) async {
final cfg = await Navigator.of(context).push<MasterConfig>(
MaterialPageRoute(
builder: (_) => _MasterFormScreen(ports: _ports, existing: existing),
),
);
if (cfg == null || !mounted) return;
final svc = context.read<NymeaService>();
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<void> _confirmDelete(ModbusRtuMaster m) async {
final ok = await showDialog<bool>(
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<NymeaService>().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<InstallerModeProvider>().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<void> 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<SerialPort> 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<String> 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<int> 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<MasterConfig>((
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<String>(
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<int>(
label: 'Baudrate',
value: _baudrate,
items: [
for (final b in _baudOptions)
DropdownMenuItem(value: b, child: Text('$b bps')),
],
onChanged: (v) => setState(() => _baudrate = v ?? _baudrate),
),
_DropdownField<String>(
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<String>(
label: 'Parité',
value: _parity,
items: [
for (final v in kParityValues)
DropdownMenuItem(value: v, child: Text(parityLabel(v))),
],
onChanged: (v) => setState(() => _parity = v ?? _parity),
),
_DropdownField<String>(
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<SerialPort?>()
.firstWhere((e) => true, orElse: () => null);
return p?.label ?? systemLocation;
}
}
// Champs
class _DropdownField<T> extends StatelessWidget {
final String label;
final T? value;
final List<DropdownMenuItem<T>> items;
final ValueChanged<T?> 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<T>(
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)),
),
],
),
);
}

View File

@ -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<List<SerialPort>> 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<String, dynamic>))
.toList();
} catch (e) {
_log('GetSerialPorts: $e', force: true);
return const [];
}
}
/// Masters Modbus RTU configurés (`ModbusRtu.GetModbusRtuMasters`).
Future<List<ModbusRtuMaster>> 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<String, dynamic>))
.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<String?> 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<String?> 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<String?> removeModbusRtuMaster(String modbusUuid) =>
_writeMaster('ModbusRtu.RemoveModbusRtuMaster', {'modbusUuid': modbusUuid});
/// Tronc commun Add/Reconfigure/Remove : envoie, lit `modbusError`, mappe.
Future<String?> _writeMaster(
String method, Map<String, dynamic> 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
//