feat: 新增 SerialMate 串口调试助手 v0.1.0
- 协议帧构成令牌序列可定制(包头/板ID/LEN口径/5种CRC x 3种覆盖), 默认兼容 DLPilot 光机协议
This commit is contained in:
@@ -0,0 +1,215 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
串口链路线程: 端口/波特率热切换 + 发送队列 + 接收分发
|
||||
======================================================
|
||||
与 DLPilot 的 DLPC485 同一套路(D I2C-Inject Injector 同源), 差异点:
|
||||
- 帧格式参数(ProtoConfig)可运行中热替换, 解析器配置随之切换并清残余半帧;
|
||||
- 支持协议解析(Frame 回调)与透传(原始 bytes 回调)两种接收模式, 运行中切换;
|
||||
- 发送一律传完整帧 bytes(组帧由 GUI 侧按 ProtoConfig 完成, 本模块不关心格式)。
|
||||
|
||||
回调全在后台线程触发, GUI 侧必须自行投递回主线程(见 main.py 的轮询队列)。
|
||||
pyserial 为本模块独占依赖(缺失只影响串口功能)。
|
||||
"""
|
||||
|
||||
import collections
|
||||
import queue
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
|
||||
try:
|
||||
import serial
|
||||
import serial.tools.list_ports
|
||||
HAS_SERIAL = True
|
||||
except ImportError:
|
||||
HAS_SERIAL = False
|
||||
|
||||
DEFAULT_BAUD = 115200
|
||||
DEFAULTS = dict(bytesize=8, parity='N', stopbits=1)
|
||||
|
||||
|
||||
class SerialLink:
|
||||
"""串口工作线程。回调(后台线程触发):
|
||||
on_status(str) 连接状态变化(已连接/断开/失败/设备拔出)
|
||||
on_frame(fr) 解析模式: 完整 Frame(protocol.Frame)
|
||||
on_raw(data) 透传模式: 原始接收 bytes
|
||||
on_tx(label, data) 发送回显: label=payload 字符串(自动包装)/None(HEX直发)
|
||||
"""
|
||||
|
||||
def __init__(self, cfg, on_status):
|
||||
self.on_status = on_status
|
||||
self.on_frame = None
|
||||
self.on_raw = None
|
||||
self.on_tx = None
|
||||
self.q = queue.Queue()
|
||||
self._cfg = cfg
|
||||
self._parse = True
|
||||
self._target = None # 意图 (port, baud, bytesize, parity, stopbits)
|
||||
self._open_key = None # 当前已打开串口的参数元组
|
||||
self.ser = None
|
||||
self.parser = None # 惰性建: 协议模块无 pyserial 依赖, 此处才用到
|
||||
self._lock = threading.Lock()
|
||||
threading.Thread(target=self._run, daemon=True).start()
|
||||
|
||||
# --- 配置与模式(线程安全) ---
|
||||
|
||||
def set_config(self, cfg):
|
||||
"""热替换帧格式; 解析器半帧缓冲随之清空, 避免新旧格式混接"""
|
||||
with self._lock:
|
||||
self._cfg = cfg
|
||||
if self.parser is not None:
|
||||
self.parser.cfg = cfg
|
||||
self.parser.clear()
|
||||
|
||||
def set_parse(self, enabled):
|
||||
"""切换 解析协议帧 / 原始透传; 切换时清解析缓冲"""
|
||||
with self._lock:
|
||||
self._parse = bool(enabled)
|
||||
if self.parser is not None:
|
||||
self.parser.clear()
|
||||
|
||||
# --- 串口管理 ---
|
||||
|
||||
@staticmethod
|
||||
def list_ports_info():
|
||||
"""枚举串口, 返回 [(设备名, 显示标签), ...]。
|
||||
|
||||
标签 = "COMx 设备描述"(描述里的 "(COMx)" 尾巴去掉), CH344 这类多路
|
||||
适配器每一路描述不同(serial-A/B/C/D), 依此区分; 多口描述完全相同时
|
||||
追加物理位置/序列号消歧。
|
||||
"""
|
||||
if not HAS_SERIAL:
|
||||
return []
|
||||
items = []
|
||||
for p in serial.tools.list_ports.comports():
|
||||
desc = re.sub(r'\s*\((COM\d+)\)\s*$', '', (p.description or '').strip(),
|
||||
flags=re.I)
|
||||
if desc.upper() == p.device.upper():
|
||||
desc = ''
|
||||
label = f'{p.device} {desc}'.rstrip()
|
||||
tail = p.location or p.serial_number or ''
|
||||
items.append((p.device, label, tail))
|
||||
counts = collections.Counter(label for _d, label, _t in items)
|
||||
out = []
|
||||
for dev, label, tail in items:
|
||||
if counts[label] > 1 and tail:
|
||||
label = f'{label} [{tail}]'
|
||||
out.append((dev, label))
|
||||
return out
|
||||
|
||||
def open(self, port, baud=DEFAULT_BAUD, **kw):
|
||||
if not HAS_SERIAL:
|
||||
self.on_status('未安装 pyserial: py -m pip install pyserial')
|
||||
return
|
||||
params = {**DEFAULTS, **kw}
|
||||
with self._lock:
|
||||
self._target = (port, int(baud), int(params['bytesize']),
|
||||
str(params['parity']).upper(), int(params['stopbits']))
|
||||
|
||||
def close(self):
|
||||
with self._lock:
|
||||
self._target = None
|
||||
|
||||
def is_connected(self):
|
||||
with self._lock:
|
||||
return self.ser is not None
|
||||
|
||||
# --- 发送(队列化, 后台线程写串口) ---
|
||||
|
||||
def send_frame(self, frame, label=''):
|
||||
"""发送一个完整帧 bytes; label 用于回显(自动包装时为 payload 文本)。
|
||||
未连接时直接丢弃并提示(防积压到重连后一起发出, DLPilot 同策略)"""
|
||||
with self._lock:
|
||||
ser = self.ser
|
||||
if ser is None:
|
||||
self.on_status('未连接串口, 命令被丢弃')
|
||||
return False
|
||||
self.q.put((label, bytes(frame)))
|
||||
return True
|
||||
|
||||
# --- 工作线程 ---
|
||||
|
||||
def _run(self):
|
||||
bytesizes = {8: serial.EIGHTBITS, 7: serial.SEVENBITS,
|
||||
6: serial.SIXBITS, 5: serial.FIVEBITS}
|
||||
parities = {'N': serial.PARITY_NONE, 'E': serial.PARITY_EVEN,
|
||||
'O': serial.PARITY_ODD}
|
||||
stopbits = {1: serial.STOPBITS_ONE, 2: serial.STOPBITS_TWO}
|
||||
while True:
|
||||
with self._lock:
|
||||
target = self._target
|
||||
cur = self._open_key
|
||||
if target != cur:
|
||||
if self.ser is not None:
|
||||
try:
|
||||
self.ser.close()
|
||||
except Exception:
|
||||
pass
|
||||
self.ser = None
|
||||
self._open_key = None
|
||||
self.on_status('已断开')
|
||||
if target:
|
||||
port, baud, dbsize, parity, sbits = target
|
||||
try:
|
||||
maker = (serial.serial_for_url if '://' in port
|
||||
else serial.Serial) # '://' 支持 loop:// 等虚拟端口(自检用)
|
||||
self.ser = maker(
|
||||
port, baud, bytesize=bytesizes.get(dbsize, serial.EIGHTBITS),
|
||||
parity=parities.get(parity, serial.PARITY_NONE),
|
||||
stopbits=stopbits.get(sbits, serial.STOPBITS_ONE),
|
||||
timeout=0.15,
|
||||
write_timeout=1.0) # 写超时: 转换器停滞时 write 会
|
||||
# 永久阻塞并卡死线程, 必须设上限
|
||||
with self._lock:
|
||||
self._open_key = target
|
||||
self.parser = None # 换口重建解析器(下次接收时惰性建)
|
||||
self.on_status(f'已连接 {port} @ {baud} {dbsize}{parity}{sbits}')
|
||||
except Exception as e:
|
||||
self.ser = None
|
||||
with self._lock:
|
||||
self._target = None
|
||||
et = str(e)
|
||||
if 'PermissionError' in repr(e) or '拒绝访问' in et \
|
||||
or 'Access is denied' in et:
|
||||
et += '(串口被占用: 检查串口助手/另一个本工具实例)'
|
||||
self.on_status(f'连接失败: {et}')
|
||||
if self.ser is not None:
|
||||
try:
|
||||
item = None
|
||||
try:
|
||||
item = self.q.get_nowait()
|
||||
except queue.Empty:
|
||||
pass
|
||||
if item is not None:
|
||||
label, frame = item
|
||||
self.ser.write(frame)
|
||||
if self.on_tx is not None:
|
||||
self.on_tx(label, frame)
|
||||
data = self.ser.read(4096)
|
||||
if data:
|
||||
self._dispatch_rx(data)
|
||||
except OSError:
|
||||
try:
|
||||
self.ser.close()
|
||||
except Exception:
|
||||
pass
|
||||
self.ser = None
|
||||
self._open_key = None # 必须同步清除, 否则同参数重连被当作已打开而跳过
|
||||
with self._lock:
|
||||
self._target = None
|
||||
self.on_status('串口丢失(设备拔出?), 请重新连接')
|
||||
time.sleep(0.02)
|
||||
|
||||
def _dispatch_rx(self, data):
|
||||
with self._lock:
|
||||
parse = self._parse
|
||||
if parse and self.parser is None: # 惰性建, 带当前 ProtoConfig
|
||||
from protocol import FrameParser
|
||||
self.parser = FrameParser(self._cfg)
|
||||
if parse:
|
||||
for fr in self.parser.feed(data):
|
||||
if self.on_frame is not None:
|
||||
self.on_frame(fr)
|
||||
elif self.on_raw is not None:
|
||||
self.on_raw(data)
|
||||
Reference in New Issue
Block a user