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

@@ -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 }

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

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

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

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

View 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']
/// ```
/// - LENJSON 部分的字节数(不含 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();
}

View File

@@ -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);
}
}

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