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:
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 简单的状态枚举(与 [DeviceState.status] 配合使用)
|
||||
enum DeviceStatus { idle, running, paused, error }
|
||||
|
||||
123
lib/features/device/providers/device_info_provider.dart
Normal file
123
lib/features/device/providers/device_info_provider.dart
Normal file
@@ -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<DeviceState> {
|
||||
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<void> queryDeviceInfo() async {
|
||||
if (!_msgService.canSend) return;
|
||||
final id = _msgService.nextId();
|
||||
await _msgService.send(DeviceMessage.request(
|
||||
messageId: id,
|
||||
type: DeviceMessageType.deviceInfo,
|
||||
data: const <String, dynamic>{},
|
||||
needAck: true,
|
||||
));
|
||||
}
|
||||
|
||||
/// 发送灯光控制消息
|
||||
///
|
||||
/// 主动切换后,下位机通常会通过 device_info 上报新的 light_status
|
||||
/// 覆盖本地状态。
|
||||
Future<bool> 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<bool> 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<DeviceInfoNotifier, DeviceState>((ref) {
|
||||
final service = ref.watch(deviceMessageServiceProvider);
|
||||
return DeviceInfoNotifier(service);
|
||||
});
|
||||
82
lib/features/device/providers/serial_provider.dart
Normal file
82
lib/features/device/providers/serial_provider.dart
Normal file
@@ -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<SerialPortService>((ref) {
|
||||
final service = SerialPortService();
|
||||
ref.onDispose(service.dispose);
|
||||
return service;
|
||||
});
|
||||
|
||||
/// JSON 协议编解码器(可在调试/真机协议不一致时整体替换)
|
||||
final jsonProtocolProvider = Provider<JsonProtocolService>((ref) {
|
||||
return JsonProtocolService();
|
||||
});
|
||||
|
||||
/// 设备消息分发服务
|
||||
///
|
||||
/// 集中处理 JSON 消息的发送与订阅;下游 Provider 各自订阅感兴趣的类型。
|
||||
final deviceMessageServiceProvider =
|
||||
Provider<DeviceMessageService>((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<SerialConfig> {
|
||||
final SettingsConfigRepository _repo;
|
||||
|
||||
SerialConfigNotifier(this._repo) : super(SerialConfig.defaults) {
|
||||
_load();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
state = await _repo.read();
|
||||
}
|
||||
|
||||
/// 修改并持久化
|
||||
Future<void> update(SerialConfig Function(SerialConfig) mutator) async {
|
||||
final next = mutator(state);
|
||||
state = next;
|
||||
await _repo.write(next);
|
||||
}
|
||||
|
||||
/// 重置为默认值
|
||||
Future<void> reset() => update((_) => SerialConfig.defaults);
|
||||
}
|
||||
|
||||
/// 串口配置仓库:把 [SerialConfig] 以 JSON 形式存到 settings 表
|
||||
class SettingsConfigRepository {
|
||||
static const _key = 'serial_config';
|
||||
final DatabaseService _db = DatabaseService.instance;
|
||||
|
||||
Future<SerialConfig> read() async {
|
||||
final raw = await _db.readSetting(_key);
|
||||
return SerialConfig.fromJsonString(raw);
|
||||
}
|
||||
|
||||
Future<void> write(SerialConfig config) async {
|
||||
await _db.writeSetting(_key, config.toJsonString());
|
||||
}
|
||||
}
|
||||
|
||||
final serialConfigProvider =
|
||||
StateNotifierProvider<SerialConfigNotifier, SerialConfig>((ref) {
|
||||
return SerialConfigNotifier(SettingsConfigRepository());
|
||||
});
|
||||
|
||||
/// 运行器实例(基于 JSON 协议与设备消息服务)
|
||||
final runnerProvider = Provider<Runner>((ref) {
|
||||
final msgService = ref.watch(deviceMessageServiceProvider);
|
||||
return JsonSerialRunner(messageService: msgService);
|
||||
});
|
||||
164
lib/features/device/services/device_message.dart
Normal file
164
lib/features/device/services/device_message.dart
Normal file
@@ -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<String, dynamic> 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<String, dynamic> 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<String, dynamic> data) {
|
||||
return DeviceMessage(
|
||||
messageId: '${original.messageId}-ack',
|
||||
type: original.type,
|
||||
data: data,
|
||||
ack: original.messageId,
|
||||
needAck: false,
|
||||
);
|
||||
}
|
||||
|
||||
/// 序列化为协议层 Map
|
||||
Map<String, dynamic> 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<String, dynamic> 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<String, dynamic>) return null;
|
||||
return fromJson(map);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// JSON 中的 data 既可能是 Map,也可能是字符串(容错处理)
|
||||
static Map<String, dynamic> _coerceData(dynamic raw) {
|
||||
if (raw is Map<String, dynamic>) return raw;
|
||||
if (raw is Map) return Map<String, dynamic>.from(raw);
|
||||
return <String, dynamic>{};
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() => 'DeviceMessage(id=$messageId, type=${type.wireName}, '
|
||||
'ack=$ack, needAck=$needAck, data=${data.keys.toList()})';
|
||||
}
|
||||
|
||||
/// 消息 ID 生成器
|
||||
///
|
||||
/// 使用时间戳 + 随机数生成全局唯一 ID(避免引入 uuid 依赖)。
|
||||
/// 格式:`<millis>-<rand>`,例如 `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';
|
||||
}
|
||||
}
|
||||
147
lib/features/device/services/device_message_service.dart
Normal file
147
lib/features/device/services/device_message_service.dart
Normal file
@@ -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<Uint8List>? _rxSub;
|
||||
StreamSubscription<String>? _errorSub;
|
||||
StreamSubscription<SerialConnectionState>? _stateSub;
|
||||
|
||||
final Map<DeviceMessageType, List<void Function(DeviceMessage)>> _handlers = {};
|
||||
|
||||
/// 错误信息流(解码错误、串口异常等)
|
||||
final _errorCtrl = StreamController<String>.broadcast();
|
||||
|
||||
/// 任何消息流的广播(用于调试 / 日志)
|
||||
final _messageCtrl = StreamController<DeviceMessage>.broadcast();
|
||||
|
||||
DeviceMessageService({
|
||||
required SerialPortService serial,
|
||||
JsonProtocolService? protocol,
|
||||
}) : _serial = serial,
|
||||
_protocol = protocol ?? JsonProtocolService() {
|
||||
_subscribe();
|
||||
}
|
||||
|
||||
Stream<String> get onError => _errorCtrl.stream;
|
||||
Stream<DeviceMessage> get onMessage => _messageCtrl.stream;
|
||||
|
||||
/// 串口连接状态变更(透传自 [SerialPortService])
|
||||
Stream<SerialConnectionState> 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, () => <void Function(DeviceMessage)>[]);
|
||||
_handlers[type]!.add(handler);
|
||||
return () {
|
||||
final list = _handlers[type];
|
||||
if (list != null) list.remove(handler);
|
||||
};
|
||||
}
|
||||
|
||||
/// 发送消息到下位机;返回是否成功写入
|
||||
Future<bool> 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<void> 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);
|
||||
}
|
||||
}
|
||||
95
lib/features/device/services/json_protocol.dart
Normal file
95
lib/features/device/services/json_protocol.dart
Normal file
@@ -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<int> _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<int> 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();
|
||||
}
|
||||
@@ -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<Step> _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<Step> 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<void> _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<void> _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 时已切过
|
||||
}
|
||||
}
|
||||
|
||||
// -- 本地兜底倒计时 ---------------------------------------------------
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
84
lib/features/device/services/task_payload.dart
Normal file
84
lib/features/device/services/task_payload.dart
Normal file
@@ -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<Step> steps;
|
||||
final int temperature;
|
||||
final int airflowTime;
|
||||
|
||||
const TaskPayload({
|
||||
required this.steps,
|
||||
required this.temperature,
|
||||
required this.airflowTime,
|
||||
});
|
||||
|
||||
/// 从程序和步骤列表构造负载
|
||||
factory TaskPayload.fromProgram(Program program, List<Step> steps) {
|
||||
return TaskPayload(
|
||||
steps: List<Step>.from(steps)
|
||||
..sort((a, b) => a.stepNo.compareTo(b.stepNo)),
|
||||
temperature: program.temperature,
|
||||
airflowTime: program.airflowTime,
|
||||
);
|
||||
}
|
||||
|
||||
/// 转换为下位机协议 Map
|
||||
Map<String, dynamic> 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<String, dynamic> _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;
|
||||
}
|
||||
}
|
||||
@@ -22,8 +22,6 @@ class HomePage extends ConsumerStatefulWidget {
|
||||
|
||||
class _HomePageState extends ConsumerState<HomePage>
|
||||
with SingleTickerProviderStateMixin {
|
||||
bool _lightOn = false;
|
||||
final bool _ceramicSleeveInstalled = false; // TODO: 后续对接硬件传感器后改为可变状态
|
||||
int _currentIndex = 0;
|
||||
|
||||
@override
|
||||
@@ -54,13 +52,6 @@ class _HomePageState extends ConsumerState<HomePage>
|
||||
// 状态栏
|
||||
StatusBar(
|
||||
isRunning: runState.status == RunStatus.running,
|
||||
lightOn: _lightOn,
|
||||
onLightToggle: () {
|
||||
setState(() {
|
||||
_lightOn = !_lightOn;
|
||||
});
|
||||
},
|
||||
ceramicSleeveInstalled: _ceramicSleeveInstalled,
|
||||
),
|
||||
|
||||
// 导航标签栏
|
||||
|
||||
@@ -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<StatusBar> createState() => _StatusBarState();
|
||||
ConsumerState<StatusBar> createState() => _StatusBarState();
|
||||
}
|
||||
|
||||
class _StatusBarState extends State<StatusBar> {
|
||||
class _StatusBarState extends ConsumerState<StatusBar> {
|
||||
String _currentTime = '';
|
||||
Timer? _timer;
|
||||
|
||||
@@ -51,9 +49,15 @@ class _StatusBarState extends State<StatusBar> {
|
||||
|
||||
String _twoDigits(int n) => n.toString().padLeft(2, '0');
|
||||
|
||||
Future<void> _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<StatusBar> {
|
||||
],
|
||||
),
|
||||
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<StatusBar> {
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
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,
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
221
test/device_protocol_test.dart
Normal file
221
test/device_protocol_test.dart
Normal file
@@ -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<String, dynamic>;
|
||||
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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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', () {
|
||||
|
||||
Reference in New Issue
Block a user