diff --git a/SerialMate/AGENTS.md b/SerialMate/AGENTS.md index 15ed2e3..8957389 100644 --- a/SerialMate/AGENTS.md +++ b/SerialMate/AGENTS.md @@ -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) diff --git a/SerialMate/README.md b/SerialMate/README.md index 6e2f336..c60e1b2 100644 --- a/SerialMate/README.md +++ b/SerialMate/README.md @@ -9,8 +9,10 @@ - **串口收发**:端口自动枚举、波特率 1200~921600、数据位/校验/停止位可选, 参数热切换;半双工 RS485 友好(每周期最多写一帧,留应答间隙)。 - **协议解析**(可关,关=原始透传): - 增量字节流定界 + CRC 校验,失败逐字节重同步,噪声自动丢弃; + 增量字节流定界 + CRC 校验,失败逐字节重同步; 接收帧 ID 位按 DLPilot 语义译码显示(0xF0=成功、0xFD=参数越界…)。 + **解析模式不吞数据**:不成帧的字节(纯文本设备、波特率不符的乱码、 + 总线噪声)以原始行照常显示,协议解析只是附加层,不影响串口助手本体。 - **帧构成定制**(协议卡,发送/接收共用同一布局,仅包头值分方向): 帧构成用**令牌序列**定义,默认 `HDR ID LEN PAYLOAD CRC`(即 DLPilot 五段式): diff --git a/SerialMate/src/link.py b/SerialMate/src/link.py index 1f5e864..dd60f68 100644 --- a/SerialMate/src/link.py +++ b/SerialMate/src/link.py @@ -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) diff --git a/SerialMate/src/main.py b/SerialMate/src/main.py index 75ac61c..22aafe4 100644 --- a/SerialMate/src/main.py +++ b/SerialMate/src/main.py @@ -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) diff --git a/SerialMate/src/protocol.py b/SerialMate/src/protocol.py index bec61b9..e2581ec 100644 --- a/SerialMate/src/protocol.py +++ b/SerialMate/src/protocol.py @@ -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 diff --git a/SerialMate/src/selftest.py b/SerialMate/src/selftest.py index 3212168..810714a 100644 --- a/SerialMate/src/selftest.py +++ b/SerialMate/src/selftest.py @@ -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) diff --git a/SerialMate/src/selftest_gui.py b/SerialMate/src/selftest_gui.py index d2f1df3..d604fa1 100644 --- a/SerialMate/src/selftest_gui.py +++ b/SerialMate/src/selftest_gui.py @@ -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() diff --git a/SerialMate/src/selftest_loop.py b/SerialMate/src/selftest_loop.py index 10ffcc2..a4b1a01 100644 --- a/SerialMate/src/selftest_loop.py +++ b/SerialMate/src/selftest_loop.py @@ -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])