flutter 利用flutter_libserialport 实现SQ800 串口通信

0 阅读16分钟

SQ800TicketService 出票模块通信服务说明

1. 背景与目标

  • 设备背景:SQ800 是一款通过串口与主机通信的出票模块,使用自定义二进制协议。
  • 本类作用SQ800TicketService 封装了 串口连接、协议帧构造与解析、CRC 校验、出票队列管理、错误恢复 等全部底层逻辑,对上层只暴露一个基于事件流的服务。
  • 设计目标
    • 上层不用关心串口/协议细节,只通过方法调用 + 监听事件即可。
    • 支持成批出票、自动连续、故障暂停、恢复后自动继续。
    • 所有关键状态和错误都通过统一事件流抛出,方便 UI 展示与日志记录。

2. 核心类与整体结构

  • 依赖:依赖于flutter_libserialport,需要自行安装 地址:pub.dev/packages/fl…
  • 核心类SQ800TicketService
  • 相关类型
    • SQ800Event:对外事件对象(类型 + 文本描述 + 可选原始值)。
    • SQ800EventType:事件类型枚举(日志、连接、出票成功/失败、传感器状态、恢复结果、设备信息等)。
  • 主要组成模块
    • 串口管理:打开/关闭串口、设置波特率、监听串口数据。
    • 命令发送:统一 _sendCmd,按协议构造帧并写入串口。
    • 响应解析:缓冲组帧、CRC 校验、按 statusType 分发到对应解析函数。
    • 出票队列:支持批量排队、自动连续打印、失败暂停、手动恢复。
    • 错误恢复:切刀故障、卡纸故障恢复,并在恢复成功后自动继续队列。
    • 事件流:eventStream 对外发布所有状态变化和日志。

3. 快速上手示例

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

4. 事件模型

4.1 SQ800Event

  • 结构
    • type: SQ800EventType:事件类型。
    • message: String:人类可读的中文描述,直接可用于日志或 UI 展示。
    • rawValue: int?:可选的原始数值(例如传感器状态码、错误码等)。

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

5. 对外 API 一览

5.1 连接与资源管理

  • listPorts(): List<String>

    • 返回当前可用串口地址列表。
    • 内部异常会通过 log 事件抛出。
  • getPortInfo(String address): Map<String, String>

    • 返回指定串口的描述信息:descriptiontransport(USB/BT/Native)、manufacturerproductserial 等。
  • connect(String address): bool

    • 9600 波特率、8N1 配置打开串口并开始监听。
    • 成功后派发 connected 事件;失败会通过 log 事件给出失败原因。
  • disconnect(): void

    • 关闭串口并清理监听,派发 disconnected 事件。
  • dispose(): void

    • 完整释放串口与事件流资源,适合应用退出或页面销毁时调用。

5.2 出票队列与单张出票

  • enqueueTickets(int count, {int lengthInch = 4})

    • 向队列中追加 count 张票,票长 lengthInch(单位:寸)。
    • 若当前未处于打印状态,会自动启动 _printNext() 连续出票。
    • 每次队列变化会通过 queueUpdate 事件告知外部。
  • resumeQueue()

    • 当发生故障导致自动连续打印中断时,用户排除故障后,可调用此方法手动恢复队列。
    • 若队列为空则只会发出日志提示。
  • clearQueue()

    • 清空当前排队的所有票,并停止自动打印。
  • printOneTicket({int lengthInch = 4})

    • 直接发送一张出票命令,不计入队列。
    • 适合测试或手动单张出票场景。
  • resetCounters()

    • 重置成功/失败计数(printedCount / failedCount),并通过 queueUpdate 事件通知。
  • 统计字段(只读)

    • printedCount:当前会话内成功出票张数。
    • failedCount:当前会话内失败次数。
    • ticketQueue:当前队列剩余张数。
    • isPrinting:当前是否处于自动连打状态。

5.3 状态查询与设备信息

  • queryStatus()

    • 查询综合状态(入纸口、出纸口、切刀、卡纸等),结果会通过 sensorStatus 事件抛出。
  • querySensor(int param)

    • 查询单个传感器状态,param 取值 0x01 ~ 0x05
  • readFirmwareVersion()

    • 读取固件版本号,通过 deviceInfo 事件返回字符串信息。
  • readDeviceModel()

    • 读取设备型号,通过 deviceInfo 事件返回字符串信息。
  • readRemainingTickets()

    • 读取设备内部记录的剩余票数,通过 deviceInfo 事件返回数值。

5.4 错误恢复

  • recoverCutter()

    • 发送恢复切刀错误命令,并将内部 _isPrinting 置为 false
    • 当设备返回恢复结果时,由 _parseRecoveryResult 解析并触发 recoverySuccess / recoveryFailed 事件。
    • 恢复成功且队列仍有剩余时,会自动延时后继续 _printNext()
  • recoverJam()

    • recoverCutter() 类似,用于恢复卡纸错误。

6. 协议与 CRC 简述

6.1 发送帧格式

发送帧格式为:

0x1F 0x0F + addr + cmd + type + length + data... + crcHigh + crcLow

  • addr:设备地址,仅低 4 位有效。
  • cmd:命令字(读传感器 / 写配置 / 读配置 / 出票 / 错误恢复等)。
  • type:命令参数或子类型(例如传感器编号、长度单位等)。
  • lengthdata 数据区的字节数。
  • data:具体业务数据(如票长)。
  • crcHigh + crcLow:对前面所有字节按技术手册算法计算出的 CRC16 校验。

构帧逻辑由静态方法 buildFrame 负责,内部会调用 crcCalc 计算 CRC,并最终返回 Uint8List 以写入串口。

6.2 接收与解析

  • 所有串口接收数据先进入 _rxBuffer,然后由 _tryParseFrames()
    • 查找帧头 0x1F 0x0F
    • 根据长度字段计算完整帧长度;
    • 数据不足则等待下一批数据;
    • 取到完整帧后计算 CRC,与帧尾两字节对比。
  • CRC 通过后,由 _parseResponse(frame) 根据 statusType 派发到不同解析函数:
    • _parseSensorStatus:解析传感器/综合状态;
    • _parseReadConfig:解析固件版本、型号、剩余票数等;
    • _parsePrintResult:解析单次出票结果,并更新队列与计数;
    • _parseRecoveryResult:解析切刀/卡纸恢复结果,并决定是否继续队列。

7. UI 集成和使用建议

  • 事件驱动:推荐在页面初始化时订阅 eventStream,将所有事件统一写入一份“设备日志”列表,并根据类型做额外 UI 反馈。
  • 显式操作按钮
    • “连接设备”:调用 listPorts + connect
    • “单张出票”:调用 printOneTicket
    • “批量出票”:输入张数与票长,调用 enqueueTickets
    • “恢复切刀 / 恢复卡纸”:在 printFailed 提示中根据错误码,引导用户点击相应按钮调用 recoverCutter / recoverJam
    • “清空队列”:调用 clearQueue,防止误继续。
  • 日志与故障排查
    • 建议 UI 中保留一个“调试日志”区域,将所有 log / crcError / invalidParam 事件记录下来,方便现场排查。
    • 十六进制原始帧字符串(hexString)在设备通信问题上非常有价值。

8. 源码

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

9. 说明文档

github.com/chinawangya…