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:
Developer
2026-06-04 13:00:21 +08:00
parent 5d28bf631b
commit 819889684f
13 changed files with 1689 additions and 122 deletions

View 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';
}
}