diff --git a/lib/features/device/models/device_state.dart b/lib/features/device/models/device_state.dart index 7476320..708ad79 100644 --- a/lib/features/device/models/device_state.dart +++ b/lib/features/device/models/device_state.dart @@ -1,18 +1,64 @@ -/// 设备状态模型 -enum DeviceStatus { idle, running, paused, error } +/// 设备运行状态 +/// +/// 与下位机 `device_info.task_status` 字段对齐: +/// - `running` 运行中 +/// - `pause` 暂停 +/// - `idle` 空闲 +enum DeviceTaskStatus { running, pause, idle } -/// 设备状态数据 +/// 设备门状态 +/// +/// 与下位机 `device_info.door_status` 字段对齐: +/// - `open` 开 +/// - `close` 关 +enum DeviceDoorStatus { open, close } + +/// 设备灯光状态 +/// +/// 与下位机 `device_info.light_status` / `light_control.status` 字段对齐: +/// - `on` 开 +/// - `off` 关 +enum DeviceLightStatus { on, off } + +/// 上位机内部统一的设备状态模型 +/// +/// 既承载 [DeviceState](运行态监控),也承载下位机 `device_info` 上报 +/// 的门状态 / 灯光状态 / 任务状态,供 UI 各处订阅展示与联动。 class DeviceState { + /// 本机内部识别的运行状态(用于驱动运行/暂停/完成流程) final DeviceStatus status; - final String? currentProgram; - final String? currentPosition; - final int? currentStepNo; - final String? currentStepName; - final int? remainingSeconds; - final double? progress; - final bool lightingOn; - DeviceState({ + /// 当前运行程序名(仅运行态有效) + final String? currentProgram; + + /// 当前步骤的孔位(例如 A1) + final String? currentPosition; + + /// 当前步骤号 + final int? currentStepNo; + + /// 当前步骤名 + final String? currentStepName; + + /// 当前步骤剩余时间(秒) + final int? remainingSeconds; + + /// 总进度 [0, 1] + final double? progress; + + /// 灯光状态(来自 device_info,主动权在下位机) + final DeviceLightStatus lightStatus; + + /// 门状态(来自 device_info) + final DeviceDoorStatus doorStatus; + + /// 任务状态(来自 device_info) + final DeviceTaskStatus taskStatus; + + /// 上次 device_info 上报的时间,便于 UI 显示「通讯正常 / 已断开」 + final DateTime? lastInfoAt; + + const DeviceState({ this.status = DeviceStatus.idle, this.currentProgram, this.currentPosition, @@ -20,7 +66,10 @@ class DeviceState { this.currentStepName, this.remainingSeconds, this.progress, - this.lightingOn = false, + this.lightStatus = DeviceLightStatus.off, + this.doorStatus = DeviceDoorStatus.close, + this.taskStatus = DeviceTaskStatus.idle, + this.lastInfoAt, }); bool get isRunning => status == DeviceStatus.running; @@ -28,6 +77,18 @@ class DeviceState { bool get isIdle => status == DeviceStatus.idle; bool get hasError => status == DeviceStatus.error; + bool get lightingOn => lightStatus == DeviceLightStatus.on; + bool get doorOpen => doorStatus == DeviceDoorStatus.open; + + /// 下位机超过该时间未上报则视为通讯异常 + static const Duration infoStaleAfter = Duration(seconds: 10); + + bool get infoStale { + final ts = lastInfoAt; + if (ts == null) return true; + return DateTime.now().difference(ts) > infoStaleAfter; + } + String statusText() { switch (status) { case DeviceStatus.running: @@ -46,7 +107,9 @@ class DeviceState { final hours = remainingSeconds! ~/ 3600; final minutes = (remainingSeconds! % 3600) ~/ 60; final seconds = remainingSeconds! % 60; - return '${hours.toString().padLeft(2, '0')}:${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}'; + return '${hours.toString().padLeft(2, '0')}:' + '${minutes.toString().padLeft(2, '0')}:' + '${seconds.toString().padLeft(2, '0')}'; } DeviceState copyWith({ @@ -57,17 +120,28 @@ class DeviceState { String? currentStepName, int? remainingSeconds, double? progress, - bool? lightingOn, + DeviceLightStatus? lightStatus, + DeviceDoorStatus? doorStatus, + DeviceTaskStatus? taskStatus, + DateTime? lastInfoAt, + bool clearProgram = false, + bool clearProgress = false, }) { return DeviceState( status: status ?? this.status, - currentProgram: currentProgram ?? this.currentProgram, + currentProgram: clearProgram ? null : (currentProgram ?? this.currentProgram), currentPosition: currentPosition ?? this.currentPosition, currentStepNo: currentStepNo ?? this.currentStepNo, currentStepName: currentStepName ?? this.currentStepName, remainingSeconds: remainingSeconds ?? this.remainingSeconds, - progress: progress ?? this.progress, - lightingOn: lightingOn ?? this.lightingOn, + progress: clearProgress ? null : (progress ?? this.progress), + lightStatus: lightStatus ?? this.lightStatus, + doorStatus: doorStatus ?? this.doorStatus, + taskStatus: taskStatus ?? this.taskStatus, + lastInfoAt: lastInfoAt ?? this.lastInfoAt, ); } -} \ No newline at end of file +} + +/// 简单的状态枚举(与 [DeviceState.status] 配合使用) +enum DeviceStatus { idle, running, paused, error } diff --git a/lib/features/device/providers/device_info_provider.dart b/lib/features/device/providers/device_info_provider.dart new file mode 100644 index 0000000..cc7550a --- /dev/null +++ b/lib/features/device/providers/device_info_provider.dart @@ -0,0 +1,123 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../models/device_state.dart'; +import '../services/device_message.dart'; +import '../services/device_message_service.dart'; +import 'serial_provider.dart'; + +/// 设备信息通知器 +/// +/// 订阅 [DeviceMessageService] 中的 `device_info` 上行消息, +/// 维护门状态 / 灯状态 / 任务状态,供 UI 直接读取。 +class DeviceInfoNotifier extends StateNotifier { + final DeviceMessageService _msgService; + void Function()? _cancelSub; + + DeviceInfoNotifier(this._msgService) : super(const DeviceState()) { + _cancelSub = _msgService.subscribe( + DeviceMessageType.deviceInfo, + _onDeviceInfo, + ); + // 串口断开时无需主动清空 lastInfoAt —— infoStale 会根据 + // 当前时间与 lastInfoAt 的差值自动判断为「通讯异常」。 + } + + void _onDeviceInfo(DeviceMessage msg) { + final data = msg.data; + final light = _parseLight(data['light_status']); + final door = _parseDoor(data['door_status']); + final task = _parseTask(data['task_status']); + state = state.copyWith( + lightStatus: light, + doorStatus: door, + taskStatus: task, + lastInfoAt: DateTime.now(), + ); + } + + /// 主动查询设备信息(发送 need_ack=true 的 create_task 之外的 device_info 请求, + /// 具体取决于下位机协议约定;目前仅作占位)。 + Future queryDeviceInfo() async { + if (!_msgService.canSend) return; + final id = _msgService.nextId(); + await _msgService.send(DeviceMessage.request( + messageId: id, + type: DeviceMessageType.deviceInfo, + data: const {}, + needAck: true, + )); + } + + /// 发送灯光控制消息 + /// + /// 主动切换后,下位机通常会通过 device_info 上报新的 light_status + /// 覆盖本地状态。 + Future toggleLight() async { + if (!_msgService.canSend) return false; + final next = state.lightStatus == DeviceLightStatus.on ? 'off' : 'on'; + final id = _msgService.nextId(); + final ok = await _msgService.send(DeviceMessage.request( + messageId: id, + type: DeviceMessageType.lightControl, + data: {'status': next}, + needAck: true, + )); + if (ok) { + // 乐观更新;下位机的 device_info 上报会作为最终一致来源 + state = state.copyWith( + lightStatus: next == 'on' ? DeviceLightStatus.on : DeviceLightStatus.off, + ); + } + return ok; + } + + /// 显式设定灯光 + Future setLight(bool on) async { + if (!_msgService.canSend) return false; + if (on && state.lightStatus == DeviceLightStatus.on) return true; + if (!on && state.lightStatus == DeviceLightStatus.off) return true; + final id = _msgService.nextId(); + final ok = await _msgService.send(DeviceMessage.request( + messageId: id, + type: DeviceMessageType.lightControl, + data: {'status': on ? 'on' : 'off'}, + needAck: true, + )); + if (ok) { + state = state.copyWith( + lightStatus: on ? DeviceLightStatus.on : DeviceLightStatus.off, + ); + } + return ok; + } + + @override + void dispose() { + _cancelSub?.call(); + _cancelSub = null; + super.dispose(); + } + + // -- 解析 ------------------------------------------------------------- + + static DeviceLightStatus _parseLight(dynamic v) => + v == 'on' ? DeviceLightStatus.on : DeviceLightStatus.off; + + static DeviceDoorStatus _parseDoor(dynamic v) => + v == 'open' ? DeviceDoorStatus.open : DeviceDoorStatus.close; + + static DeviceTaskStatus _parseTask(dynamic v) { + return switch (v) { + 'running' => DeviceTaskStatus.running, + 'pause' => DeviceTaskStatus.pause, + _ => DeviceTaskStatus.idle, + }; + } +} + +/// 设备信息 Provider +final deviceInfoProvider = + StateNotifierProvider((ref) { + final service = ref.watch(deviceMessageServiceProvider); + return DeviceInfoNotifier(service); +}); diff --git a/lib/features/device/providers/serial_provider.dart b/lib/features/device/providers/serial_provider.dart new file mode 100644 index 0000000..6078366 --- /dev/null +++ b/lib/features/device/providers/serial_provider.dart @@ -0,0 +1,82 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../core/database/database_service.dart'; +import '../models/serial_config.dart'; +import '../services/device_message_service.dart'; +import '../services/json_protocol.dart'; +import '../services/runner_interface.dart'; +import '../services/serial_port_service.dart'; +import '../services/serial_runner.dart'; + +/// 串口服务单例 +final serialPortServiceProvider = Provider((ref) { + final service = SerialPortService(); + ref.onDispose(service.dispose); + return service; +}); + +/// JSON 协议编解码器(可在调试/真机协议不一致时整体替换) +final jsonProtocolProvider = Provider((ref) { + return JsonProtocolService(); +}); + +/// 设备消息分发服务 +/// +/// 集中处理 JSON 消息的发送与订阅;下游 Provider 各自订阅感兴趣的类型。 +final deviceMessageServiceProvider = + Provider((ref) { + final serial = ref.watch(serialPortServiceProvider); + final protocol = ref.watch(jsonProtocolProvider); + final service = DeviceMessageService(serial: serial, protocol: protocol); + ref.onDispose(service.dispose); + return service; +}); + +/// 当前串口配置(设置页修改后通过 notifier 写入并持久化) +class SerialConfigNotifier extends StateNotifier { + final SettingsConfigRepository _repo; + + SerialConfigNotifier(this._repo) : super(SerialConfig.defaults) { + _load(); + } + + Future _load() async { + state = await _repo.read(); + } + + /// 修改并持久化 + Future update(SerialConfig Function(SerialConfig) mutator) async { + final next = mutator(state); + state = next; + await _repo.write(next); + } + + /// 重置为默认值 + Future reset() => update((_) => SerialConfig.defaults); +} + +/// 串口配置仓库:把 [SerialConfig] 以 JSON 形式存到 settings 表 +class SettingsConfigRepository { + static const _key = 'serial_config'; + final DatabaseService _db = DatabaseService.instance; + + Future read() async { + final raw = await _db.readSetting(_key); + return SerialConfig.fromJsonString(raw); + } + + Future write(SerialConfig config) async { + await _db.writeSetting(_key, config.toJsonString()); + } +} + +final serialConfigProvider = + StateNotifierProvider((ref) { + return SerialConfigNotifier(SettingsConfigRepository()); +}); + +/// 运行器实例(基于 JSON 协议与设备消息服务) +final runnerProvider = Provider((ref) { + final msgService = ref.watch(deviceMessageServiceProvider); + return JsonSerialRunner(messageService: msgService); +}); diff --git a/lib/features/device/services/device_message.dart b/lib/features/device/services/device_message.dart new file mode 100644 index 0000000..a1115c4 --- /dev/null +++ b/lib/features/device/services/device_message.dart @@ -0,0 +1,164 @@ +import 'dart:convert'; + +/// 下位机消息类型 +/// +/// 对应《下位机交互数据模型.md》中定义的四种 type 字段。 +enum DeviceMessageType { + /// 设备基本信息:门状态 / 任务运行状态 / 灯状态 + deviceInfo('device_info'), + + /// 下发任务:步骤列表 + 温度 + 吹气时间 + createTask('create_task'), + + /// 灯光控制:开 / 关 + lightControl('light_control'), + + /// 任务控制:继续 / 停止 / 暂停 + control('control'); + + const DeviceMessageType(this.wireName); + + /// 协议层使用的字符串名称 + final String wireName; + + /// 从协议字符串解析消息类型;未知值返回 null + static DeviceMessageType? fromWire(String? name) { + if (name == null) return null; + for (final t in values) { + if (t.wireName == name) return t; + } + return null; + } +} + +/// 下位机消息 +/// +/// 完整对应数据模型: +/// ```json +/// { +/// "message_id": "uuid", +/// "type": "device_info", +/// "ack": "uuid-of-original", +/// "need_ack": true, +/// "data": { ... } +/// } +/// ``` +class DeviceMessage { + /// 唯一识别码。发送时由调用方生成(UUID 字符串); + /// 接收时来自协议,便于 ack 关联。 + final String messageId; + + /// 消息类型 + final DeviceMessageType type; + + /// 当本条消息是某条消息的响应时,填写被响应的 message_id;否则为 null + final String? ack; + + /// 是否需要对方响应 + final bool needAck; + + /// 业务负载;具体结构由 [type] 决定 + final Map data; + + const DeviceMessage({ + required this.messageId, + required this.type, + required this.data, + this.ack, + this.needAck = false, + }); + + /// 构造主动请求时使用的便捷工厂 + factory DeviceMessage.request({ + required DeviceMessageType type, + required Map data, + required String messageId, + bool needAck = false, + }) { + return DeviceMessage( + messageId: messageId, + type: type, + data: data, + ack: null, + needAck: needAck, + ); + } + + /// 构造应答时使用的便捷工厂 + factory DeviceMessage.ackFor(DeviceMessage original, Map data) { + return DeviceMessage( + messageId: '${original.messageId}-ack', + type: original.type, + data: data, + ack: original.messageId, + needAck: false, + ); + } + + /// 序列化为协议层 Map + Map toJson() => { + 'message_id': messageId, + 'type': type.wireName, + 'ack': ack, + 'need_ack': needAck, + 'data': data, + }; + + /// 序列化为 JSON 字符串 + String encode() => jsonEncode(toJson()); + + /// 从 JSON Map 解析;不抛异常,解析失败时返回 null + static DeviceMessage? fromJson(Map json) { + final type = DeviceMessageType.fromWire(json['type'] as String?); + if (type == null) return null; + final messageId = json['message_id'] as String?; + if (messageId == null || messageId.isEmpty) return null; + return DeviceMessage( + messageId: messageId, + type: type, + ack: (json['ack'] as String?)?.isEmpty == true ? null : json['ack'] as String?, + needAck: json['need_ack'] as bool? ?? false, + data: _coerceData(json['data']), + ); + } + + /// 从 JSON 字符串解析;解析失败时返回 null + static DeviceMessage? decode(String raw) { + try { + final map = jsonDecode(raw); + if (map is! Map) return null; + return fromJson(map); + } catch (_) { + return null; + } + } + + /// JSON 中的 data 既可能是 Map,也可能是字符串(容错处理) + static Map _coerceData(dynamic raw) { + if (raw is Map) return raw; + if (raw is Map) return Map.from(raw); + return {}; + } + + @override + String toString() => 'DeviceMessage(id=$messageId, type=${type.wireName}, ' + 'ack=$ack, needAck=$needAck, data=${data.keys.toList()})'; +} + +/// 消息 ID 生成器 +/// +/// 使用时间戳 + 随机数生成全局唯一 ID(避免引入 uuid 依赖)。 +/// 格式:`-`,例如 `1717500000000-1a2b3c` +class MessageIdGenerator { + int _counter = 0; + + /// 生成下一个唯一 ID + String next() { + _counter = (_counter + 1) & 0xFFFFFF; + final ts = DateTime.now().millisecondsSinceEpoch.toRadixString(36); + final rand = (_counter.toRadixString(36) + + (DateTime.now().microsecondsSinceEpoch & 0xFFFF).toRadixString(36)) + .padLeft(4, '0'); + return '$ts-$rand'; + } +} diff --git a/lib/features/device/services/device_message_service.dart b/lib/features/device/services/device_message_service.dart new file mode 100644 index 0000000..b696b02 --- /dev/null +++ b/lib/features/device/services/device_message_service.dart @@ -0,0 +1,147 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'serial_port_service.dart'; +import 'device_message.dart'; +import 'json_protocol.dart'; + +/// 设备消息分发器 +/// +/// 集中处理与下位机之间的 JSON 消息收发: +/// 1. 接收:监听串口原始字节,调用 [JsonProtocolService] 解码为 [DeviceMessage], +/// 按 type 分发到对应订阅者; +/// 2. 发送:把 [DeviceMessage] 编码为字节流写入串口。 +/// +/// 各业务方(SerialRunner / DeviceInfoNotifier / LightControl)只需关心 +/// 自己订阅/发送的消息类型,不必直接接触串口。 +class DeviceMessageService { + final SerialPortService _serial; + final JsonProtocolService _protocol; + + StreamSubscription? _rxSub; + StreamSubscription? _errorSub; + StreamSubscription? _stateSub; + + final Map> _handlers = {}; + + /// 错误信息流(解码错误、串口异常等) + final _errorCtrl = StreamController.broadcast(); + + /// 任何消息流的广播(用于调试 / 日志) + final _messageCtrl = StreamController.broadcast(); + + DeviceMessageService({ + required SerialPortService serial, + JsonProtocolService? protocol, + }) : _serial = serial, + _protocol = protocol ?? JsonProtocolService() { + _subscribe(); + } + + Stream get onError => _errorCtrl.stream; + Stream get onMessage => _messageCtrl.stream; + + /// 串口连接状态变更(透传自 [SerialPortService]) + Stream get connectionStateChanges => + _serial.connectionStateChanges; + + /// 是否可发送消息(已连接且已初始化) + bool get canSend => _serial.isConnected; + + /// 当前连接状态 + SerialConnectionState get connectionState => _serial.state; + + /// 生成下一个消息 ID + String nextId() => _protocol.nextId(); + + /// 订阅指定 [type] 的消息;返回取消订阅的函数 + void Function() subscribe(DeviceMessageType type, void Function(DeviceMessage) handler) { + _handlers.putIfAbsent(type, () => []); + _handlers[type]!.add(handler); + return () { + final list = _handlers[type]; + if (list != null) list.remove(handler); + }; + } + + /// 发送消息到下位机;返回是否成功写入 + Future send(DeviceMessage message) async { + if (!_serial.isConnected) { + _emitError('串口未连接,无法发送 ${message.type.wireName}'); + return false; + } + try { + final bytes = _protocol.encode(message); + final written = await _serial.write(bytes); + if (written == 0) { + _emitError('写入失败:${message.type.wireName}'); + return false; + } + _messageCtrl.add(message); + return true; + } catch (e) { + _emitError('编码异常: $e'); + return false; + } + } + + /// 释放资源 + Future dispose() async { + await _rxSub?.cancel(); + await _errorSub?.cancel(); + await _stateSub?.cancel(); + _rxSub = _errorSub = _stateSub = null; + _handlers.clear(); + await _errorCtrl.close(); + await _messageCtrl.close(); + } + + // -- 私有方法 --------------------------------------------------------- + + void _subscribe() { + _rxSub?.cancel(); + _errorSub?.cancel(); + _stateSub?.cancel(); + + _rxSub = _serial.onData.listen(_onData); + _errorSub = _serial.onError.listen(_emitError); + _stateSub = _serial.connectionStateChanges.listen((s) { + if (s == SerialConnectionState.disconnected) { + _protocol.reset(); + } + }); + } + + void _onData(Uint8List data) { + if (data.isEmpty) return; + // 反复解析直到缓冲区无法形成完整帧 + while (true) { + final (msg, consumed) = _protocol.tryDecode(data); + if (msg == null) { + if (consumed > 0 && data.length >= consumed) { + data = Uint8List.sublistView(data, consumed); + } + return; + } + data = Uint8List.sublistView(data, consumed); + _dispatch(msg); + } + } + + void _dispatch(DeviceMessage msg) { + _messageCtrl.add(msg); + final list = _handlers[msg.type]; + if (list == null) return; + for (final handler in List.of(list)) { + try { + handler(msg); + } catch (e) { + _emitError('消息处理异常 (${msg.type.wireName}): $e'); + } + } + } + + void _emitError(String message) { + if (!_errorCtrl.isClosed) _errorCtrl.add(message); + } +} diff --git a/lib/features/device/services/json_protocol.dart b/lib/features/device/services/json_protocol.dart new file mode 100644 index 0000000..a2f2bd9 --- /dev/null +++ b/lib/features/device/services/json_protocol.dart @@ -0,0 +1,95 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'device_message.dart'; + +/// JSON 协议层帧编解码器 +/// +/// 由于串口为字节流,需要在 JSON 字符串之外增加帧定界。 +/// 采用「4 字节大端长度前缀 + UTF-8 JSON + 换行符」的简单可靠方案: +/// ``` +/// [LEN(4B BE)] [JSON_UTF8...] ['\n'] +/// ``` +/// - LEN:JSON 部分的字节数(不含 LEN 自身与换行符) +/// - JSON:完整 `DeviceMessage.toJson()` 序列化结果 +/// - 换行符:辅助日志/调试,便于人工嗅探串口 +/// +/// 解析器按累积缓冲区增量解码,单次返回一条已完整解析的消息。 +class JsonProtocolService { + static const int _lengthPrefixSize = 4; + static const int _maxFrameBytes = 64 * 1024; + + final MessageIdGenerator _idGenerator = MessageIdGenerator(); + final List _buffer = []; + + /// 生成下一个消息 ID + String nextId() => _idGenerator.next(); + + /// 将消息编码为可下发的字节流 + Uint8List encode(DeviceMessage message) { + final json = utf8.encode(message.encode()); + if (json.isEmpty) { + throw ArgumentError('encoded JSON is empty'); + } + if (json.length > _maxFrameBytes) { + throw ArgumentError('message too large: ${json.length}B'); + } + final buf = Uint8List(_lengthPrefixSize + json.length + 1); + // 大端长度 + final len = json.length; + buf[0] = (len >> 24) & 0xFF; + buf[1] = (len >> 16) & 0xFF; + buf[2] = (len >> 8) & 0xFF; + buf[3] = len & 0xFF; + buf.setRange(_lengthPrefixSize, _lengthPrefixSize + json.length, json); + buf[buf.length - 1] = 0x0A; // '\n' + return buf; + } + + /// 尝试从累积缓冲区解析一条完整消息 + /// + /// 返回 (message, consumedBytes)。consumedBytes 表示已消费字节数, + /// 调用方应从缓冲区中移除。 + /// 不足一帧时返回 (null, 0)。 + (DeviceMessage?, int) tryDecode(List incoming) { + if (incoming.isEmpty) return (null, 0); + _buffer.addAll(incoming); + + // 防止缓冲区无限增长 + if (_buffer.length > _maxFrameBytes * 2) { + _buffer.removeRange(0, _buffer.length - _maxFrameBytes); + } + + if (_buffer.length < _lengthPrefixSize) return (null, 0); + + final len = (_buffer[0] << 24) | + (_buffer[1] << 16) | + (_buffer[2] << 8) | + _buffer[3]; + if (len <= 0 || len > _maxFrameBytes) { + // 长度异常,丢弃首字节重新对齐 + _buffer.removeAt(0); + return (null, 0); + } + + final totalNeeded = _lengthPrefixSize + len + 1; // +1 换行符 + if (_buffer.length < totalNeeded) return (null, 0); + + final jsonBytes = _buffer.sublist(_lengthPrefixSize, _lengthPrefixSize + len); + final tail = _buffer[_lengthPrefixSize + len]; + // 换行符不是必需的,缺失也接受;存在则跳过 + final consumed = tail == 0x0A ? totalNeeded : totalNeeded - 1; + _buffer.removeRange(0, consumed); + + try { + final json = utf8.decode(jsonBytes); + final msg = DeviceMessage.decode(json); + return (msg, consumed); + } catch (_) { + return (null, consumed); + } + } + + /// 重置内部缓冲区(断线/异常时调用) + void reset() => _buffer.clear(); +} diff --git a/lib/features/device/services/serial_runner.dart b/lib/features/device/services/serial_runner.dart index 5d8239d..c1d76a6 100644 --- a/lib/features/device/services/serial_runner.dart +++ b/lib/features/device/services/serial_runner.dart @@ -1,63 +1,100 @@ +import 'dart:async'; + import '../../programs/models/program.dart'; import '../../programs/models/step.dart'; +import 'device_message.dart'; +import 'device_message_service.dart'; import 'runner_interface.dart'; +import 'task_payload.dart'; -/// 串口运行器(真实硬件实现) -/// 实现与设备的串口通信 -class SerialRunner implements Runner { +/// JSON 协议运行器 +/// +/// 与下位机的程序运行相关通信(create_task / control)通过 +/// [DeviceMessageService] 完成;运行过程中下位机可通过 ack 消息确认动作, +/// 步骤进度仍由下位机主动上报(具体协议待硬件侧确认)。 +/// +/// 当前实现: +/// 1. start → 发送 `create_task` 消息(need_ack=true),收到 ack 后切到 running; +/// 2. pause → 发送 `control{status:pause}`,切到 paused; +/// 3. resume → 发送 `control{status:continue}`,切到 running; +/// 4. stop → 发送 `control{status:stop}`,切到 idle。 +/// +/// 为兜底下位机不主动上报完成的情况,保留本地倒计时 + 步骤推进。 +class JsonSerialRunner implements Runner { @override RunnerStatus status = RunnerStatus.idle; - /// 串口配置 - final String portName; - final int baudRate; - final int dataBits; - final int stopBits; + final DeviceMessageService _msg; + // ignore: unused_field 持有当前运行的程序引用,便于调试与未来扩展 + Program? _program; + List _steps = const []; + RunnerCallbacks? _callbacks; - SerialRunner({ - this.portName = '/dev/ttyUSB0', - this.baudRate = 9600, - this.dataBits = 8, - this.stopBits = 1, - }); + void Function()? _cancelAckSub; + Timer? _ticker; + int _currentStepIndex = 0; + int _remainingSeconds = 0; + String? _pendingCreateTaskId; + String? _pendingControlId; + + JsonSerialRunner({required DeviceMessageService messageService}) + : _msg = messageService { + _cancelAckSub = _msg.subscribe(DeviceMessageType.createTask, _onCreateTaskAck); + _msg.subscribe(DeviceMessageType.control, _onControlAck); + } @override void start(Program program, List steps, RunnerCallbacks callbacks) { - // TODO: 实现串口通信启动逻辑 - // 1. 打开串口连接 - // 2. 发送程序配置 - // 3. 按步骤发送控制指令 - // 4. 接收设备反馈并更新状态 + if (status == RunnerStatus.running) { + callbacks.onError?.call('已有程序在运行中'); + return; + } + if (steps.isEmpty) { + callbacks.onError?.call('程序步骤为空'); + status = RunnerStatus.error; + return; + } - status = RunnerStatus.running; + _program = program; + _steps = List.unmodifiable(steps); + _callbacks = callbacks; + _currentStepIndex = 0; + _remainingSeconds = _stepTotalSeconds(steps[0]); - // 示例:发送启动指令 - // _sendCommand('START', program.code); - - // 示例:监听设备状态 - // _listenToDevice(callbacks); + final payload = TaskPayload.fromProgram(program, steps); + final messageId = _msg.nextId(); + _pendingCreateTaskId = messageId; + final msg = payload.toMessage(messageId, needAck: true); + _msg.send(msg).then((ok) { + if (!ok) { + _pendingCreateTaskId = null; + status = RunnerStatus.error; + _callbacks?.onError?.call('下发任务失败:串口写入错误'); + } + }); } @override void pause() { - if (status == RunnerStatus.running) { - // _sendCommand('PAUSE'); - status = RunnerStatus.paused; - } + if (status != RunnerStatus.running) return; + _sendControl('pause'); + _stopLocalTicker(); + status = RunnerStatus.paused; } @override void resume() { - if (status == RunnerStatus.paused) { - // _sendCommand('RESUME'); - status = RunnerStatus.running; - } + if (status != RunnerStatus.paused) return; + _sendControl('continue'); + status = RunnerStatus.running; + _startLocalTicker(); } @override void stop() { - // _sendCommand('STOP'); - // _closeConnection(); + if (status == RunnerStatus.idle) return; + _sendControl('stop'); + _teardown(); status = RunnerStatus.idle; } @@ -66,26 +103,102 @@ class SerialRunner implements Runner { @override void dispose() { - stop(); + _teardown(); + _cancelAckSub?.call(); + _cancelAckSub = null; } - /// 发送控制指令(待硬件协议确定后实现) - Future _sendCommand(String command, [String? data]) async { - // TODO: 根据硬件通信协议实现 - // 示例协议格式: [CMD:data] 或 二进制协议 + // -- 私有方法 --------------------------------------------------------- + + void _sendControl(String statusValue) { + final messageId = _msg.nextId(); + _pendingControlId = messageId; + final msg = DeviceMessage.request( + messageId: messageId, + type: DeviceMessageType.control, + data: {'status': statusValue}, + needAck: true, + ); + _msg.send(msg); } - /// 监听设备反馈(待硬件协议确定后实现) - void _listenToDevice(RunnerCallbacks callbacks) { - // TODO: 解析设备返回的状态数据 - // 状态格式示例: [STEP:1,TIME:60,POS:A1] + void _onCreateTaskAck(DeviceMessage ack) { + if (ack.ack != _pendingCreateTaskId) return; + _pendingCreateTaskId = null; + // ack 即视为下位机已接受任务,进入 running 状态 + if (status == RunnerStatus.idle || status == RunnerStatus.error) { + status = RunnerStatus.running; + _startLocalTicker(); + } } - /// 执行单个步骤 - Future _executeStep(Step step) async { - // TODO: 根据步骤参数生成控制指令 - // 混合: MIX(position, time, speed) - // 吸磁: MAGNET(position, time) - // 吹气: BLOW(position, speed, time) + void _onControlAck(DeviceMessage ack) { + if (ack.ack != _pendingControlId) return; + _pendingControlId = null; + // control ack 不修改状态,状态机在调用 pause/resume/stop 时已切过 } -} \ No newline at end of file + + // -- 本地兜底倒计时 --------------------------------------------------- + + void _startLocalTicker() { + _ticker?.cancel(); + _ticker = Timer.periodic(const Duration(seconds: 1), (_) { + if (status != RunnerStatus.running) return; + if (_steps.isEmpty) return; + _remainingSeconds--; + if (_remainingSeconds <= 0) { + _currentStepIndex++; + if (_currentStepIndex >= _steps.length) { + _stopLocalTicker(); + status = RunnerStatus.completed; + _callbacks?.onComplete?.call(); + return; + } + _remainingSeconds = _stepTotalSeconds(_steps[_currentStepIndex]); + } + final well = _steps[_currentStepIndex.clamp(0, _steps.length - 1)] + .position; + _callbacks?.onProgress?.call( + _currentStepIndex, + _remainingSeconds, + _calculateProgress(_currentStepIndex, _remainingSeconds), + well, + ); + }); + } + + void _stopLocalTicker() { + _ticker?.cancel(); + _ticker = null; + } + + void _teardown() { + _stopLocalTicker(); + _program = null; + _steps = const []; + _callbacks = null; + _pendingCreateTaskId = null; + _pendingControlId = null; + } + + int _stepTotalSeconds(Step s) { + final t = s.mixTime + s.magnetTime + s.blowTime; + return t == 0 ? 5 : t; + } + + double _calculateProgress(int stepIndex, int remaining) { + if (_steps.isEmpty) return 0; + var total = 0; + for (final s in _steps) { + total += _stepTotalSeconds(s); + } + if (total <= 0) return 0; + var elapsed = 0; + for (var i = 0; i < stepIndex && i < _steps.length; i++) { + elapsed += _stepTotalSeconds(_steps[i]); + } + final cur = _stepTotalSeconds(_steps[stepIndex.clamp(0, _steps.length - 1)]); + elapsed += (cur - remaining).clamp(0, cur); + return (elapsed / total).clamp(0.0, 1.0); + } +} diff --git a/lib/features/device/services/task_payload.dart b/lib/features/device/services/task_payload.dart new file mode 100644 index 0000000..bf0b112 --- /dev/null +++ b/lib/features/device/services/task_payload.dart @@ -0,0 +1,84 @@ +import 'dart:convert'; + +import '../../programs/models/program.dart'; +import '../../programs/models/step.dart'; +import 'device_message.dart'; + +/// 下位机启动任务负载 +/// +/// 将上位机 [Program] + [Step] 列表转换为下位机协议要求的 `create_task` 数据。 +/// 协议示例: +/// ```json +/// { +/// "steps": [ +/// {"no": 1, "slot": 1, "name": "混合", "mixtime": 60, "pulltime": 30, "volume": 100, "speed": 5} +/// ], +/// "temperature": 50, +/// "airflowtime": 60 +/// } +/// ``` +class TaskPayload { + final List steps; + final int temperature; + final int airflowTime; + + const TaskPayload({ + required this.steps, + required this.temperature, + required this.airflowTime, + }); + + /// 从程序和步骤列表构造负载 + factory TaskPayload.fromProgram(Program program, List steps) { + return TaskPayload( + steps: List.from(steps) + ..sort((a, b) => a.stepNo.compareTo(b.stepNo)), + temperature: program.temperature, + airflowTime: program.airflowTime, + ); + } + + /// 转换为下位机协议 Map + Map toJson() { + return { + 'steps': steps.map((s) => _stepToJson(s)).toList(), + 'temperature': temperature, + 'airflowtime': airflowTime, + }; + } + + /// 序列化为 JSON 字符串 + String encode() => jsonEncode(toJson()); + + /// 包裹为下位机 [DeviceMessage] + DeviceMessage toMessage(String messageId, {bool needAck = true}) { + return DeviceMessage.request( + messageId: messageId, + type: DeviceMessageType.createTask, + data: toJson(), + needAck: needAck, + ); + } + + Map _stepToJson(Step s) { + return { + 'no': s.stepNo, + 'slot': _slotFromPosition(s.position), + 'name': s.name, + 'mixtime': s.mixTime, + 'pulltime': s.magnetTime, + 'volume': s.volume, + 'speed': s.speed, + }; + } + + /// 将孔位(如 "A1")转换为下位机使用的整数编号 + /// 协议约定:A1=1, A2=2, ..., A6=6, B1=7, ..., D6=24 + static int _slotFromPosition(String position) { + if (position.length < 2) return 1; + final row = position.codeUnitAt(0) - 'A'.codeUnitAt(0); + final col = int.tryParse(position.substring(1)) ?? 1; + if (row < 0 || row > 3 || col < 1 || col > 6) return 1; + return row * 6 + col; + } +} diff --git a/lib/features/home/pages/home_page.dart b/lib/features/home/pages/home_page.dart index 08da9f2..82858b2 100644 --- a/lib/features/home/pages/home_page.dart +++ b/lib/features/home/pages/home_page.dart @@ -22,8 +22,6 @@ class HomePage extends ConsumerStatefulWidget { class _HomePageState extends ConsumerState with SingleTickerProviderStateMixin { - bool _lightOn = false; - final bool _ceramicSleeveInstalled = false; // TODO: 后续对接硬件传感器后改为可变状态 int _currentIndex = 0; @override @@ -54,13 +52,6 @@ class _HomePageState extends ConsumerState // 状态栏 StatusBar( isRunning: runState.status == RunStatus.running, - lightOn: _lightOn, - onLightToggle: () { - setState(() { - _lightOn = !_lightOn; - }); - }, - ceramicSleeveInstalled: _ceramicSleeveInstalled, ), // 导航标签栏 diff --git a/lib/features/home/widgets/status_bar.dart b/lib/features/home/widgets/status_bar.dart index c8d64bb..62a411d 100644 --- a/lib/features/home/widgets/status_bar.dart +++ b/lib/features/home/widgets/status_bar.dart @@ -1,30 +1,28 @@ import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../core/localization/app_localizations.dart'; import '../../../core/theme/app_theme.dart'; import '../../../shared/widgets/status_indicator.dart'; +import '../../device/providers/device_info_provider.dart'; /// 状态栏组件 - 明亮工业风格 -/// 显示设备名称、实时时钟、系统状态、照明控制、瓷套棒状态 -class StatusBar extends StatefulWidget { +/// 显示设备名称、实时时钟、系统状态、照明控制 +class StatusBar extends ConsumerStatefulWidget { final bool isRunning; - final bool lightOn; final VoidCallback? onLightToggle; - final bool ceramicSleeveInstalled; const StatusBar({ super.key, this.isRunning = false, - this.lightOn = false, this.onLightToggle, - this.ceramicSleeveInstalled = false, }); @override - State createState() => _StatusBarState(); + ConsumerState createState() => _StatusBarState(); } -class _StatusBarState extends State { +class _StatusBarState extends ConsumerState { String _currentTime = ''; Timer? _timer; @@ -51,9 +49,15 @@ class _StatusBarState extends State { String _twoDigits(int n) => n.toString().padLeft(2, '0'); + Future _onLightTap() async { + widget.onLightToggle?.call(); + await ref.read(deviceInfoProvider.notifier).toggleLight(); + } + @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); + final deviceInfo = ref.watch(deviceInfoProvider); return Container( height: 56, @@ -86,9 +90,10 @@ class _StatusBarState extends State { ], ), const Spacer(), - _LightToggleButton(isOn: widget.lightOn, onTap: widget.onLightToggle), - const SizedBox(width: 16), - _CeramicSleeveStatus(installed: widget.ceramicSleeveInstalled), + _LightToggleButton( + isOn: deviceInfo.lightingOn, + onTap: _onLightTap, + ), const SizedBox(width: 20), StatusIndicator( text: widget.isRunning @@ -114,36 +119,6 @@ class _StatusBarState extends State { } } -class _CeramicSleeveStatus extends StatelessWidget { - final bool installed; - const _CeramicSleeveStatus({required this.installed}); - - @override - Widget build(BuildContext context) { - return Row( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - width: 8, - height: 8, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: installed ? Colors.greenAccent : Colors.redAccent, - ), - ), - const SizedBox(width: 6), - Text( - installed ? '瓷套棒: 已安装' : '瓷套棒: 未安装', - style: TextStyle( - color: Colors.white.withValues(alpha: 0.9), - fontSize: 12, - ), - ), - ], - ); - } -} - class _LightToggleButton extends StatelessWidget { final bool isOn; final VoidCallback? onTap; diff --git a/lib/features/settings/widgets/serial_config_panel.dart b/lib/features/settings/widgets/serial_config_panel.dart new file mode 100644 index 0000000..cab8d01 --- /dev/null +++ b/lib/features/settings/widgets/serial_config_panel.dart @@ -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 createState() => _SerialConfigPanelState(); +} + +class _SerialConfigPanelState extends ConsumerState { + List _devices = const []; + UsbDevice? _selectedDevice; + bool _loadingDevices = false; + bool _operating = false; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) => _refreshDevices()); + } + + Future _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 _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 _disconnect() async { + setState(() => _operating = true); + final service = ref.read(serialPortServiceProvider); + await service.disconnect(); + if (!mounted) return; + setState(() => _operating = false); + _showSnack('已断开串口', AppTheme.infoColor); + } + + Future _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 {}, + 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( + label: '数据位', + value: config.dataBits, + options: const [5, 6, 7, 8], + display: (v) => '$v', + onChanged: (v) => _updateConfig((c) => c.copyWith(dataBits: v)), + ), + _dropdownRow( + label: '停止位', + value: config.stopBits, + options: const [1, 2], + display: (v) => '$v', + onChanged: (v) => _updateConfig((c) => c.copyWith(stopBits: v)), + ), + _dropdownRow( + 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( + 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({ + required String label, + required T value, + required List options, + required String Function(T) display, + required ValueChanged 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( + 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 _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, + ], + ), + ); + } +} diff --git a/test/device_protocol_test.dart b/test/device_protocol_test.dart new file mode 100644 index 0000000..5aa81c7 --- /dev/null +++ b/test/device_protocol_test.dart @@ -0,0 +1,221 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:kuaishai2/features/device/models/device_state.dart'; +import 'package:kuaishai2/features/device/services/device_message.dart'; +import 'package:kuaishai2/features/device/services/json_protocol.dart'; +import 'package:kuaishai2/features/device/services/task_payload.dart'; +import 'package:kuaishai2/features/programs/models/program.dart'; +import 'package:kuaishai2/features/programs/models/step.dart'; + +void main() { + group('DeviceMessageType', () { + test('wire names match protocol', () { + expect(DeviceMessageType.deviceInfo.wireName, 'device_info'); + expect(DeviceMessageType.createTask.wireName, 'create_task'); + expect(DeviceMessageType.lightControl.wireName, 'light_control'); + expect(DeviceMessageType.control.wireName, 'control'); + }); + + test('fromWire round-trip', () { + for (final t in DeviceMessageType.values) { + expect(DeviceMessageType.fromWire(t.wireName), t); + } + expect(DeviceMessageType.fromWire(null), isNull); + expect(DeviceMessageType.fromWire('nope'), isNull); + }); + }); + + group('DeviceMessage', () { + test('toJson 与数据模型一致', () { + final msg = DeviceMessage( + messageId: 'abc-1', + type: DeviceMessageType.deviceInfo, + data: const { + 'door_status': 'open', + 'task_status': 'running', + 'light_status': 'on', + }, + ); + final json = msg.toJson(); + expect(json['message_id'], 'abc-1'); + expect(json['type'], 'device_info'); + expect(json['ack'], isNull); + expect(json['need_ack'], isFalse); + expect((json['data'] as Map)['door_status'], 'open'); + }); + + test('encode/decode 闭环', () { + final src = DeviceMessage( + messageId: 'm-001', + type: DeviceMessageType.lightControl, + data: const {'status': 'on'}, + ack: 'reply-001', + needAck: true, + ); + final raw = src.encode(); + final parsed = DeviceMessage.decode(raw); + expect(parsed, isNotNull); + expect(parsed!.messageId, 'm-001'); + expect(parsed.type, DeviceMessageType.lightControl); + expect(parsed.ack, 'reply-001'); + expect(parsed.needAck, isTrue); + expect(parsed.data['status'], 'on'); + }); + + test('decode 非法 JSON 返回 null', () { + expect(DeviceMessage.decode('not-json'), isNull); + expect(DeviceMessage.decode('{"foo":1}'), isNull); + expect(DeviceMessage.decode(jsonEncode({ + 'message_id': '', + 'type': 'device_info', + 'data': {}, + })), isNull); + }); + + test('ackFor 工厂生成应答消息', () { + final original = DeviceMessage( + messageId: 'orig-1', + type: DeviceMessageType.createTask, + data: const {'k': 'v'}, + ); + final ack = DeviceMessage.ackFor(original, {'result': 'ok'}); + expect(ack.ack, 'orig-1'); + expect(ack.type, DeviceMessageType.createTask); + expect(ack.data['result'], 'ok'); + }); + }); + + group('MessageIdGenerator', () { + test('连续生成的 ID 互不相同', () { + final gen = MessageIdGenerator(); + final ids = List.generate(100, (_) => gen.next()).toSet(); + expect(ids.length, 100); + }); + }); + + group('JsonProtocolService', () { + test('encode + tryDecode 闭环', () { + final p = JsonProtocolService(); + final msg = DeviceMessage( + messageId: 'p-1', + type: DeviceMessageType.control, + data: const {'status': 'stop'}, + ); + final bytes = p.encode(msg); + // 4 字节长度前缀 + 至少 1 字节 JSON + 1 字节换行 + expect(bytes.length, greaterThan(5)); + + final (decoded, consumed) = p.tryDecode(bytes); + expect(consumed, bytes.length); + expect(decoded, isNotNull); + expect(decoded!.type, DeviceMessageType.control); + expect(decoded.data['status'], 'stop'); + }); + + test('不足一帧时返回 null', () { + final p = JsonProtocolService(); + final partial = Uint8List.fromList([0x00, 0x00, 0x00]); // 只有 3 字节长度前缀 + final (decoded, consumed) = p.tryDecode(partial); + expect(decoded, isNull); + expect(consumed, 0); + }); + + test('分片到达也能解析', () { + final p = JsonProtocolService(); + final msg = DeviceMessage( + messageId: 'frag-1', + type: DeviceMessageType.createTask, + data: const {'x': 1}, + ); + final bytes = p.encode(msg); + + // 拆成 1 字节 / 剩余两段 + final first = bytes.sublist(0, 1); + final second = bytes.sublist(1, 4); + final third = bytes.sublist(4); + + expect(p.tryDecode(first).$1, isNull); + final (m1, _) = p.tryDecode(second); + expect(m1, isNull); + final (m2, consumed) = p.tryDecode(third); + expect(m2, isNotNull); + expect(m2!.messageId, 'frag-1'); + // consumed = 4 字节长度前缀 + json 长度 + 1 字节换行 + expect(consumed, bytes.length); + }); + + test('长度字段异常时丢弃首字节重新对齐', () { + final p = JsonProtocolService(); + // 长度 = 0xFFFFFF 不可能 + final corrupted = Uint8List.fromList([0xFF, 0xFF, 0xFF, 0xFF, 0x00, 0x00, 0x00, 0x01, 0x7B, 0x7D, 0x0A]); + final (decoded, _) = p.tryDecode(corrupted); + // 长度异常 -> 丢弃 1 字节 + 下一帧有效 + // 第二帧 length=1, json='{', 缺少换行也允许 + expect(decoded, isNull); + }); + }); + + group('TaskPayload → DeviceMessage', () { + test('构造 create_task 消息字段与文档一致', () { + final program = Program( + id: 1, + code: 'P001', + name: 'Demo', + createdAt: '2026-06-04', + temperature: 50, + airflowTime: 60, + ); + final steps = [ + Step( + id: 1, + programId: 1, + stepNo: 1, + position: 'A1', + name: '混合', + mixTime: 60, + magnetTime: 30, + volume: 100, + speed: 5, + ), + ]; + final payload = TaskPayload.fromProgram(program, steps); + final msg = payload.toMessage('mid-1', needAck: true); + + expect(msg.type, DeviceMessageType.createTask); + expect(msg.needAck, isTrue); + expect(msg.data['temperature'], 50); + expect(msg.data['airflowtime'], 60); + + final stepJson = (msg.data['steps'] as List).first as Map; + expect(stepJson['no'], 1); + expect(stepJson['slot'], 1); // A1 -> 1 + expect(stepJson['mixtime'], 60); + expect(stepJson['pulltime'], 30); + expect(stepJson['volume'], 100); + expect(stepJson['speed'], 5); + }); + }); + + group('DeviceState model', () { + test('copyWith 不会修改未传字段', () { + const s = DeviceState( + lightStatus: DeviceLightStatus.on, + doorStatus: DeviceDoorStatus.open, + taskStatus: DeviceTaskStatus.running, + lastInfoAt: null, + ); + final s2 = s.copyWith(lightStatus: DeviceLightStatus.off); + expect(s2.lightStatus, DeviceLightStatus.off); + expect(s2.doorStatus, DeviceDoorStatus.open); + expect(s2.taskStatus, DeviceTaskStatus.running); + }); + + test('infoStale 在 10 秒后为 true', () { + final past = DateTime.now().subtract(const Duration(seconds: 30)); + final s = DeviceState(lastInfoAt: past); + expect(s.infoStale, isTrue); + }); + }); +} diff --git a/test/models_test.dart b/test/models_test.dart index a47806c..ac7720b 100644 --- a/test/models_test.dart +++ b/test/models_test.dart @@ -52,10 +52,8 @@ void main() { mixTime: 60, magnetTime: 30, volume: 100, - mixSpeed: '中速', - blowSpeed: '高速', blowTime: 10, - needleSpeed: 5, + speed: 5, ); final map = step.toMap(); @@ -69,6 +67,7 @@ void main() { expect(fromMap.mixTime, equals(step.mixTime)); expect(fromMap.magnetTime, equals(step.magnetTime)); expect(fromMap.volume, equals(step.volume)); + expect(fromMap.speed, equals(step.speed)); }); test('copyWith should create modified copy', () {