Files
HW-Debug-Tools/SerialMate/src/link.py
T

227 lines
9.7 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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:
"""串口工作线程。回调(后台线程触发, 第二参数 ts=事件发生的 epoch 秒):
on_status(text, ts) 连接状态变化(已连接/断开/失败/设备拔出)
on_frame(fr, ts) 解析模式: 完整 Frame(protocol.Frame); ts=本批字节到达时刻
on_raw(data, ts) 透传模式: 原始接收 bytes; ts=到达时刻
on_tx(label, data, ts) 发送回显: label=payload 字符串(自动包装)/None(HEX直发);
ts=写入串口时刻
时间戳必须由本模块在读线程打(字节到达时刻), GUI 消费侧再打会随 UI 卡顿
成批挤压失真——日志时间就不再是到达时间。
"""
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', time.time())
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('未连接串口, 命令被丢弃', time.time())
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('已断开', time.time())
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 会
# 永久阻塞并卡死线程, 必须设上限
inter_byte_timeout=0.02) # 字节间超时(Win32
# ReadIntervalTimeout): timeout=0.15 是"总超时"
# (150ms 内必返回, 不会无限攒批), 周期上报帧下 read
# 会 ~150ms 攒 1~2 帧才返回一批; 设 20ms 后按帧粒度
# 返回, 帧到达时间戳精确到单帧。20ms 仍大于 1200 波特
# 单字节时间(~8.3ms)×2, 不误切帧
with self._lock:
self._open_key = target
self.parser = None # 换口重建解析器(下次接收时惰性建)
self.on_status(f'已连接 {port} @ {baud} {dbsize}{parity}{sbits}',
time.time())
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}', time.time())
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, time.time())
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.time())
time.sleep(0.02)
def _dispatch_rx(self, data):
ts = time.time() # 字节到达时刻: 后台线程打, 随帧携带
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, ts)
elif self.on_raw is not None:
self.on_raw(data, ts)