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:
parent
0ebbde49da
commit
3a535e05fa
@ -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')),
|
||||
|
||||
168
lib/models/modbus_models.dart
Normal file
168
lib/models/modbus_models.dart
Normal 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',
|
||||
};
|
||||
679
lib/screens/settings/protocols_screen.dart
Normal file
679
lib/screens/settings/protocols_screen.dart
Normal 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)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
@ -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
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user