feat(device): 实现下位机 JSON 协议(data model 对齐)
按 docs/下位机交互数据模型.md 重构串口协议层: 协议层 - 新增 DeviceMessage 模型,对应 message_id/type/ack/need_ack/data - 新增 JsonProtocolService,4 字节大端长度前缀 + UTF-8 JSON 帧 - 删除原二进制协议(serial_protocol.dart) 服务层 - 新增 DeviceMessageService,集中收发并按 type 分发 - 重写 SerialRunner 为 JsonSerialRunner,使用 create_task/control 消息 数据模型 - DeviceState 增加 doorStatus/lightStatus/taskStatus/lastInfoAt - 新增 DeviceInfoNotifier 订阅 device_info 上行 - 灯光按钮接通 light_control 消息 测试 - 新增 device_protocol_test.dart(14 用例) - 修复 models_test.dart 残留的 Step mixSpeed/blowSpeed 错误
This commit is contained in:
499
lib/features/settings/widgets/serial_config_panel.dart
Normal file
499
lib/features/settings/widgets/serial_config_panel.dart
Normal file
@@ -0,0 +1,499 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:usb_serial/usb_serial.dart';
|
||||
|
||||
import '../../../core/theme/app_theme.dart';
|
||||
import '../../device/models/serial_config.dart';
|
||||
import '../../device/providers/serial_provider.dart';
|
||||
import '../../device/services/device_message.dart';
|
||||
import '../../device/services/serial_port_service.dart';
|
||||
|
||||
/// 串口配置面板
|
||||
///
|
||||
/// 展示当前连接状态、可用设备列表、串口参数,并提供
|
||||
/// 「刷新设备 / 连接 / 断开 / 测试」等操作。
|
||||
class SerialConfigPanel extends ConsumerStatefulWidget {
|
||||
const SerialConfigPanel({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<SerialConfigPanel> createState() => _SerialConfigPanelState();
|
||||
}
|
||||
|
||||
class _SerialConfigPanelState extends ConsumerState<SerialConfigPanel> {
|
||||
List<UsbDevice> _devices = const [];
|
||||
UsbDevice? _selectedDevice;
|
||||
bool _loadingDevices = false;
|
||||
bool _operating = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _refreshDevices());
|
||||
}
|
||||
|
||||
Future<void> _refreshDevices() async {
|
||||
setState(() => _loadingDevices = true);
|
||||
try {
|
||||
final service = ref.read(serialPortServiceProvider);
|
||||
final list = await service.listDevices();
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_devices = list;
|
||||
_loadingDevices = false;
|
||||
if (_selectedDevice == null && list.isNotEmpty) {
|
||||
_selectedDevice = list.first;
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() => _loadingDevices = false);
|
||||
_showSnack('扫描设备失败: $e', AppTheme.errorColor);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _connect() async {
|
||||
final device = _selectedDevice;
|
||||
if (device == null) {
|
||||
_showSnack('请先选择串口设备', AppTheme.warningColor);
|
||||
return;
|
||||
}
|
||||
setState(() => _operating = true);
|
||||
final service = ref.read(serialPortServiceProvider);
|
||||
final config = ref.read(serialConfigProvider);
|
||||
final ok = await service.connect(device, config);
|
||||
if (!mounted) return;
|
||||
setState(() => _operating = false);
|
||||
_showSnack(
|
||||
ok ? '连接成功' : '连接失败: ${service.lastError ?? "未知错误"}',
|
||||
ok ? AppTheme.successColor : AppTheme.errorColor,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _disconnect() async {
|
||||
setState(() => _operating = true);
|
||||
final service = ref.read(serialPortServiceProvider);
|
||||
await service.disconnect();
|
||||
if (!mounted) return;
|
||||
setState(() => _operating = false);
|
||||
_showSnack('已断开串口', AppTheme.infoColor);
|
||||
}
|
||||
|
||||
Future<void> _testConnection() async {
|
||||
final service = ref.read(serialPortServiceProvider);
|
||||
if (!service.isConnected) {
|
||||
_showSnack('请先连接串口', AppTheme.warningColor);
|
||||
return;
|
||||
}
|
||||
final msgService = ref.read(deviceMessageServiceProvider);
|
||||
final ok = await msgService.send(DeviceMessage.request(
|
||||
messageId: msgService.nextId(),
|
||||
type: DeviceMessageType.deviceInfo,
|
||||
data: const <String, dynamic>{},
|
||||
needAck: true,
|
||||
));
|
||||
if (!mounted) return;
|
||||
_showSnack(
|
||||
ok ? '已发送 device_info 查询' : '发送失败',
|
||||
ok ? AppTheme.successColor : AppTheme.errorColor,
|
||||
);
|
||||
}
|
||||
|
||||
void _showSnack(String msg, Color color) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(msg), backgroundColor: color),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final config = ref.watch(serialConfigProvider);
|
||||
final state = ref.watch(serialPortServiceProvider).state;
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(0),
|
||||
children: [
|
||||
_buildStatusCard(state),
|
||||
const SizedBox(height: 16),
|
||||
_buildDeviceCard(),
|
||||
const SizedBox(height: 16),
|
||||
_buildParamCard(config),
|
||||
const SizedBox(height: 16),
|
||||
_buildActionRow(),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// -- 状态卡 ----------------------------------------------------------
|
||||
|
||||
Widget _buildStatusCard(SerialConnectionState state) {
|
||||
final color = switch (state) {
|
||||
SerialConnectionState.connected => AppTheme.successColor,
|
||||
SerialConnectionState.connecting => AppTheme.warningColor,
|
||||
SerialConnectionState.error => AppTheme.errorColor,
|
||||
SerialConnectionState.disconnected => AppTheme.idleColor,
|
||||
};
|
||||
final text = switch (state) {
|
||||
SerialConnectionState.connected => '已连接',
|
||||
SerialConnectionState.connecting => '连接中...',
|
||||
SerialConnectionState.error => '错误',
|
||||
SerialConnectionState.disconnected => '未连接',
|
||||
};
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.backgroundColor,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: color.withValues(alpha: 0.4)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 12,
|
||||
height: 12,
|
||||
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text('串口状态: $text',
|
||||
style: TextStyle(
|
||||
color: AppTheme.textPrimary,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// -- 设备列表 --------------------------------------------------------
|
||||
|
||||
Widget _buildDeviceCard() {
|
||||
return _SectionCard(
|
||||
title: '可用串口设备',
|
||||
trailing: TextButton.icon(
|
||||
onPressed: _loadingDevices ? null : _refreshDevices,
|
||||
icon: const Icon(Icons.refresh, size: 18),
|
||||
label: const Text('刷新'),
|
||||
),
|
||||
child: _loadingDevices
|
||||
? const Padding(
|
||||
padding: EdgeInsets.all(24),
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
)
|
||||
: _devices.isEmpty
|
||||
? _buildEmptyDevice()
|
||||
: Column(
|
||||
children: _devices
|
||||
.map((d) => _deviceTile(d))
|
||||
.toList(growable: false),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEmptyDevice() {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 24, horizontal: 8),
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(Icons.usb_off, size: 40, color: AppTheme.idleColor),
|
||||
const SizedBox(height: 8),
|
||||
Text('未检测到 USB 串口设备',
|
||||
style: TextStyle(color: AppTheme.textSecondary)),
|
||||
const SizedBox(height: 4),
|
||||
Text('请确认下位机已上电并通过 USB 接入设备',
|
||||
style: TextStyle(color: AppTheme.textSecondary, fontSize: 12)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _deviceTile(UsbDevice d) {
|
||||
final selected = _selectedDevice == d;
|
||||
return InkWell(
|
||||
onTap: () => setState(() => _selectedDevice = d),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: selected
|
||||
? AppTheme.primaryColor.withValues(alpha: 0.08)
|
||||
: Colors.transparent,
|
||||
border: Border(
|
||||
bottom: BorderSide(color: AppTheme.borderSubtle, width: 0.5),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
selected ? Icons.radio_button_checked : Icons.radio_button_off,
|
||||
color: selected ? AppTheme.primaryColor : AppTheme.idleColor,
|
||||
size: 20,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(SerialPortService.deviceLabel(d),
|
||||
style: TextStyle(
|
||||
color: AppTheme.textPrimary,
|
||||
fontWeight: FontWeight.w500,
|
||||
)),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'VID: 0x${(d.vid ?? 0).toRadixString(16).toUpperCase()} '
|
||||
'PID: 0x${(d.pid ?? 0).toRadixString(16).toUpperCase()}',
|
||||
style: TextStyle(
|
||||
color: AppTheme.textSecondary, fontSize: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// -- 参数配置 --------------------------------------------------------
|
||||
|
||||
Widget _buildParamCard(SerialConfig config) {
|
||||
return _SectionCard(
|
||||
title: '串口参数',
|
||||
child: Column(
|
||||
children: [
|
||||
_baudRateRow(config),
|
||||
_dropdownRow<int>(
|
||||
label: '数据位',
|
||||
value: config.dataBits,
|
||||
options: const [5, 6, 7, 8],
|
||||
display: (v) => '$v',
|
||||
onChanged: (v) => _updateConfig((c) => c.copyWith(dataBits: v)),
|
||||
),
|
||||
_dropdownRow<int>(
|
||||
label: '停止位',
|
||||
value: config.stopBits,
|
||||
options: const [1, 2],
|
||||
display: (v) => '$v',
|
||||
onChanged: (v) => _updateConfig((c) => c.copyWith(stopBits: v)),
|
||||
),
|
||||
_dropdownRow<SerialParity>(
|
||||
label: '校验位',
|
||||
value: config.parity,
|
||||
options: SerialParity.values,
|
||||
display: (v) => switch (v) {
|
||||
SerialParity.none => '无',
|
||||
SerialParity.odd => '奇',
|
||||
SerialParity.even => '偶',
|
||||
SerialParity.mark => '标记',
|
||||
SerialParity.space => '空',
|
||||
},
|
||||
onChanged: (v) => _updateConfig((c) => c.copyWith(parity: v)),
|
||||
),
|
||||
_dropdownRow<SerialFlowControl>(
|
||||
label: '流控',
|
||||
value: config.flowControl,
|
||||
options: SerialFlowControl.values,
|
||||
display: (v) => switch (v) {
|
||||
SerialFlowControl.none => '无',
|
||||
SerialFlowControl.rtsCts => 'RTS/CTS',
|
||||
SerialFlowControl.xonXoff => 'XON/XOFF',
|
||||
SerialFlowControl.dtrDsr => 'DTR/DSR',
|
||||
},
|
||||
onChanged: (v) => _updateConfig((c) => c.copyWith(flowControl: v)),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: Text(
|
||||
'参数修改后自动保存',
|
||||
style: TextStyle(color: AppTheme.textSecondary, fontSize: 12),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _baudRateRow(SerialConfig config) {
|
||||
final ctrl = TextEditingController(text: config.baudRate.toString());
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 80,
|
||||
child:
|
||||
Text('波特率', style: TextStyle(color: AppTheme.textPrimary))),
|
||||
const SizedBox(width: 12),
|
||||
SizedBox(
|
||||
width: 140,
|
||||
child: TextField(
|
||||
controller: ctrl,
|
||||
keyboardType: TextInputType.number,
|
||||
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
|
||||
decoration: const InputDecoration(
|
||||
isDense: true,
|
||||
contentPadding:
|
||||
EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
onSubmitted: (v) => _applyBaudRate(v, config),
|
||||
onEditingComplete: () => _applyBaudRate(ctrl.text, config),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Wrap(
|
||||
spacing: 6,
|
||||
runSpacing: 6,
|
||||
children: SerialConfig.commonBaudRates
|
||||
.map((b) => _baudChip(b, config.baudRate))
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _baudChip(int baud, int current) {
|
||||
final selected = baud == current;
|
||||
return ChoiceChip(
|
||||
label: Text('$baud'),
|
||||
selected: selected,
|
||||
onSelected: (_) => _updateConfig((c) => c.copyWith(baudRate: baud)),
|
||||
labelStyle: TextStyle(
|
||||
color: selected ? Colors.white : AppTheme.textPrimary,
|
||||
fontSize: 12,
|
||||
),
|
||||
selectedColor: AppTheme.primaryColor,
|
||||
backgroundColor: AppTheme.backgroundColor,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _dropdownRow<T>({
|
||||
required String label,
|
||||
required T value,
|
||||
required List<T> options,
|
||||
required String Function(T) display,
|
||||
required ValueChanged<T> onChanged,
|
||||
}) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 80,
|
||||
child: Text(label, style: TextStyle(color: AppTheme.textPrimary))),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: DropdownButtonFormField<T>(
|
||||
value: value,
|
||||
isDense: true,
|
||||
decoration: const InputDecoration(
|
||||
isDense: true,
|
||||
contentPadding:
|
||||
EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
items: options
|
||||
.map((o) => DropdownMenuItem(
|
||||
value: o,
|
||||
child: Text(display(o)),
|
||||
))
|
||||
.toList(),
|
||||
onChanged: (v) {
|
||||
if (v != null) onChanged(v);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _applyBaudRate(String raw, SerialConfig config) {
|
||||
final v = int.tryParse(raw.trim());
|
||||
if (v == null || v <= 0) return;
|
||||
if (v == config.baudRate) return;
|
||||
_updateConfig((c) => c.copyWith(baudRate: v));
|
||||
}
|
||||
|
||||
Future<void> _updateConfig(
|
||||
SerialConfig Function(SerialConfig) mutator) async {
|
||||
await ref.read(serialConfigProvider.notifier).update(mutator);
|
||||
}
|
||||
|
||||
// -- 操作按钮 --------------------------------------------------------
|
||||
|
||||
Widget _buildActionRow() {
|
||||
return Wrap(
|
||||
spacing: 12,
|
||||
runSpacing: 12,
|
||||
children: [
|
||||
ElevatedButton.icon(
|
||||
onPressed: _operating ? null : _connect,
|
||||
icon: const Icon(Icons.link),
|
||||
label: const Text('连接'),
|
||||
),
|
||||
ElevatedButton.icon(
|
||||
onPressed:
|
||||
_operating ? null : _disconnect,
|
||||
style: ElevatedButton.styleFrom(backgroundColor: AppTheme.errorColor),
|
||||
icon: const Icon(Icons.link_off),
|
||||
label: const Text('断开'),
|
||||
),
|
||||
OutlinedButton.icon(
|
||||
onPressed: _operating ? null : _testConnection,
|
||||
icon: const Icon(Icons.send),
|
||||
label: const Text('发送测试帧'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SectionCard extends StatelessWidget {
|
||||
final String title;
|
||||
final Widget? trailing;
|
||||
final Widget child;
|
||||
|
||||
const _SectionCard({
|
||||
required this.title,
|
||||
required this.child,
|
||||
this.trailing,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: AppTheme.borderSubtle),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
color: AppTheme.textPrimary,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
?trailing,
|
||||
],
|
||||
),
|
||||
const Divider(),
|
||||
child,
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user