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
+38 -3
View File
@@ -93,6 +93,14 @@ SerialMate/
5. **解析重同步策略**:前缀令牌逐位对齐(HDR/字面量须匹配、ID 通配),LEN 值经 5. **解析重同步策略**:前缀令牌逐位对齐(HDR/字面量须匹配、ID 通配),LEN 值经
口径换算为 payload 长度,负值视为伪帧头逐出;CRC 失配丢 1 字节重同步—— 口径换算为 payload 长度,负值视为伪帧头逐出;CRC 失配丢 1 字节重同步——
通用工具不预设合法 LEN 区间(与 DLPilot 的 LEN 范围预判不同)。 通用工具不预设合法 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 日志 6. **ID 位不校验只透出**:`Frame.code` 原样上抛(布局无 ID 时为 None,GUI 日志
与 selftest 均需兼容 None),TYPE_NAMES 仅用于显示译码(DLPilot 语义)。 与 selftest 均需兼容 None),TYPE_NAMES 仅用于显示译码(DLPilot 语义)。
7. **BASE_DIR 语义**(config.py):frozen 时 =exe 所在目录,源码运行时 =src/; 7. **BASE_DIR 语义**(config.py):frozen 时 =exe 所在目录,源码运行时 =src/;
@@ -150,9 +158,9 @@ SerialMate/
```powershell ```powershell
python -m compileall -q . # 全模块可编译 python -m compileall -q . # 全模块可编译
python selftest.py # 协议回归(47 项, 改 protocol.py 后必跑) python selftest.py # 协议回归(67 项, 改 protocol.py 后必跑)
python selftest_loop.py # 链路回环(需 pyserial, 改 link.py 后必跑) python selftest_loop.py # 链路回环(21 项, 需 pyserial, 改 link.py 后必跑)
python selftest_gui.py # GUI 功能回归(offscreen, 改 main.py 后建议重跑) python selftest_gui.py # GUI 功能回归(68 项, offscreen, 改 main.py 后建议重跑)
python main.py --seconds 5 # GUI 冒烟(源码); offscreen: set QT_QPA_PLATFORM=offscreen python main.py --seconds 5 # GUI 冒烟(源码); offscreen: set QT_QPA_PLATFORM=offscreen
python build_exe.py # → ../release/SerialMate.exe(缺 PyInstaller 自动装) python build_exe.py # → ../release/SerialMate.exe(缺 PyInstaller 自动装)
python package_release.py <版本> # → ../release/SerialMate-v<版本>-win64.zip python package_release.py <版本> # → ../release/SerialMate-v<版本>-win64.zip
@@ -196,6 +204,33 @@ tag/release 命名 `SerialMate-vX.Y.Z`,zip 命名
## 7. 进度记录 ## 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,接收路径修复:时间戳失真 + 显示延迟累积) ### v0.1.1(2026-10-10,接收路径修复:时间戳失真 + 显示延迟累积)
背景:FilmForge 固件联调(GD32F405 RS485 115200,M2 连续温度上报 2~10 帧/s) 背景:FilmForge 固件联调(GD32F405 RS485 115200,M2 连续温度上报 2~10 帧/s)
+3 -1
View File
@@ -9,8 +9,10 @@
- **串口收发**:端口自动枚举、波特率 1200~921600、数据位/校验/停止位可选, - **串口收发**:端口自动枚举、波特率 1200~921600、数据位/校验/停止位可选,
参数热切换;半双工 RS485 友好(每周期最多写一帧,留应答间隙)。 参数热切换;半双工 RS485 友好(每周期最多写一帧,留应答间隙)。
- **协议解析**(可关,关=原始透传): - **协议解析**(可关,关=原始透传):
增量字节流定界 + CRC 校验,失败逐字节重同步,噪声自动丢弃; 增量字节流定界 + CRC 校验,失败逐字节重同步;
接收帧 ID 位按 DLPilot 语义译码显示(0xF0=成功、0xFD=参数越界…)。 接收帧 ID 位按 DLPilot 语义译码显示(0xF0=成功、0xFD=参数越界…)。
**解析模式不吞数据**:不成帧的字节(纯文本设备、波特率不符的乱码、
总线噪声)以原始行照常显示,协议解析只是附加层,不影响串口助手本体。
- **帧构成定制**(协议卡,发送/接收共用同一布局,仅包头值分方向): - **帧构成定制**(协议卡,发送/接收共用同一布局,仅包头值分方向):
帧构成用**令牌序列**定义,默认 `HDR ID LEN PAYLOAD CRC`(即 DLPilot 五段式): 帧构成用**令牌序列**定义,默认 `HDR ID LEN PAYLOAD CRC`(即 DLPilot 五段式):
+9
View File
@@ -33,6 +33,8 @@ class SerialLink:
"""串口工作线程。回调(后台线程触发, 第二参数 ts=事件发生的 epoch 秒): """串口工作线程。回调(后台线程触发, 第二参数 ts=事件发生的 epoch 秒):
on_status(text, ts) 连接状态变化(已连接/断开/失败/设备拔出) on_status(text, ts) 连接状态变化(已连接/断开/失败/设备拔出)
on_frame(fr, ts) 解析模式: 完整 Frame(protocol.Frame); ts=本批字节到达时刻 on_frame(fr, ts) 解析模式: 完整 Frame(protocol.Frame); ts=本批字节到达时刻
on_garbage(data, ts) 解析模式: 不成帧的原始 bytes(重同步丢弃字节, 不吞数据);
ts=到达时刻
on_raw(data, ts) 透传模式: 原始接收 bytes; ts=到达时刻 on_raw(data, ts) 透传模式: 原始接收 bytes; ts=到达时刻
on_tx(label, data, ts) 发送回显: label=payload 字符串(自动包装)/None(HEX直发); on_tx(label, data, ts) 发送回显: label=payload 字符串(自动包装)/None(HEX直发);
ts=写入串口时刻 ts=写入串口时刻
@@ -43,6 +45,7 @@ class SerialLink:
def __init__(self, cfg, on_status): def __init__(self, cfg, on_status):
self.on_status = on_status self.on_status = on_status
self.on_frame = None self.on_frame = None
self.on_garbage = None
self.on_raw = None self.on_raw = None
self.on_tx = None self.on_tx = None
self.q = queue.Queue() self.q = queue.Queue()
@@ -222,5 +225,11 @@ class SerialLink:
for fr in self.parser.feed(data): for fr in self.parser.feed(data):
if self.on_frame is not None: if self.on_frame is not None:
self.on_frame(fr, ts) 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: elif self.on_raw is not None:
self.on_raw(data, ts) self.on_raw(data, ts)
+7 -2
View File
@@ -8,7 +8,8 @@ SerialMate — RS485/串口调试助手(Fluent UI)
功能: 功能:
- 串口收发, 参数热切换; 半双工 RS485 友好(每个工作周期最多写一帧, 留应答间隙) - 串口收发, 参数热切换; 半双工 RS485 友好(每个工作周期最多写一帧, 留应答间隙)
- 接收: 协议解析(包头+LEN 定界+CRC 校验, 坏帧重同步) / 原始透传 两模式切换 - 接收: 协议解析(包头+LEN 定界+CRC 校验, 坏帧重同步; 不成帧字节以原始行透出,
不吞数据) / 原始透传 两模式切换
- payload 显示 字符串/HEX 切换, 原始帧 hex 可选, 日志可保存 - payload 显示 字符串/HEX 切换, 原始帧 hex 可选, 日志可保存
- 曲线: 4 通道, 二进制定长 / 文本数字 / 正则 三种提取方式 + 倍率 - 曲线: 4 通道, 二进制定长 / 文本数字 / 正则 三种提取方式 + 倍率
- 发送: 自动包装(payload 即协议载荷, '/' 分隔多条依次组帧) / HEX 直发, - 发送: 自动包装(payload 即协议载荷, '/' 分隔多条依次组帧) / HEX 直发,
@@ -49,7 +50,7 @@ from protocol import (BIN_FORMATS, CRC_ALGOS, CRC_ALGO_LABELS, CRC_SCOPE_LABELS,
from theme import ThemeManager from theme import ThemeManager
APP_NAME = 'SerialMate' APP_NAME = 'SerialMate'
APP_VERSION = '0.1.1' APP_VERSION = '0.1.2'
REPO_URL = 'https://gitea.hulk.wang/hulk/HW-Debug-Tools' REPO_URL = 'https://gitea.hulk.wang/hulk/HW-Debug-Tools'
BAUDS = (1200, 2400, 4800, 9600, 19200, 38400, 57600, 115200, 230400, 460800, 921600) 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(), self.link = SerialLink(cfg=self._proto_from_store(),
on_status=lambda s, ts: self.events.put(('status', s, ts))) 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_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_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.link.on_tx = lambda lb, d, ts: self.events.put(('tx', lb, d, ts))
self._build_ui() self._build_ui()
@@ -470,6 +472,9 @@ class Window(QMainWindow):
self.combo_crc.currentIndexChanged.connect(self._apply_proto) self.combo_crc.currentIndexChanged.connect(self._apply_proto)
self.combo_scope.currentIndexChanged.connect(self._apply_proto) self.combo_scope.currentIndexChanged.connect(self._apply_proto)
self.chk_parse = CheckBox('解析协议帧(收)') self.chk_parse = CheckBox('解析协议帧(收)')
self.chk_parse.setToolTip(
'勾选=按协议格式解析, 合法帧结构化显示; 不成帧的字节不丢弃, 以原始行透出\n'
'不勾选=原始透传, 收到什么显示什么(基础串口助手模式)')
self.chk_parse.stateChanged.connect(self._on_parse_toggle) self.chk_parse.stateChanged.connect(self._on_parse_toggle)
self._row(lay, CaptionLabel('CRC'), self.combo_crc, self._row(lay, CaptionLabel('CRC'), self.combo_crc,
CaptionLabel('覆盖'), self.combo_scope, self.chk_parse) CaptionLabel('覆盖'), self.combo_scope, self.chk_parse)
+19 -3
View File
@@ -331,6 +331,9 @@ class FrameParser:
"""增量字节流 -> 完整帧: 前缀令牌逐位对齐(帧头/字面量, ID 为通配) + LEN 定长 """增量字节流 -> 完整帧: 前缀令牌逐位对齐(帧头/字面量, ID 为通配) + LEN 定长
+ CRC 校验; 任一处失配丢 1 字节重新同步。LEN 值经口径换算后即 payload 长度, + CRC 校验; 任一处失配丢 1 字节重新同步。LEN 值经口径换算后即 payload 长度,
负值视为伪帧头逐出 —— 伪帧头最多滞留一帧即被逐出重同步, 对调试工具可接受。 负值视为伪帧头逐出 —— 伪帧头最多滞留一帧即被逐出重同步, 对调试工具可接受。
丢弃字节不销毁: 累积进 self.garbage, 由上层 drain_garbage() 取走透出 ——
本工具首先是串口助手, 协议解析是附加层, 不成帧的字节也必须可见。
(仅匹配上前缀、还在等后续字节的"半帧"缓冲不算垃圾, 可能补全成真帧)
""" """
def __init__(self, cfg): def __init__(self, cfg):
@@ -349,9 +352,22 @@ class FrameParser:
self._len_pos = lay.len_pos self._len_pos = lay.len_pos
self._extra = lay.len_extra(cfg.len_sem, cs) self._extra = lay.len_extra(cfg.len_sem, cs)
self._buf = bytearray() self._buf = bytearray()
self.garbage = bytearray() # 重同步丢弃的非帧字节(调用方定期取走)
def clear(self): def clear(self):
self._buf.clear() 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): def _aligned(self):
"""把缓冲对齐到前缀匹配; 字节不足且已匹配部分则保持等待(返回 True), """把缓冲对齐到前缀匹配; 字节不足且已匹配部分则保持等待(返回 True),
@@ -365,7 +381,7 @@ class FrameParser:
break # 失配 -> 丢 1 字节 break # 失配 -> 丢 1 字节
else: else:
return True return True
del buf[:1] self._drop_head()
if not buf: if not buf:
return False return False
@@ -378,7 +394,7 @@ class FrameParser:
break # LEN 字节未到齐 break # LEN 字节未到齐
plen = self._buf[self._len_pos] - self._extra plen = self._buf[self._len_pos] - self._extra
if plen < 0: # LEN 为负 -> 伪帧头, 逐出重同步 if plen < 0: # LEN 为负 -> 伪帧头, 逐出重同步
del self._buf[:1] self._drop_head()
continue continue
total = cfg.layout.total_size(plen, cfg.crc_size) total = cfg.layout.total_size(plen, cfg.crc_size)
if len(self._buf) < total: if len(self._buf) < total:
@@ -388,7 +404,7 @@ class FrameParser:
frames.append(cfg.parse_frame(raw)) frames.append(cfg.parse_frame(raw))
del self._buf[:total] del self._buf[:total]
else: else:
del self._buf[:1] # CRC 失配 -> 丢帧头重同步 self._drop_head() # CRC 失配 -> 丢帧头重同步
return frames 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('bad-crc then good', len(frames), 1)
check('recovered code', frames[0].code, 0xF0) 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) 伪帧头与帧头字节入载荷 # 9) 伪帧头与帧头字节入载荷
# 9a 小 LEN 伪帧头(B5 AA 01 55,CRC 应为 00 而实为 01)失败后逐字节重同步到真帧 # 9a 小 LEN 伪帧头(B5 AA 01 55,CRC 应为 00 而实为 01)失败后逐字节重同步到真帧
frames = d.FrameParser(cfg).feed(b'\xB5\xAA\x01\x55\x01' + good) 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() win._flush_rx()
check('透传HEX行', win.text_rx.toPlainText().splitlines()[-1].endswith('11 22'), True) 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) 未连接发送丢弃 # 6) 未连接发送丢弃
win._send() win._send()
win._drain_events() win._drain_events()
+17 -2
View File
@@ -3,7 +3,8 @@
"""链路自检(无硬件): pyserial loop:// 虚拟回环验证 组帧→发送→接收→解析→曲线提取 """链路自检(无硬件): pyserial loop:// 虚拟回环验证 组帧→发送→接收→解析→曲线提取
==================================================================== ====================================================================
缺 pyserial 时跳过(退出码 0)。loop:// 回环口写即读, 发出的帧同样回到 缺 pyserial 时跳过(退出码 0)。loop:// 回环口写即读, 发出的帧同样回到
接收侧, 但 TX 帧头(D5)不是 RX 帧头(B5), 会被解析器当噪声丢弃, 不影响判定。 接收侧, 但 TX 帧头(D5)不是 RX 帧头(B5), 会作为非帧字节经 on_garbage 透出
(v0.1.2 起解析模式不吞数据), 不影响判定。
用法: python selftest_loop.py (全部通过退出码 0) 用法: python selftest_loop.py (全部通过退出码 0)
""" """
@@ -38,10 +39,11 @@ def wait_for(pred, timeout=5.0):
return False return False
got = {'status': [], 'frames': [], 'tx': [], 'raw': []} got = {'status': [], 'frames': [], 'tx': [], 'raw': [], 'garbage': []}
cfg = d.ProtoConfig() cfg = d.ProtoConfig()
link = SerialLink(cfg, on_status=lambda s, ts: got['status'].append((s, ts))) 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_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_raw = lambda b, ts: got['raw'].append((b, ts))
link.on_tx = lambda lb, data, ts: got['tx'].append((lb, data, 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) 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) 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) 透传模式切换: 关解析后收到原始字节 # 4) 透传模式切换: 关解析后收到原始字节
link.set_parse(False) link.set_parse(False)
raw = bytes([0x11, 0x22, 0xB5, 0x00]) raw = bytes([0x11, 0x22, 0xB5, 0x00])