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:
+38
-3
@@ -93,6 +93,14 @@ SerialMate/
|
||||
5. **解析重同步策略**:前缀令牌逐位对齐(HDR/字面量须匹配、ID 通配),LEN 值经
|
||||
口径换算为 payload 长度,负值视为伪帧头逐出;CRC 失配丢 1 字节重同步——
|
||||
通用工具不预设合法 LEN 区间(与 DLPilot 的 LEN 范围预判不同)。
|
||||
5a. **解析不吞数据(v0.1.2 起的产品语义)**:串口助手是本体、协议解析是附加层。
|
||||
FrameParser 重同步丢弃的字节累积进 `garbage`,`link.SerialLink._dispatch_rx`
|
||||
每批 `drain_garbage()` 无条件取走(未接回调也不许在解析器里无限累积),
|
||||
经 `on_garbage(data, ts)` 上抛;GUI 把它并入 `('raw', ...)` 事件,与透传行
|
||||
同格式显示。纯文本设备/波特率不符的乱码在解析模式下必须全可见。
|
||||
例外:只匹配上前缀、还在等后续字节的"半帧"缓冲不算垃圾(可能补全成真帧),
|
||||
字节流恰好在帧头后截断时会扣留至多一个前缀长度的字节——可接受,勿改成
|
||||
超时强制吐出(会把慢速真帧撕碎)。改此语义需同步 selftest 8b / loop 3b。
|
||||
6. **ID 位不校验只透出**:`Frame.code` 原样上抛(布局无 ID 时为 None,GUI 日志
|
||||
与 selftest 均需兼容 None),TYPE_NAMES 仅用于显示译码(DLPilot 语义)。
|
||||
7. **BASE_DIR 语义**(config.py):frozen 时 =exe 所在目录,源码运行时 =src/;
|
||||
@@ -150,9 +158,9 @@ SerialMate/
|
||||
|
||||
```powershell
|
||||
python -m compileall -q . # 全模块可编译
|
||||
python selftest.py # 协议回归(47 项, 改 protocol.py 后必跑)
|
||||
python selftest_loop.py # 链路回环(需 pyserial, 改 link.py 后必跑)
|
||||
python selftest_gui.py # GUI 功能回归(offscreen, 改 main.py 后建议重跑)
|
||||
python selftest.py # 协议回归(67 项, 改 protocol.py 后必跑)
|
||||
python selftest_loop.py # 链路回环(21 项, 需 pyserial, 改 link.py 后必跑)
|
||||
python selftest_gui.py # GUI 功能回归(68 项, offscreen, 改 main.py 后建议重跑)
|
||||
python main.py --seconds 5 # GUI 冒烟(源码); offscreen: set QT_QPA_PLATFORM=offscreen
|
||||
python build_exe.py # → ../release/SerialMate.exe(缺 PyInstaller 自动装)
|
||||
python package_release.py <版本> # → ../release/SerialMate-v<版本>-win64.zip
|
||||
@@ -196,6 +204,33 @@ tag/release 命名 `SerialMate-vX.Y.Z`,zip 命名
|
||||
|
||||
## 7. 进度记录
|
||||
|
||||
### v0.1.2(2026-10-16,接收语义修复:解析模式不再吞数据)
|
||||
|
||||
背景:用户接 COM25(非 DLPilot 协议设备)开解析模式,接收区一片空白——
|
||||
`_dispatch_rx` 在解析模式只上抛完整合法帧,不成帧的字节被 FrameParser 静默
|
||||
丢弃;用户明确产品定位:**首先是基础串口调试工具,自定义协议解析是附加项**,
|
||||
附加能力不得影响本体功能。
|
||||
|
||||
已完成:
|
||||
- protocol.py:FrameParser 丢弃字节不再销毁——重同步逐出的字节累积进
|
||||
`garbage`,新增 `drain_garbage()` 取走清空;`clear()` 一并清垃圾;
|
||||
`feed()` 返回值不变(旧用法零改动)。
|
||||
- link.py:新增 `on_garbage(data, ts)` 回调(区别于透传模式的 `on_raw`,
|
||||
语义可分但 GUI 同样并入 raw 事件显示);`_dispatch_rx` 每批 drain 无条件
|
||||
执行(未接回调也不累积),有字节且接了回调才上抛,ts=同一批到达时刻。
|
||||
- main.py:接线 `on_garbage → ('raw', ...)`;"解析协议帧(收)"补 tooltip
|
||||
说明两模式语义;APP_VERSION 0.1.2。
|
||||
- 例外语义(有意为之):只匹配前缀、等待后续字节的半帧缓冲不算垃圾
|
||||
(可能补全成真帧);字节流恰在帧头后截断时扣留至多一个前缀长度。
|
||||
- 自检扩充:selftest 8b(纯文本全透出/噪声+帧+尾字节/坏CRC帧字节全透出/
|
||||
半帧头扣留后补全成帧)+10 项;selftest_loop 3b(解析模式非帧字节经
|
||||
on_garbage 透出、回环 TX 帧只算非帧字节)+3 项;selftest_gui 5b
|
||||
(on_garbage 接线+非帧字节日志行)+2 项;全部通过。
|
||||
- README 协议解析段同步"解析不吞数据"语义;exe 重建(v0.1.2);
|
||||
zip/Release 未发布(待用户验收)。
|
||||
|
||||
待办 / 计划:无新增,沿用 v0.1.0 待办。
|
||||
|
||||
### v0.1.1(2026-10-10,接收路径修复:时间戳失真 + 显示延迟累积)
|
||||
|
||||
背景:FilmForge 固件联调(GD32F405 RS485 115200,M2 连续温度上报 2~10 帧/s)
|
||||
|
||||
@@ -9,8 +9,10 @@
|
||||
- **串口收发**:端口自动枚举、波特率 1200~921600、数据位/校验/停止位可选,
|
||||
参数热切换;半双工 RS485 友好(每周期最多写一帧,留应答间隙)。
|
||||
- **协议解析**(可关,关=原始透传):
|
||||
增量字节流定界 + CRC 校验,失败逐字节重同步,噪声自动丢弃;
|
||||
增量字节流定界 + CRC 校验,失败逐字节重同步;
|
||||
接收帧 ID 位按 DLPilot 语义译码显示(0xF0=成功、0xFD=参数越界…)。
|
||||
**解析模式不吞数据**:不成帧的字节(纯文本设备、波特率不符的乱码、
|
||||
总线噪声)以原始行照常显示,协议解析只是附加层,不影响串口助手本体。
|
||||
- **帧构成定制**(协议卡,发送/接收共用同一布局,仅包头值分方向):
|
||||
帧构成用**令牌序列**定义,默认 `HDR ID LEN PAYLOAD CRC`(即 DLPilot 五段式):
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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])
|
||||
|
||||
Reference in New Issue
Block a user