diff --git a/lib/features/device/providers/device_info_provider.dart b/lib/features/device/providers/device_info_provider.dart index cc7550a..d1994d4 100644 --- a/lib/features/device/providers/device_info_provider.dart +++ b/lib/features/device/providers/device_info_provider.dart @@ -1,6 +1,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../models/device_state.dart'; +import '../services/device_log.dart'; import '../services/device_message.dart'; import '../services/device_message_service.dart'; import 'serial_provider.dart'; @@ -27,18 +28,30 @@ class DeviceInfoNotifier extends StateNotifier { final light = _parseLight(data['light_status']); final door = _parseDoor(data['door_status']); final task = _parseTask(data['task_status']); + final prev = state; + final changed = prev.lightStatus != light || + prev.doorStatus != door || + prev.taskStatus != task; state = state.copyWith( lightStatus: light, doorStatus: door, taskStatus: task, lastInfoAt: DateTime.now(), ); + if (changed) { + DeviceLog.info( + 'device_info updated: light=${light.name} door=${door.name} task=${task.name}', + ); + } } /// 主动查询设备信息(发送 need_ack=true 的 create_task 之外的 device_info 请求, /// 具体取决于下位机协议约定;目前仅作占位)。 Future queryDeviceInfo() async { - if (!_msgService.canSend) return; + if (!_msgService.canSend) { + DeviceLog.warn('queryDeviceInfo skipped: serial disconnected'); + return; + } final id = _msgService.nextId(); await _msgService.send(DeviceMessage.request( messageId: id, @@ -53,7 +66,10 @@ class DeviceInfoNotifier extends StateNotifier { /// 主动切换后,下位机通常会通过 device_info 上报新的 light_status /// 覆盖本地状态。 Future toggleLight() async { - if (!_msgService.canSend) return false; + if (!_msgService.canSend) { + DeviceLog.warn('toggleLight skipped: serial disconnected'); + return false; + } final next = state.lightStatus == DeviceLightStatus.on ? 'off' : 'on'; final id = _msgService.nextId(); final ok = await _msgService.send(DeviceMessage.request( @@ -67,13 +83,17 @@ class DeviceInfoNotifier extends StateNotifier { state = state.copyWith( lightStatus: next == 'on' ? DeviceLightStatus.on : DeviceLightStatus.off, ); + DeviceLog.info('toggleLight optimistic: -> $next'); } return ok; } /// 显式设定灯光 Future setLight(bool on) async { - if (!_msgService.canSend) return false; + if (!_msgService.canSend) { + DeviceLog.warn('setLight skipped: serial disconnected'); + return false; + } if (on && state.lightStatus == DeviceLightStatus.on) return true; if (!on && state.lightStatus == DeviceLightStatus.off) return true; final id = _msgService.nextId(); @@ -87,6 +107,7 @@ class DeviceInfoNotifier extends StateNotifier { state = state.copyWith( lightStatus: on ? DeviceLightStatus.on : DeviceLightStatus.off, ); + DeviceLog.info('setLight optimistic: -> ${on ? "on" : "off"}'); } return ok; } diff --git a/lib/features/device/services/device_log.dart b/lib/features/device/services/device_log.dart new file mode 100644 index 0000000..3e4ca04 --- /dev/null +++ b/lib/features/device/services/device_log.dart @@ -0,0 +1,49 @@ +import 'dart:developer' as developer; + +/// 串口/协议层统一日志入口 +/// +/// 使用 `dart:developer.log` 便于 Dart DevTools 抓取与分级。 +/// 所有日志统一以 `kuaishai.device` 为 name 前缀,方便在 DevTools 中过滤。 +class DeviceLog { + static const String _name = 'kuaishai.device'; + + /// 信息级别:正常收发与状态变化 + static void info(String message, {Object? error, Map? context}) { + developer.log(message, name: _name, level: 800, error: error); + } + + /// 警告级别:可恢复的异常(解析错误、写入失败等) + static void warn(String message, {Object? error, Map? context}) { + developer.log( + message, + name: _name, + level: 900, + error: error, + // dart:developer 当前不支持直接附带 context;调用方可在 message 中拼接 + ); + } + + /// 严重级别:连接断开、协议级不可恢复错误 + static void severe(String message, {Object? error, Map? context}) { + developer.log(message, name: _name, level: 1000, error: error); + } + + /// 将 Map 数据截断为可读摘要,避免在日志中泄露超长负载 + static String summarizeData(Map? data, {int maxKeys = 8}) { + if (data == null || data.isEmpty) return '{}'; + final keys = data.keys.take(maxKeys).toList(); + final shown = keys.map((k) { + final v = data[k]; + if (v is String) return '$k="${_truncate(v, 32)}"'; + if (v is List) return '$k=[${v.length}]'; + return '$k=$v'; + }).join(', '); + final suffix = data.length > maxKeys ? ', ...' : ''; + return '{$shown$suffix}'; + } + + static String _truncate(String s, int max) { + if (s.length <= max) return s; + return '${s.substring(0, max)}…'; + } +} diff --git a/lib/features/device/services/device_message_service.dart b/lib/features/device/services/device_message_service.dart index b696b02..f3e3016 100644 --- a/lib/features/device/services/device_message_service.dart +++ b/lib/features/device/services/device_message_service.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:typed_data'; import 'serial_port_service.dart'; +import 'device_log.dart'; import 'device_message.dart'; import 'json_protocol.dart'; @@ -77,10 +78,18 @@ class DeviceMessageService { _emitError('写入失败:${message.type.wireName}'); return false; } + DeviceLog.info( + 'TX ${message.type.wireName} ' + 'id=${message.messageId} ' + 'ack=${message.ack ?? "-"} ' + 'needAck=${message.needAck} ' + 'data=${DeviceLog.summarizeData(message.data)}\n' + ' json=${message.encode()}', + ); _messageCtrl.add(message); return true; } catch (e) { - _emitError('编码异常: $e'); + _emitError('编码异常: $e', error: e); return false; } } @@ -104,7 +113,7 @@ class DeviceMessageService { _stateSub?.cancel(); _rxSub = _serial.onData.listen(_onData); - _errorSub = _serial.onError.listen(_emitError); + _errorSub = _serial.onError.listen((msg) => _emitError(msg)); _stateSub = _serial.connectionStateChanges.listen((s) { if (s == SerialConnectionState.disconnected) { _protocol.reset(); @@ -129,6 +138,13 @@ class DeviceMessageService { } void _dispatch(DeviceMessage msg) { + DeviceLog.info( + 'RX ${msg.type.wireName} ' + 'id=${msg.messageId} ' + 'ack=${msg.ack ?? "-"} ' + 'data=${DeviceLog.summarizeData(msg.data)}\n' + ' json=${msg.encode()}', + ); _messageCtrl.add(msg); final list = _handlers[msg.type]; if (list == null) return; @@ -136,12 +152,18 @@ class DeviceMessageService { try { handler(msg); } catch (e) { - _emitError('消息处理异常 (${msg.type.wireName}): $e'); + _emitError('消息处理异常 (${msg.type.wireName}): $e', error: e); + DeviceLog.severe('消息处理异常 (${msg.type.wireName})', error: e); } } } - void _emitError(String message) { + void _emitError(String message, {Object? error}) { + if (error != null) { + DeviceLog.warn(message, error: error); + } else { + DeviceLog.warn(message); + } if (!_errorCtrl.isClosed) _errorCtrl.add(message); } } diff --git a/lib/features/device/services/serial_port_service.dart b/lib/features/device/services/serial_port_service.dart new file mode 100644 index 0000000..2aaa4c9 --- /dev/null +++ b/lib/features/device/services/serial_port_service.dart @@ -0,0 +1,225 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:usb_serial/usb_serial.dart'; + +import '../models/serial_config.dart'; +import 'device_log.dart'; + +/// 串口连接状态 +enum SerialConnectionState { disconnected, connecting, connected, error } + +/// 串口服务 +/// +/// 封装 [UsbSerial] 库,对外暴露稳定的连接/读写接口, +/// 屏蔽底层 USB 设备的插拔、权限请求、参数配置等细节。 +class SerialPortService { + UsbPort? _port; + UsbDevice? _device; + StreamSubscription? _readSub; + final _connectionCtrl = StreamController.broadcast(); + final _dataCtrl = StreamController.broadcast(); + final _errorCtrl = StreamController.broadcast(); + SerialConnectionState _state = SerialConnectionState.disconnected; + String? _lastError; + + /// 当前连接状态 + SerialConnectionState get state => _state; + + /// 最后一次错误信息 + String? get lastError => _lastError; + + /// 是否已连接 + bool get isConnected => _state == SerialConnectionState.connected; + + /// 当前连接设备(仅在已连接时可用) + UsbDevice? get currentDevice => _device; + + /// 连接状态变更流 + Stream get connectionStateChanges => + _connectionCtrl.stream; + + /// 接收到的数据流 + Stream get onData => _dataCtrl.stream; + + /// 错误信息流 + Stream get onError => _errorCtrl.stream; + + /// 列出当前连接的 USB 串口设备 + Future> listDevices() async { + return await UsbSerial.listDevices(); + } + + /// 设备名(vendor/product 拼接) + static String deviceLabel(UsbDevice d) { + final v = _hex(d.vid); + final p = _hex(d.pid); + final name = d.productName ?? d.manufacturerName ?? 'USB Serial'; + return '$name [$v:$p]'; + } + + static String _hex(int? v) => + (v ?? 0).toRadixString(16).padLeft(4, '0').toUpperCase(); + + /// 打开指定设备并按 [config] 配置参数 + Future connect(UsbDevice device, SerialConfig config) async { + if (_state == SerialConnectionState.connected || + _state == SerialConnectionState.connecting) { + await disconnect(); + } + + DeviceLog.info('连接串口设备: ${deviceLabel(device)}, ' + 'baud=${config.baudRate} dataBits=${config.dataBits} ' + 'stopBits=${config.stopBits} parity=${config.parity.name}'); + _setState(SerialConnectionState.connecting); + try { + // UsbSerial.create 内部会触发 Android USB 权限弹窗 + final port = await device.create(); + if (port == null) { + _fail('创建设备端口失败(可能未授予 USB 权限)'); + return false; + } + + final opened = await port.open(); + if (!opened) { + await port.close(); + _fail('打开端口失败'); + return false; + } + + await _applyConfig(port, config); + + _port = port; + _device = device; + _subscribeStream(port); + _setState(SerialConnectionState.connected); + DeviceLog.info('串口已连接: ${deviceLabel(device)}'); + return true; + } catch (e) { + _fail('连接异常: $e'); + return false; + } + } + + /// 应用串口参数 + Future _applyConfig(UsbPort port, SerialConfig config) async { + await port.setPortParameters( + config.baudRate, + config.dataBits, + config.stopBits, + _parityToCode(config.parity), + ); + await port.setFlowControl(_flowControlToCode(config.flowControl)); + } + + /// 断开当前连接 + Future disconnect() async { + if (_state == SerialConnectionState.disconnected) return; + await _readSub?.cancel(); + _readSub = null; + try { + await _port?.close(); + } catch (_) {} + _port = null; + _device = null; + _setState(SerialConnectionState.disconnected); + DeviceLog.info('串口已断开'); + } + + /// 写入数据,返回成功写入的字节数(设备库本身不返回字节数,连接失败时返回 0) + Future write(Uint8List data) async { + final port = _port; + if (port == null || !isConnected) { + _emitError('串口未连接,写入失败'); + return 0; + } + try { + await port.write(data); + DeviceLog.info('串口 TX: ${data.length} 字节'); + return data.length; + } catch (e) { + _emitError('写入异常: $e', error: e); + return 0; + } + } + + /// 释放资源(应用退出时调用) + Future dispose() async { + await disconnect(); + await _connectionCtrl.close(); + await _dataCtrl.close(); + await _errorCtrl.close(); + } + + void _subscribeStream(UsbPort port) { + _readSub?.cancel(); + final stream = port.inputStream; + if (stream == null) { + _emitError('当前端口无输入流'); + return; + } + _readSub = stream.listen( + (data) { + if (data.isNotEmpty) { + DeviceLog.info('串口 RX: ${data.length} 字节'); + _dataCtrl.add(data); + } + }, + onError: (Object e) { + _emitError('读取异常: $e', error: e); + }, + onDone: () { + DeviceLog.info('串口输入流关闭'); + _setState(SerialConnectionState.disconnected); + }, + cancelOnError: false, + ); + } + + void _setState(SerialConnectionState s) { + _state = s; + if (s != SerialConnectionState.error) { + _lastError = null; + } + if (!_connectionCtrl.isClosed) { + _connectionCtrl.add(s); + } + } + + void _fail(String message) { + _lastError = message; + _setState(SerialConnectionState.error); + _emitError(message); + } + + void _emitError(String message, {Object? error}) { + _lastError = message; + if (error != null) { + DeviceLog.severe(message, error: error); + } else { + DeviceLog.warn(message); + } + if (!_errorCtrl.isClosed) { + _errorCtrl.add(message); + } + } + + static int _parityToCode(SerialParity p) { + return switch (p) { + SerialParity.none => UsbPort.PARITY_NONE, + SerialParity.odd => UsbPort.PARITY_ODD, + SerialParity.even => UsbPort.PARITY_EVEN, + SerialParity.mark => UsbPort.PARITY_MARK, + SerialParity.space => UsbPort.PARITY_SPACE, + }; + } + + static int _flowControlToCode(SerialFlowControl f) { + return switch (f) { + SerialFlowControl.none => UsbPort.FLOW_CONTROL_OFF, + SerialFlowControl.rtsCts => UsbPort.FLOW_CONTROL_RTS_CTS, + SerialFlowControl.xonXoff => UsbPort.FLOW_CONTROL_XON_XOFF, + SerialFlowControl.dtrDsr => UsbPort.FLOW_CONTROL_DSR_DTR, + }; + } +} diff --git a/lib/features/device/services/serial_runner.dart b/lib/features/device/services/serial_runner.dart index c1d76a6..9687c8f 100644 --- a/lib/features/device/services/serial_runner.dart +++ b/lib/features/device/services/serial_runner.dart @@ -2,6 +2,7 @@ import 'dart:async'; import '../../programs/models/program.dart'; import '../../programs/models/step.dart'; +import 'device_log.dart'; import 'device_message.dart'; import 'device_message_service.dart'; import 'runner_interface.dart'; @@ -65,6 +66,8 @@ class JsonSerialRunner implements Runner { final messageId = _msg.nextId(); _pendingCreateTaskId = messageId; final msg = payload.toMessage(messageId, needAck: true); + DeviceLog.info('Runner.start: program="${program.name}" steps=${steps.length} ' + 'temperature=${program.temperature} airflow=${program.airflowTime}'); _msg.send(msg).then((ok) { if (!ok) { _pendingCreateTaskId = null; @@ -77,6 +80,7 @@ class JsonSerialRunner implements Runner { @override void pause() { if (status != RunnerStatus.running) return; + DeviceLog.info('Runner.pause'); _sendControl('pause'); _stopLocalTicker(); status = RunnerStatus.paused; @@ -85,6 +89,7 @@ class JsonSerialRunner implements Runner { @override void resume() { if (status != RunnerStatus.paused) return; + DeviceLog.info('Runner.resume'); _sendControl('continue'); status = RunnerStatus.running; _startLocalTicker(); @@ -93,6 +98,7 @@ class JsonSerialRunner implements Runner { @override void stop() { if (status == RunnerStatus.idle) return; + DeviceLog.info('Runner.stop'); _sendControl('stop'); _teardown(); status = RunnerStatus.idle; @@ -125,6 +131,7 @@ class JsonSerialRunner implements Runner { void _onCreateTaskAck(DeviceMessage ack) { if (ack.ack != _pendingCreateTaskId) return; _pendingCreateTaskId = null; + DeviceLog.info('Runner received create_task ack: id=${ack.messageId}'); // ack 即视为下位机已接受任务,进入 running 状态 if (status == RunnerStatus.idle || status == RunnerStatus.error) { status = RunnerStatus.running; @@ -135,6 +142,7 @@ class JsonSerialRunner implements Runner { void _onControlAck(DeviceMessage ack) { if (ack.ack != _pendingControlId) return; _pendingControlId = null; + DeviceLog.info('Runner received control ack: id=${ack.messageId}'); // control ack 不修改状态,状态机在调用 pause/resume/stop 时已切过 } @@ -149,6 +157,7 @@ class JsonSerialRunner implements Runner { if (_remainingSeconds <= 0) { _currentStepIndex++; if (_currentStepIndex >= _steps.length) { + DeviceLog.info('Runner 本地倒计时完成 (timeout fallback)'); _stopLocalTicker(); status = RunnerStatus.completed; _callbacks?.onComplete?.call(); diff --git a/test/device_protocol_test.dart b/test/device_protocol_test.dart index 5aa81c7..8b012ae 100644 --- a/test/device_protocol_test.dart +++ b/test/device_protocol_test.dart @@ -3,6 +3,7 @@ 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_log.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'; @@ -218,4 +219,38 @@ void main() { expect(s.infoStale, isTrue); }); }); + + group('DeviceLog', () { + test('summarizeData 截断长字符串与多余键', () { + final data = { + 'a': 1, + 'b': 'short', + 'c': 'x' * 200, + 'd': [1, 2, 3], + 'e': true, + 'f': null, + 'g': 'extra', + 'h': 'extra2', + 'i': 'extra3', + }; + final summary = DeviceLog.summarizeData(data, maxKeys: 4); + expect(summary.contains('a=1'), isTrue); + expect(summary.contains('b="short"'), isTrue); + expect(summary.contains('…'), isTrue); // 长字符串被截断 + expect(summary.contains('…'), isTrue); + expect(summary.contains(', ...'), isTrue); // 多余键被截断 + }); + + test('summarizeData 空 Map / null 友好处理', () { + expect(DeviceLog.summarizeData(null), '{}'); + expect(DeviceLog.summarizeData({}), '{}'); + }); + + test('info / warn / severe 不抛异常', () { + // 仅验证调用不抛异常;dart:developer.log 在测试环境无可见输出 + DeviceLog.info('hello'); + DeviceLog.warn('warn', error: StateError('x')); + DeviceLog.severe('severe', error: 'err'); + }); + }); }