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/connection_screen.dart';
|
||||||
import 'screens/installations/installations_screen.dart';
|
import 'screens/installations/installations_screen.dart';
|
||||||
import 'screens/settings/app_settings_screen.dart';
|
import 'screens/settings/app_settings_screen.dart';
|
||||||
|
import 'screens/settings/protocols_screen.dart';
|
||||||
import 'screens/settings/appearance_screen.dart';
|
import 'screens/settings/appearance_screen.dart';
|
||||||
import 'screens/settings/screens_settings_screen.dart';
|
import 'screens/settings/screens_settings_screen.dart';
|
||||||
import 'screens/things_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', builder: (c, s) => _StubScreen('Configuration système')),
|
||||||
GoRoute(path: '/settings/system/things', builder: (c, s) => _StubScreen('Configuration Things')),
|
GoRoute(path: '/settings/system/things', builder: (c, s) => _StubScreen('Configuration Things')),
|
||||||
GoRoute(path: '/settings/system/network', builder: (c, s) => _StubScreen('Système & réseau')),
|
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/mqtt', builder: (c, s) => _StubScreen('MQTT / Web server')),
|
||||||
GoRoute(path: '/settings/system/plugins', builder: (c, s) => _StubScreen('Plugins')),
|
GoRoute(path: '/settings/system/plugins', builder: (c, s) => _StubScreen('Plugins')),
|
||||||
GoRoute(path: '/settings/system/update', builder: (c, s) => _StubScreen('Mise à jour système')),
|
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:shared_preferences/shared_preferences.dart';
|
||||||
import 'package:web_socket_channel/web_socket_channel.dart';
|
import 'package:web_socket_channel/web_socket_channel.dart';
|
||||||
import '../models/energy_data.dart'; // EnergyData, HistoryPoint, PowerBalanceEntry
|
import '../models/energy_data.dart'; // EnergyData, HistoryPoint, PowerBalanceEntry
|
||||||
|
import '../models/modbus_models.dart'; // ModbusRtuMaster, SerialPort
|
||||||
import '../models/nymea_models.dart';
|
import '../models/nymea_models.dart';
|
||||||
import 'energy_ratios.dart'; // seam interim ratios (§2.1)
|
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).
|
/// Trace de routage (charges hors etmvariableload : PAC SG-Ready, batterie).
|
||||||
void logInfo(String msg) => _log(msg, force: true);
|
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
|
// THINGS / INTEGRATIONS
|
||||||
// ═══════════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user