flutter 利用flutter_libserialport 实现SQ800 串口通信
SQ800TicketService 出票模块通信服务说明
背景与目标
设备背景:SQ800 是一款通过串口与主机通信的出票模块,使用自定义二进制协议。
本类作用:SQ800TicketService 封装了 串口连接、协议帧构造与解析、CRC 校验、出票队列管理、错误恢复 等全部底层逻辑,对上层只暴露一个基于事件流的服务。
设计目标:
- 上层不用关心串口/协议细节,只通过方法调用 + 监听事件即可。
- 支持成批出票、自动连续、故障暂停、恢复后自动继续。
- 所有关键状态和错误都通过统一事件流抛出,方便 UI 展示与日志记录。
核心类与整体结构
依赖:依赖于
flutter_libserialport,需要自行安装:地址核心类:
SQ800TicketService相关类型:
SQ800Event:对外事件对象(类型 + 文本描述 + 可选原始值)。SQ800EventType:事件类型枚举(日志、连接、出票成功/失败、传感器状态、恢复结果、设备信息等)。
主要组成模块:
- 串口管理:打开/关闭串口、设置波特率、监听串口数据。
- 命令发送:统一
_sendCmd,按协议构造帧并写入串口。 - 响应解析:缓冲组帧、CRC 校验、按 statusType 分发到对应解析函数。
- 出票队列:支持批量排队、自动连续打印、失败暂停、手动恢复。
- 错误恢复:切刀故障、卡纸故障恢复,并在恢复成功后自动继续队列。
- 事件流:
eventStream对外发布所有状态变化和日志。
快速上手示例
final service = SQ800TicketService();
// 1. 订阅事件(建议在页面 initState 中)
late final StreamSubscription<SQ800Event> sub;
sub = service.eventStream.listen((e) {
// 根据 e.type 分类处理,例如追加日志、更新 UI、弹提示等
print('[${e.type}] ${e.message}');
});
// 2. 枚举并连接串口(实际项目中可让用户选择端口)
final ports = service.listPorts();
if (ports.isNotEmpty) {
final ok = service.connect(ports.first);
if (ok) {
// 3. 加入 5 张 4 寸票到队列,自动连续出票
service.enqueueTickets(5, lengthInch: 4);
// 可选:查询状态、固件版本、剩余票数等
service.queryStatus();
service.readFirmwareVersion();
service.readRemainingTickets();
}
}
// 4. 出现切刀/卡纸错误后,UI 上提供按钮调用:
// service.recoverCutter();
// service.recoverJam();
// 5. 页面销毁时释放资源
@override
void dispose() {
sub.cancel();
service.dispose();
super.dispose();
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
事件模型
SQ800Event
结构:
type: SQ800EventType:事件类型。message: String:人类可读的中文描述,直接可用于日志或 UI 展示。rawValue: int?:可选的原始数值(例如传感器状态码、错误码等)。
SQ800EventType 常见类型
- log:调试日志、错误信息(例如连接异常、发送失败、CRC 错误等)。
- connected / disconnected:串口连接/断开。
- printSuccess / printFailed:单次出票结果。
- sensorStatus:单个或综合传感器状态(入纸口、出纸口、切刀、卡纸等)。
- recoverySuccess / recoveryFailed:切刀/卡纸恢复结果。
- deviceInfo:固件版本、设备型号、剩余票数等配置/信息。
- configValue:写配置返回。
- crcError / invalidParam:设备端 CRC 错误、无效参数。
- queueUpdate / queuePaused:队列状态变化、队列暂停(达到重试上限等待人工确认等)。
上层 UI 只需要订阅 eventStream,根据 type 做分支处理即可,例如:
service.eventStream.listen((e) {
switch (e.type) {
case SQ800EventType.printSuccess:
// 显示“出票成功”,并更新计数
break;
case SQ800EventType.printFailed:
// 弹出错误提示,提示用户检查切刀/卡纸情况并手动点击恢复
break;
case SQ800EventType.sensorStatus:
// 实时展示传感器状态,例如“入纸口无票”、“卡纸”等
break;
default:
// 其他类型根据需要处理
break;
}
});2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
对外 API 一览
连接与资源管理
listPorts(): List<String>- 返回当前可用串口地址列表。
- 内部异常会通过 log 事件抛出。
getPortInfo(String address): Map<String, String>- 返回指定串口的描述信息:description、transport(USB/BT/Native)、manufacturer、product、serial 等。
connect(String address): bool- 按 9600 波特率、8N1 配置打开串口并开始监听。
- 成功后派发 connected 事件;失败会通过 log 事件给出失败原因。
disconnect(): void- 关闭串口并清理监听,派发 disconnected 事件。
dispose(): void- 完整释放串口与事件流资源,适合应用退出或页面销毁时调用。
出票队列与单张出票
enqueueTickets(int count, {int lengthInch = 4})- 向队列中追加 count 张票,票长 lengthInch(单位:寸)。
- 若当前未处于打印状态,会自动启动 _printNext() 连续出票。
- 每次队列变化会通过 queueUpdate 事件告知外部。
resumeQueue()- 当发生故障导致自动连续打印中断时,用户排除故障后,可调用此方法手动恢复队列。
- 若队列为空则只会发出日志提示。
clearQueue()- 清空当前排队的所有票,并停止自动打印。
printOneTicket({int lengthInch = 4})- 直接发送一张出票命令,不计入队列。
- 适合测试或手动单张出票场景。
resetCounters()- 重置成功/失败计数(printedCount / failedCount),并通过 queueUpdate 事件通知。
统计字段(只读)
- printedCount:当前会话内成功出票张数。
- failedCount:当前会话内失败次数。
- ticketQueue:当前队列剩余张数。
- isPrinting:当前是否处于自动连打状态。
状态查询与设备信息
queryStatus()- 查询综合状态(入纸口、出纸口、切刀、卡纸等),结果会通过 sensorStatus 事件抛出。
querySensor(int param)- 查询单个传感器状态,param 取值 0x01 ~ 0x05。
readFirmwareVersion()- 读取固件版本号,通过 deviceInfo 事件返回字符串信息。
readDeviceModel()- 读取设备型号,通过 deviceInfo 事件返回字符串信息。
readRemainingTickets()- 读取设备内部记录的剩余票数,通过 deviceInfo 事件返回数值。
错误恢复
recoverCutter()- 发送恢复切刀错误命令,并将内部 _isPrinting 置为 false。
- 当设备返回恢复结果时,由 _parseRecoveryResult 解析并触发 recoverySuccess / recoveryFailed 事件。
- 恢复成功且队列仍有剩余时,会自动延时后继续 _printNext()。
recoverJam()- 与
recoverCutter()类似,用于恢复卡纸错误。
- 与
协议与 CRC 简述
发送帧格式
发送帧格式为:
0x1F 0x0F + addr + cmd + type + length + data... + crcHigh + crcLow
- addr:设备地址,仅低 4 位有效。
- cmd:命令字(读传感器 / 写配置 / 读配置 / 出票 / 错误恢复等)。
- type:命令参数或子类型(例如传感器编号、长度单位等)。
- length:data 数据区的字节数。
- data:具体业务数据(如票长)。
- crcHigh + crcLow:对前面所有字节按技术手册算法计算出的 CRC16 校验。
构帧逻辑由静态方法 buildFrame 负责,内部会调用 crcCalc 计算 CRC,并最终返回 Uint8List 以写入串口。
接收与解析
所有串口接收数据先进入 _rxBuffer,然后由 _tryParseFrames():
- 查找帧头 0x1F 0x0F;
- 根据长度字段计算完整帧长度;
- 数据不足则等待下一批数据;
- 取到完整帧后计算 CRC,与帧尾两字节对比。
CRC 通过后,由 _parseResponse(frame) 根据 statusType 派发到不同解析函数:
- _parseSensorStatus:解析传感器/综合状态;
- _parseReadConfig:解析固件版本、型号、剩余票数等;
- _parsePrintResult:解析单次出票结果,并更新队列与计数;
- _parseRecoveryResult:解析切刀/卡纸恢复结果,并决定是否继续队列。
UI 集成和使用建议
事件驱动:推荐在页面初始化时订阅 eventStream,将所有事件统一写入一份“设备日志”列表,并根据类型做额外 UI 反馈。
显式操作按钮:
- “连接设备”:调用 listPorts + connect。
- “单张出票”:调用 printOneTicket。
- “批量出票”:输入张数与票长,调用 enqueueTickets。
- “恢复切刀 / 恢复卡纸”:在 printFailed 提示中根据错误码,引导用户点击相应按钮调用 recoverCutter / recoverJam。
- “清空队列”:调用 clearQueue,防止误继续。
日志与故障排查:
- 建议 UI 中保留一个“调试日志”区域,将所有 log / crcError / invalidParam 事件记录下来,方便现场排查。
- 十六进制原始帧字符串(hexString)在设备通信问题上非常有价值。
源码
import 'dart:async';
import 'dart:typed_data';
import 'package:flutter_libserialport/flutter_libserialport.dart';
/// SQ800 设备返回的响应事件
class SQ800Event {
final SQ800EventType type;
final String message;
final int? rawValue;
const SQ800Event(this.type, this.message, {this.rawValue});
@override
String toString() => message;
}
enum SQ800EventType {
log,
connected,
disconnected,
printSuccess,
printFailed,
sensorStatus,
recoverySuccess,
recoveryFailed,
deviceInfo,
configValue,
crcError,
invalidParam,
queueUpdate,
/// 连续失败达到上限,队列暂停,等待用户确认是否继续
queuePaused,
}
/// SQ800 出票模块通信服务
///
/// 封装了串口连接、协议帧构建/解析、CRC、出票队列、错误恢复等全部逻辑。
/// 通过 [eventStream] 向外抛出所有事件,UI 层只需监听即可。
///
/// 用法:
/// ```dart
/// final service = SQ800TicketService();
/// service.eventStream.listen((e) => print(e.message));
/// service.connect('/dev/ttyS3');
/// service.enqueueTickets(5, lengthInch: 4);
/// service.queryStatus();
/// service.dispose();
/// ```
class SQ800TicketService {
SQ800TicketService({int deviceAddr = 0x00}) : _deviceAddr = deviceAddr;
// ── 协议命令常量 ──
static const int cmdReadSensor = 0x01;
static const int cmdWriteConfig = 0x02;
static const int cmdReadConfig = 0x03;
static const int cmdPrintTicket = 0x04;
static const int cmdErrorRecovery = 0x05;
static const int paramInch = 0x01;
static const int paramMm = 0x02;
static const int paramAllStatus = 0x05;
// ── 状态 ──
int _deviceAddr;
int get deviceAddr => _deviceAddr;
set deviceAddr(int v) => _deviceAddr = v & 0x0F;
SerialPort? _port;
SerialPortReader? _reader;
StreamSubscription<Uint8List>? _subscription;
bool get isConnected => _port?.isOpen ?? false;
String get connectedPortName => _port?.name ?? '';
int _ticketQueue = 0;
int get ticketQueue => _ticketQueue;
bool _isPrinting = false;
bool get isPrinting => _isPrinting;
int _ticketLengthInch = 4;
int _printedCount = 0;
int get printedCount => _printedCount;
int _failedCount = 0;
int get failedCount => _failedCount;
/// 重置出票计数(成功 + 失败)
void resetCounters() {
_printedCount = 0;
_failedCount = 0;
_emit(SQ800EventType.queueUpdate, '计数已重置');
}
// ── 事件流 ──
final _eventCtrl = StreamController<SQ800Event>.broadcast();
Stream<SQ800Event> get eventStream => _eventCtrl.stream;
void _emit(SQ800EventType type, String msg, {int? raw}) {
_eventCtrl.add(SQ800Event(type, msg, rawValue: raw));
}
// ════════════════════════════════════════════════════════
// 串口管理
// ════════════════════════════════════════════════════════
/// 枚举可用串口,返回地址列表
List<String> listPorts() {
try {
return SerialPort.availablePorts;
} catch (e) {
_emit(SQ800EventType.log, '枚举串口异常: $e');
return [];
}
}
/// 获取指定串口的描述信息
Map<String, String> getPortInfo(String address) {
try {
final p = SerialPort(address);
final info = {
'description': p.description ?? '',
'transport': _transportName(p.transport),
'manufacturer': p.manufacturer ?? '',
'product': p.productName ?? '',
'serial': p.serialNumber ?? '',
};
p.dispose();
return info;
} catch (_) {
return {};
}
}
/// 连接指定串口(9600, 8N1)
bool connect(String address) {
if (isConnected) {
_emit(SQ800EventType.log, '已有连接,请先断开');
return false;
}
_cleanup();
_emit(SQ800EventType.log, '正在打开 $address …');
try {
_port = SerialPort(address);
if (!_port!.openReadWrite()) {
final err = SerialPort.lastError;
_emit(SQ800EventType.log, '打开失败: $err');
_port!.dispose();
_port = null;
return false;
}
final config = _port!.config;
config.baudRate = 9600;
config.bits = 8;
config.stopBits = 1;
config.parity = SerialPortParity.none;
_port!.config = config;
config.dispose();
_reader = SerialPortReader(_port!);
_subscription = _reader!.stream.listen(
_onDataReceived,
onError: (e) => _emit(SQ800EventType.log, '读取异常: $e'),
);
_emit(SQ800EventType.connected, '已连接 $address (9600, 8N1)');
return true;
} catch (e) {
_emit(SQ800EventType.log, '连接异常: $e');
_cleanup();
return false;
}
}
/// 断开串口
void disconnect() {
_cleanup();
_isPrinting = false;
_emit(SQ800EventType.disconnected, '已断开');
}
void _cleanup() {
_subscription?.cancel();
_subscription = null;
_reader?.close();
_reader = null;
if (_port != null) {
try { _port!.close(); } catch (_) {}
try { _port!.dispose(); } catch (_) {}
_port = null;
}
}
/// 释放资源
void dispose() {
_cleanup();
_eventCtrl.close();
}
// ════════════════════════════════════════════════════════
// 出票队列
// ════════════════════════════════════════════════════════
/// 加入出票队列
void enqueueTickets(int count, {int lengthInch = 4}) {
if (count <= 0) return;
if (!isConnected) {
_emit(SQ800EventType.log, '请先连接串口');
return;
}
_ticketLengthInch = lengthInch;
_ticketQueue += count;
_emit(SQ800EventType.queueUpdate, '加入队列 $count 张 → 共 $_ticketQueue');
if (!_isPrinting) {
_isPrinting = true;
_printNext();
}
}
/// 手动恢复打印队列(重置重试计数)
void resumeQueue() {
if (_ticketQueue <= 0) {
_emit(SQ800EventType.log, '队列为空');
return;
}
if (_isPrinting) return;
_emit(SQ800EventType.queueUpdate, '手动恢复队列,剩余 $_ticketQueue 张');
_isPrinting = true;
_printNext();
}
/// 清空队列
void clearQueue() {
_ticketQueue = 0;
_isPrinting = false;
_emit(SQ800EventType.queueUpdate, '队列已清空');
}
void _printNext() {
if (_ticketQueue <= 0 || !isConnected) {
_isPrinting = false;
_emit(SQ800EventType.queueUpdate, '队列完成');
return;
}
_sendCmd(cmdPrintTicket, paramInch, [_ticketLengthInch], '出票 $_ticketLengthInch 寸');
}
// ════════════════════════════════════════════════════════
// 命令发送
// ════════════════════════════════════════════════════════
/// 查询综合状态
void queryStatus() => _sendCmd(cmdReadSensor, paramAllStatus, const [], '查询综合状态');
/// 查询单项传感器状态 (param: 0x01~0x05)
void querySensor(int param) => _sendCmd(cmdReadSensor, param, const [], '查询传感器 0x${param.toRadixString(16)}');
/// 读固件版本
void readFirmwareVersion() => _sendCmd(cmdReadConfig, 0x06, const [], '读固件版本');
/// 读设备型号
void readDeviceModel() => _sendCmd(cmdReadConfig, 0x07, const [], '读设备型号');
/// 读剩余票数
void readRemainingTickets() => _sendCmd(cmdReadConfig, 0x05, const [], '读剩余票数');
/// 恢复切刀错误,恢复完成后自动继续出票
void recoverCutter() {
_sendCmd(cmdErrorRecovery, 0x01, const [], '恢复切刀');
_isPrinting = false;
}
/// 恢复卡纸错误,恢复完成后自动继续出票
void recoverJam() {
_sendCmd(cmdErrorRecovery, 0x02, const [], '恢复卡纸');
_isPrinting = false;
}
/// 发送一张出票命令(不经过队列)
void printOneTicket({int lengthInch = 4}) {
_sendCmd(cmdPrintTicket, paramInch, [lengthInch], '单张出票 $lengthInch 寸');
}
bool _sendCmd(int cmd, int type, List<int> data, String desc) {
if (!isConnected) {
_emit(SQ800EventType.log, '未连接,无法发送: $desc');
return false;
}
try {
final frame = buildFrame(_deviceAddr, cmd, type, data);
_port!.write(frame);
_emit(SQ800EventType.log, 'TX [$desc]: ${hexString(frame)}');
return true;
} catch (e) {
_emit(SQ800EventType.log, '发送失败: $e');
return false;
}
}
// ════════════════════════════════════════════════════════
// 接收 & 解析
// ════════════════════════════════════════════════════════
final List<int> _rxBuffer = [];
void _onDataReceived(Uint8List data) {
_rxBuffer.addAll(data);
_tryParseFrames();
}
void _tryParseFrames() {
while (_rxBuffer.length >= 8) {
final idx = _findHeader();
if (idx < 0) { _rxBuffer.clear(); return; }
if (idx > 0) _rxBuffer.removeRange(0, idx);
if (_rxBuffer.length < 6) return;
final dataLen = _rxBuffer[5];
final totalLen = 6 + dataLen + 2;
if (_rxBuffer.length < totalLen) return;
final frame = Uint8List.fromList(_rxBuffer.sublist(0, totalLen));
_rxBuffer.removeRange(0, totalLen);
final crcGot = crcCalc(frame.sublist(0, totalLen - 2));
final crcIn = (frame[totalLen - 2] << 8) | frame[totalLen - 1];
if (crcGot != crcIn) {
_emit(SQ800EventType.crcError, 'RX CRC错误: ${hexString(frame)}');
continue;
}
_parseResponse(frame);
}
}
int _findHeader() {
for (var i = 0; i < _rxBuffer.length - 1; i++) {
if (_rxBuffer[i] == 0x1F && _rxBuffer[i + 1] == 0x0F) return i;
}
return -1;
}
void _parseResponse(Uint8List frame) {
final statusType = frame[3];
final statusParam = frame[4];
final dataLen = frame[5];
_emit(SQ800EventType.log, 'RX: ${hexString(frame)}');
switch (statusType) {
case 0x00:
_emit(SQ800EventType.crcError, '设备返回 CRC 校验错误');
break;
case 0x01:
if (dataLen >= 1) _parseSensorStatus(statusParam, frame[6]);
break;
case 0x02:
_emit(SQ800EventType.configValue, '写配置返回 param=0x${statusParam.toRadixString(16)}');
break;
case 0x03:
_parseReadConfig(statusParam, dataLen, frame);
break;
case 0x04:
if (dataLen >= 1) _parsePrintResult(frame[6]);
break;
case 0x05:
if (dataLen >= 1) _parseRecoveryResult(statusParam, frame[6]);
break;
case 0x06:
_emit(SQ800EventType.invalidParam, '无效参数');
break;
default:
_emit(SQ800EventType.log, '未知状态类型 0x${statusType.toRadixString(16)}');
}
}
void _parseSensorStatus(int param, int m) {
String msg;
switch (param) {
case 0x01: msg = '入纸口: ${m == 1 ? "无票" : "有票"}'; break;
case 0x02: msg = '出纸口: ${m == 1 ? "有票未取走" : "无票"}'; break;
case 0x03: msg = '切刀: ${m == 1 ? "位置出错" : "正常"}'; break;
case 0x04: msg = '卡纸: ${m == 1 ? "卡纸" : "正常"}'; break;
case 0x05:
const map = {0: '正常', 1: '入纸口无票', 2: '出纸口有票未取走', 3: '切刀出错', 4: '卡纸'};
msg = '综合状态: ${map[m] ?? "未知($m)"}';
break;
default: msg = '传感器[$param] = $m'; break;
}
_emit(SQ800EventType.sensorStatus, msg, raw: m);
}
void _parsePrintResult(int m) {
const map = {0: '出票成功', 1: '失败:无票', 2: '失败:出纸口有票未取走', 3: '失败:切刀故障', 4: '失败:卡票'};
final desc = map[m] ?? '未知($m)';
if (m == 0) {
_printedCount++;
if (_ticketQueue > 0) _ticketQueue--;
_emit(SQ800EventType.printSuccess, '$desc (已出 $_printedCount 张,队列剩余 $_ticketQueue)', raw: m);
if (_ticketQueue > 0) {
Future.delayed(const Duration(milliseconds: 200), _printNext);
} else {
_isPrinting = false;
_emit(SQ800EventType.queueUpdate, '队列全部完成');
}
} else {
_isPrinting = false;
final hint = (m == 3 || m == 4)
? '请排除故障后点击「恢复${m == 4 ? "卡纸" : "切刀"}」'
: '请排除故障后点击恢复按钮';
_emit(SQ800EventType.printFailed,
'$desc (队列剩余 $_ticketQueue 张),$hint', raw: m);
}
}
void _parseRecoveryResult(int param, int m) {
final target = param == 0x01 ? '切刀' : '卡纸';
if (m == 0) {
_emit(SQ800EventType.recoverySuccess, '$target恢复成功');
// 恢复成功 → 继续出票
if (_ticketQueue > 0) {
_emit(SQ800EventType.queueUpdate, '$target恢复成功,继续出票 (剩余 $_ticketQueue 张)');
_isPrinting = true;
Future.delayed(const Duration(milliseconds: 500), _printNext);
}
} else {
_emit(SQ800EventType.recoveryFailed, '$target恢复失败', raw: m);
}
}
void _parseReadConfig(int param, int dataLen, Uint8List frame) {
if (dataLen < 1) return;
String msg;
switch (param) {
case 0x06:
msg = '固件版本: ${String.fromCharCodes(frame.sublist(6, 6 + dataLen))}';
break;
case 0x07:
msg = '设备型号: ${String.fromCharCodes(frame.sublist(6, 6 + dataLen))}';
break;
case 0x05:
msg = '剩余票数: ${_bytesToInt(frame, 6, dataLen)}';
break;
default:
msg = '配置[0x${param.toRadixString(16)}] = ${_bytesToInt(frame, 6, dataLen)}';
}
_emit(SQ800EventType.deviceInfo, msg);
}
// ════════════════════════════════════════════════════════
// 协议工具(静态,可直接调用)
// ════════════════════════════════════════════════════════
static const List<int> _crcTable = [
0x0000, 0x8005, 0x800f, 0x000a, 0x801b, 0x001e, 0x0014, 0x8011,
0x8033, 0x0036, 0x003c, 0x8039, 0x0028, 0x802d, 0x8027, 0x0022,
0x8063, 0x0066, 0x006c, 0x8069, 0x0078, 0x807d, 0x8077, 0x0072,
0x0050, 0x8055, 0x805f, 0x005a, 0x804b, 0x004e, 0x0044, 0x8041,
0x80c3, 0x00c6, 0x00cc, 0x80c9, 0x00d8, 0x80dd, 0x80d7, 0x00d2,
0x00f0, 0x80f5, 0x80ff, 0x00fa, 0x80eb, 0x00ee, 0x00e4, 0x80e1,
0x00a0, 0x80a5, 0x80af, 0x00aa, 0x80bb, 0x00be, 0x00b4, 0x80b1,
0x8093, 0x0096, 0x009c, 0x8099, 0x0088, 0x808d, 0x8087, 0x0082,
0x8183, 0x0186, 0x018c, 0x8189, 0x0198, 0x819d, 0x8197, 0x0192,
0x01b0, 0x81b5, 0x81bf, 0x01ba, 0x81ab, 0x01ae, 0x01a4, 0x81a1,
0x01e0, 0x81e5, 0x81ef, 0x01ea, 0x81fb, 0x01fe, 0x01f4, 0x81f1,
0x81d3, 0x01d6, 0x01dc, 0x81d9, 0x01c8, 0x81cd, 0x81c7, 0x01c2,
0x0140, 0x8145, 0x814f, 0x014a, 0x815b, 0x015e, 0x0154, 0x8151,
0x8173, 0x0176, 0x017c, 0x8179, 0x0168, 0x816d, 0x8167, 0x0162,
0x8123, 0x0126, 0x012c, 0x8129, 0x0138, 0x813d, 0x8137, 0x0132,
0x0110, 0x8115, 0x811f, 0x011a, 0x810b, 0x010e, 0x0104, 0x8101,
0x8303, 0x0306, 0x030c, 0x8309, 0x0318, 0x831d, 0x8317, 0x0312,
0x0330, 0x8335, 0x833f, 0x033a, 0x832b, 0x032e, 0x0324, 0x8321,
0x0360, 0x8365, 0x836f, 0x036a, 0x837b, 0x037e, 0x0374, 0x8371,
0x8353, 0x0356, 0x035c, 0x8359, 0x0348, 0x834d, 0x8347, 0x0342,
0x03c0, 0x83c5, 0x83cf, 0x03ca, 0x83db, 0x03de, 0x03d4, 0x83d1,
0x83f3, 0x03f6, 0x03fc, 0x83f9, 0x03e8, 0x83ed, 0x83e7, 0x03e2,
0x83a3, 0x03a6, 0x03ac, 0x83a9, 0x03b8, 0x83bd, 0x83b7, 0x03b2,
0x0390, 0x8395, 0x839f, 0x039a, 0x838b, 0x038e, 0x0384, 0x8381,
0x0280, 0x8285, 0x828f, 0x028a, 0x829b, 0x029e, 0x0294, 0x8291,
0x82b3, 0x02b6, 0x02bc, 0x82b9, 0x02a8, 0x82ad, 0x82a7, 0x02a2,
0x82e3, 0x02e6, 0x02ec, 0x82e9, 0x02f8, 0x82fd, 0x82f7, 0x02f2,
0x02d0, 0x82d5, 0x82df, 0x02da, 0x82cb, 0x02ce, 0x02c4, 0x82c1,
0x8243, 0x0246, 0x024c, 0x8249, 0x0258, 0x825d, 0x8257, 0x0252,
0x0270, 0x8275, 0x827f, 0x027a, 0x826b, 0x026e, 0x0264, 0x8261,
0x0220, 0x8225, 0x822f, 0x022a, 0x823b, 0x023e, 0x0234, 0x8231,
0x8213, 0x0216, 0x021c, 0x8219, 0x0208, 0x820d, 0x8207, 0x0202,
];
/// CRC16 校验(技术手册附录算法)
static int crcCalc(Uint8List data) {
int crc = 0;
for (final b in data) {
crc = ((crc << 8) & 0xFFFF) ^ _crcTable[((crc >> 8) ^ b) & 0xFF];
}
return crc & 0xFFFF;
}
/// 构造发送帧: 0x1F 0x0F + addr + cmd + type + length + data + crc
static Uint8List buildFrame(int addr, int cmd, int type, [List<int> data = const []]) {
final len = data.length;
final frame = Uint8List(6 + len + 2);
frame[0] = 0x1F;
frame[1] = 0x0F;
frame[2] = addr & 0x0F;
frame[3] = cmd & 0xFF;
frame[4] = type & 0xFF;
frame[5] = len & 0xFF;
for (var i = 0; i < len; i++) {
frame[6 + i] = data[i] & 0xFF;
}
final crc = crcCalc(frame.sublist(0, 6 + len));
frame[6 + len] = (crc >> 8) & 0xFF;
frame[6 + len + 1] = crc & 0xFF;
return frame;
}
/// Uint8List 转十六进制字符串
static String hexString(Uint8List d) =>
d.map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase()).join(' ');
static int _bytesToInt(Uint8List frame, int offset, int len) {
int v = 0;
for (var i = 0; i < len && (offset + i) < frame.length; i++) {
v |= frame[offset + i] << (8 * i);
}
return v;
}
static String _transportName(int t) {
switch (t) {
case SerialPortTransport.usb: return 'USB';
case SerialPortTransport.bluetooth: return 'Bluetooth';
case SerialPortTransport.native: return 'Native';
default: return 'Unknown';
}
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
