fix(SerialMate): v0.1.2 解析模式不再吞数据——不成帧字节透传显示

背景: 用户接非 DLPilot 协议设备(COM25)开解析模式接收区一片空白——_dispatch_rx 在解析模式只上抛完整合法帧, 不成帧字节(纯文本日志/别的协议/波特率不符乱码)被 FrameParser 重同步时静默丢弃。用户明确定位: 首先是基础串口调试工具, 自定义协议解析是附加项, 附加能力不得影响本体功能。

修复: protocol.py FrameParser 丢弃字节累积进 garbage(新增 drain_garbage(), feed() 返回值不变); link.py 新增 on_garbage(data, ts) 回调, 每批无条件 drain(未接回调也不累积); main.py 把 on_garbage 并入 raw 事件与透传行同格式显示, 解析协议帧开关补 tooltip, 版本 0.1.2。半帧前缀扣留语义有意保留(可能补全成真帧, 至多扣一个前缀长度)。

自检: selftest 8b +10 项(纯文本全透出/噪声+帧+尾字节/坏CRC帧字节全透出/半帧头扣留补全), loop 3b +3 项, gui 5b +2 项; 全量 67+21+68 项 PASS; exe 已重建。README/AGENTS.md(不变量 5a) 同步。
This commit is contained in:
2026-10-16 19:08:55 +08:00
parent ac76c10132
commit 8e91e661f1
8 changed files with 118 additions and 11 deletions
+9
View File
@@ -33,6 +33,8 @@ class SerialLink:
"""串口工作线程。回调(后台线程触发, 第二参数 ts=事件发生的 epoch 秒):
on_status(text, ts) 连接状态变化(已连接/断开/失败/设备拔出)
on_frame(fr, ts) 解析模式: 完整 Frame(protocol.Frame); ts=本批字节到达时刻
on_garbage(data, ts) 解析模式: 不成帧的原始 bytes(重同步丢弃字节, 不吞数据);
ts=到达时刻
on_raw(data, ts) 透传模式: 原始接收 bytes; ts=到达时刻
on_tx(label, data, ts) 发送回显: label=payload 字符串(自动包装)/None(HEX直发);
ts=写入串口时刻
@@ -43,6 +45,7 @@ class SerialLink:
def __init__(self, cfg, on_status):
self.on_status = on_status
self.on_frame = None
self.on_garbage = None
self.on_raw = None
self.on_tx = None
self.q = queue.Queue()
@@ -222,5 +225,11 @@ class SerialLink:
for fr in self.parser.feed(data):
if self.on_frame is not None:
self.on_frame(fr, ts)
# 不成帧的字节也透出: 串口助手是本体、协议解析是附加层, 解析不能
# 让收到的字节凭空消失(纯文本设备/波特率不符的乱码全在这里可见)。
# drain 必须无条件执行, 未接回调时也不能在解析器里无限累积。
junk = self.parser.drain_garbage()
if junk and self.on_garbage is not None:
self.on_garbage(junk, ts)
elif self.on_raw is not None:
self.on_raw(data, ts)
+7 -2
View File
@@ -8,7 +8,8 @@ SerialMate — RS485/串口调试助手(Fluent UI)
功能:
- 串口收发, 参数热切换; 半双工 RS485 友好(每个工作周期最多写一帧, 留应答间隙)
- 接收: 协议解析(包头+LEN 定界+CRC 校验, 坏帧重同步) / 原始透传 两模式切换
- 接收: 协议解析(包头+LEN 定界+CRC 校验, 坏帧重同步; 不成帧字节以原始行透出,
不吞数据) / 原始透传 两模式切换
- payload 显示 字符串/HEX 切换, 原始帧 hex 可选, 日志可保存
- 曲线: 4 通道, 二进制定长 / 文本数字 / 正则 三种提取方式 + 倍率
- 发送: 自动包装(payload 即协议载荷, '/' 分隔多条依次组帧) / HEX 直发,
@@ -49,7 +50,7 @@ from protocol import (BIN_FORMATS, CRC_ALGOS, CRC_ALGO_LABELS, CRC_SCOPE_LABELS,
from theme import ThemeManager
APP_NAME = 'SerialMate'
APP_VERSION = '0.1.1'
APP_VERSION = '0.1.2'
REPO_URL = 'https://gitea.hulk.wang/hulk/HW-Debug-Tools'
BAUDS = (1200, 2400, 4800, 9600, 19200, 38400, 57600, 115200, 230400, 460800, 921600)
@@ -250,6 +251,7 @@ class Window(QMainWindow):
self.link = SerialLink(cfg=self._proto_from_store(),
on_status=lambda s, ts: self.events.put(('status', s, ts)))
self.link.on_frame = lambda fr, ts: self.events.put(('frame', fr, ts))
self.link.on_garbage = lambda b, ts: self.events.put(('raw', b, ts))
self.link.on_raw = lambda b, ts: self.events.put(('raw', b, ts))
self.link.on_tx = lambda lb, d, ts: self.events.put(('tx', lb, d, ts))
self._build_ui()
@@ -470,6 +472,9 @@ class Window(QMainWindow):
self.combo_crc.currentIndexChanged.connect(self._apply_proto)
self.combo_scope.currentIndexChanged.connect(self._apply_proto)
self.chk_parse = CheckBox('解析协议帧(收)')
self.chk_parse.setToolTip(
'勾选=按协议格式解析, 合法帧结构化显示; 不成帧的字节不丢弃, 以原始行透出\n'
'不勾选=原始透传, 收到什么显示什么(基础串口助手模式)')
self.chk_parse.stateChanged.connect(self._on_parse_toggle)
self._row(lay, CaptionLabel('CRC'), self.combo_crc,
CaptionLabel('覆盖'), self.combo_scope, self.chk_parse)
+19 -3
View File
@@ -331,6 +331,9 @@ class FrameParser:
"""增量字节流 -> 完整帧: 前缀令牌逐位对齐(帧头/字面量, ID 为通配) + LEN 定长
+ CRC 校验; 任一处失配丢 1 字节重新同步。LEN 值经口径换算后即 payload 长度,
负值视为伪帧头逐出 —— 伪帧头最多滞留一帧即被逐出重同步, 对调试工具可接受。
丢弃字节不销毁: 累积进 self.garbage, 由上层 drain_garbage() 取走透出 ——
本工具首先是串口助手, 协议解析是附加层, 不成帧的字节也必须可见。
(仅匹配上前缀、还在等后续字节的"半帧"缓冲不算垃圾, 可能补全成真帧)
"""
def __init__(self, cfg):
@@ -349,9 +352,22 @@ class FrameParser:
self._len_pos = lay.len_pos
self._extra = lay.len_extra(cfg.len_sem, cs)
self._buf = bytearray()
self.garbage = bytearray() # 重同步丢弃的非帧字节(调用方定期取走)
def clear(self):
self._buf.clear()
self.garbage.clear()
def drain_garbage(self):
"""取走累积的非帧字节(bytes)并清空; 解析模式不吞数据的取数入口"""
out = bytes(self.garbage)
self.garbage.clear()
return out
def _drop_head(self):
"""丢缓冲首字节重同步; 丢弃字节记入 garbage(可能正是用户要看的输出)"""
self.garbage.append(self._buf[0])
del self._buf[:1]
def _aligned(self):
"""把缓冲对齐到前缀匹配; 字节不足且已匹配部分则保持等待(返回 True),
@@ -365,7 +381,7 @@ class FrameParser:
break # 失配 -> 丢 1 字节
else:
return True
del buf[:1]
self._drop_head()
if not buf:
return False
@@ -378,7 +394,7 @@ class FrameParser:
break # LEN 字节未到齐
plen = self._buf[self._len_pos] - self._extra
if plen < 0: # LEN 为负 -> 伪帧头, 逐出重同步
del self._buf[:1]
self._drop_head()
continue
total = cfg.layout.total_size(plen, cfg.crc_size)
if len(self._buf) < total:
@@ -388,7 +404,7 @@ class FrameParser:
frames.append(cfg.parse_frame(raw))
del self._buf[:total]
else:
del self._buf[:1] # CRC 失配 -> 丢帧头重同步
self._drop_head() # CRC 失配 -> 丢帧头重同步
return frames
+18
View File
@@ -93,6 +93,24 @@ frames = d.FrameParser(cfg).feed(bytes(bad) + good)
check('bad-crc then good', len(frames), 1)
check('recovered code', frames[0].code, 0xF0)
# 8b) 非帧字节不吞: 重同步丢弃的字节累积进 garbage 可整体取回(串口助手是本体,
# 协议解析只是附加层——纯文本设备接入时所有字节都从这里透出显示)
p = d.FrameParser(cfg)
check('纯文本不成帧', p.feed(b'hello serial\r\n'), [])
check('纯文本全入 garbage', p.drain_garbage(), b'hello serial\r\n')
check('garbage 取后清空', p.drain_garbage(), b'')
p = d.FrameParser(cfg)
frames = p.feed(b'\x00\x01' + good + b'tail')
check('噪声+帧+尾字节 帧数', len(frames), 1)
check('噪声与尾字节可取回', p.drain_garbage(), b'\x00\x01tail')
p = d.FrameParser(cfg)
p.feed(bytes(bad) + good)
check('坏CRC帧字节全透出', p.drain_garbage(), bytes(bad))
p = d.FrameParser(cfg)
p.feed(b'\xB5') # 只到帧头: 可能补全成真帧, 扣留不算垃圾
check('半帧头扣留待补全', p.drain_garbage(), b'')
check('补全后成帧', [f.code for f in p.feed(good[1:])], [0xF0])
# 9) 伪帧头与帧头字节入载荷
# 9a 小 LEN 伪帧头(B5 AA 01 55,CRC 应为 00 而实为 01)失败后逐字节重同步到真帧
frames = d.FrameParser(cfg).feed(b'\xB5\xAA\x01\x55\x01' + good)
+7
View File
@@ -165,6 +165,13 @@ win._on_raw(b'\x11\x22')
win._flush_rx()
check('透传HEX行', win.text_rx.toPlainText().splitlines()[-1].endswith('11 22'), True)
# 5b) 解析模式非帧字节: link.on_garbage 与透传同走 raw 事件, 日志不丢任何字节
# (v0.1.2 起协议解析是附加层, 串口助手本体语义优先)
check('on_garbage 已接线', win.link.on_garbage is not None, True)
win.events.put(('raw', b'\xAB\xCD', _time.time()))
win._drain_events()
check('非帧字节日志行', win.text_rx.toPlainText().splitlines()[-1].endswith('AB CD'), True)
# 6) 未连接发送丢弃
win._send()
win._drain_events()
+17 -2
View File
@@ -3,7 +3,8 @@
"""链路自检(无硬件): pyserial loop:// 虚拟回环验证 组帧→发送→接收→解析→曲线提取
====================================================================
缺 pyserial 时跳过(退出码 0)。loop:// 回环口写即读, 发出的帧同样回到
接收侧, 但 TX 帧头(D5)不是 RX 帧头(B5), 会被解析器当噪声丢弃, 不影响判定。
接收侧, 但 TX 帧头(D5)不是 RX 帧头(B5), 会作为非帧字节经 on_garbage 透出
(v0.1.2 起解析模式不吞数据), 不影响判定。
用法: python selftest_loop.py (全部通过退出码 0)
"""
@@ -38,10 +39,11 @@ def wait_for(pred, timeout=5.0):
return False
got = {'status': [], 'frames': [], 'tx': [], 'raw': []}
got = {'status': [], 'frames': [], 'tx': [], 'raw': [], 'garbage': []}
cfg = d.ProtoConfig()
link = SerialLink(cfg, on_status=lambda s, ts: got['status'].append((s, ts)))
link.on_frame = lambda fr, ts: got['frames'].append((fr, ts))
link.on_garbage = lambda b, ts: got['garbage'].append((b, ts))
link.on_raw = lambda b, ts: got['raw'].append((b, ts))
link.on_tx = lambda lb, data, ts: got['tx'].append((lb, data, ts))
@@ -75,6 +77,19 @@ if got['frames']:
v = d.extract_value(fr.payload, 'num', index=1, scale=0.1)
check('曲线提取(×0.1)', v is not None and abs(v - 24.6) < 1e-9, True)
# 3b) 解析模式下非帧字节不吞: 以 garbage 原样透出(带到达时刻)——
# 纯文本设备接入解析模式也全可见, 这是 v0.1.2 的核心语义
link.ser.write(b'plain text\n')
check('解析模式非帧字节透出',
wait_for(lambda: b'plain text\n'
in b''.join(b for b, _t in got['garbage'])), True)
if got['garbage']:
check('非帧字节携带到达时刻',
isinstance(got['garbage'][-1][1], float)
and 0 < time.time() - got['garbage'][-1][1] < 60, True)
# 回环回来的 TX 帧(D5 头)在解析模式下同样只算非帧字节, 不应进 frames
check('回环TX帧不算合法帧', all(fr.raw[0] == 0xB5 for fr, _t in got['frames']), True)
# 4) 透传模式切换: 关解析后收到原始字节
link.set_parse(False)
raw = bytes([0x11, 0x22, 0xB5, 0x00])