fix: SerialMate v0.1.1 接收路径修复——时间戳改 link 线程到达时刻打戳、接收日志批量插入+5000行上限+积压折叠、通道配置缓存;自测扩至 selftest_gui 69 项,新增 ISSUE 报告与 _sim_periodic 复现脚本;重打包

This commit is contained in:
2026-10-13 11:44:29 +08:00
parent c91b4aa800
commit 7a236a63da
10 changed files with 411 additions and 66 deletions
+67 -1
View File
@@ -54,7 +54,7 @@ SerialMate/
保证内容贴顶(卡片被拖高后富余高度收在卡底,勿删——否则拉伸被各行均分, 保证内容贴顶(卡片被拖高后富余高度收在卡底,勿删——否则拉伸被各行均分,
标题上方出现空隙);发送卡行序:板型管理行(下拉+新建+删除+配置+重载) / 标题上方出现空隙);发送卡行序:板型管理行(下拉+新建+删除+配置+重载) /
预设行(下拉+填入+直接发送) / 模式行(下拉+存为板型+存入预设) / 输入行 / 预设行(下拉+填入+直接发送) / 模式行(下拉+存为板型+存入预设) / 输入行 /
周期行;帧日志 payload 高亮走 `_append_rx_frame`(html.escape 后拼 span 周期行;帧日志 payload 高亮走 `_append_rx_frame`(`_esc_log` 转义后拼 span
背景,toPlainText 内容不变,selftest 的日志断言因此不受影响);预设下拉 背景,toPlainText 内容不变,selftest 的日志断言因此不受影响);预设下拉
显示"名称 · 命令",存入命令走 `_add_command_named`(弹窗核 `_add_command` 显示"名称 · 命令",存入命令走 `_add_command_named`(弹窗核 `_add_command`
包装,测试用核函数),预设直发走 `_send_preset_direct`(经输入框复用 包装,测试用核函数),预设直发走 `_send_preset_direct`(经输入框复用
@@ -65,6 +65,21 @@ SerialMate/
2. **回调线程边界**:SerialLink 的 on_status/on_frame/on_raw/on_tx 全在后台线程 2. **回调线程边界**:SerialLink 的 on_status/on_frame/on_raw/on_tx 全在后台线程
触发,一律 `events.put(...)` 投递,主线程 30ms QTimer 轮询 `_drain_events` 触发,一律 `events.put(...)` 投递,主线程 30ms QTimer 轮询 `_drain_events`
分发(与 DLPilot `_poll_dlpc` 同模式),禁止回调里直接碰 GUI。 分发(与 DLPilot `_poll_dlpc` 同模式),禁止回调里直接碰 GUI。
**时间戳由 link 后台线程打**(字节到达/写入串口时刻):四个回调签名均带
尾参 ts 并随事件入队(事件元组尾部即 ts,如 `('frame', fr, ts)`)——GUI
消费侧只经 `_timestamp(t)` 格式化、不现取(消费时刻打戳会随 UI 卡顿成批
挤压失真,v0.1.0 教训,见 `ISSUE_rx_timestamp_and_lag.md`)。
2b. **接收日志治理**:日志行只入 `_rx_buf`(`_append_rx`/`_append_rx_frame`
负责转义),`_drain_events` 末尾 `_flush_rx` 一次 appendHtml——每行必须包
`<p style="margin:0">`(**`<br>` 连接只算 1 个 block,会让
maximumBlockCount 裁旧行失效**,offscreen 实验验证过);转义用 `_esc_log`
(空格转 `&nbsp;`,防富文本折叠连续空格破坏 TX 行对齐);`_update_counters`
由 drain 末尾统一调一次,勿回到每帧调用。事件积压超 `FOLD_QSIZE`(200) 时
该批帧走 `_absorb_frame`(曲线照常入库、计数器照常,日志折叠为带首尾到达
时刻的计数摘要行,消化到阈值内自动恢复逐帧)。
2c. **通道配置缓存**:`_on_frame` 帧路径只读 `self._ch_cfg`(`_on_channel`
用户改动配置时经 `_collect_channels` 重建),禁止每帧遍历 UI 控件读配置
(高频时放大 UI 线程占用,v0.1.0 延迟累积的帮凶之一)。
3. **半双工节奏**:link 工作线程每个循环周期最多出队写一帧(20ms 间隔), 3. **半双工节奏**:link 工作线程每个循环周期最多出队写一帧(20ms 间隔),
RS485 总线多帧连发留应答间隙,勿改成批量 drain 一次写。 RS485 总线多帧连发留应答间隙,勿改成批量 drain 一次写。
4. **帧构成语义**:帧由 `FrameLayout` 令牌序列定义(`HDR ID LEN PAYLOAD CRC` 4. **帧构成语义**:帧由 `FrameLayout` 令牌序列定义(`HDR ID LEN PAYLOAD CRC`
@@ -181,6 +196,57 @@ tag/release 命名 `SerialMate-vX.Y.Z`,zip 命名
## 7. 进度记录 ## 7. 进度记录
### v0.1.1(2026-10-10,接收路径修复:时间戳失真 + 显示延迟累积)
背景:FilmForge 固件联调(GD32F405 RS485 115200,M2 连续温度上报 2~10 帧/s)
发现日志时间戳成批挤压、显示延迟越跑越大;固件侧二分验证 100% 出帧,问题
独立存在于 SerialMate 显示层,见 `ISSUE_rx_timestamp_and_lag.md`。
已完成:
- **根因 1**(时间戳在 GUI 消费时刻打):时间戳改在 link 后台线程打
(字节到达/写入串口时刻),`on_status/on_frame/on_raw/on_tx` 签名均追加
尾参 ts 并随事件入队;`_timestamp(t)` 变为纯格式化函数。日志时间恢复为
真实到达时间。
- **根因 2**(接收日志无限增长 → UI 递减性卡顿 → 延迟累积):
- 接收日志批量插入:`_drain_events` 把本轮日志行攒入 `_rx_buf`,末尾
`_flush_rx` 一次 appendHtml(每行一个 `<p style="margin:0">`,
maximumBlockCount 裁旧行照常生效;`_esc_log` 空格转 `&nbsp;` 防富文本
折叠连续空格);`_update_counters` 改为 drain 末尾统一一次;
- 通道配置缓存 `_ch_cfg`:`_on_channel` 时重建,帧路径不再每帧遍历控件
(提取逻辑收敛进 `_extract_channels`);
- 积压折叠:事件队列超 `FOLD_QSIZE`(200) 时该批帧走 `_absorb_frame`——
曲线照常入库、计数器照常,日志折叠为一行计数摘要(含首尾到达时刻,
如 `← 12:00:00.100~12:00:02.300 ⏳ 显示滞后, 折叠 250 帧: B=0xF0 温度
上报 ×250`),消化到阈值内自动恢复逐帧;
- 行数上限 5000(v0.1.0 末期已加的止血,本次提为常量 `RX_MAX_BLOCKS`)。
- 自检扩充:selftest_loop 增回调 ts 断言共 18 项;selftest_gui 增时间戳
格式化/帧日志用到达时刻/通道缓存同步/积压折叠 8 项共 69 项;另做端到端
模拟(600 帧积压一次性消费):日志压到 201 行、曲线 600 点全收、时间戳
单调且逐帧等于真实到达时刻。
- README 接收显示段同步时间戳/日志治理语义;exe 重建入库(v0.1.1);
zip/Release 未发布(待用户验收)。
- **真机 M2S100"卡住"排查(2026-10-10,用户复测反馈)**:用户 v0.1.1 exe
发 M2S100 后 ack 与周期帧均不显示。本机分层复现均正常——裸 pyserial+
解析器(帧 100% 到达、ack ok、100ms 间隔稳)、offscreen GUI 全链路
(60 帧/6s 一帧不丢)、桌面真渲染完整用户序列(连接→M1/M2→M2S100→帧流,
138 帧全显示)。exe 经归档解包确认含 v0.1.1 代码。未能复现,定性为
用户端环境/运行态问题,待用户按状态栏"积压"指示再复现时定位。
排查副产品:
- link.py 串口参数加 `inter_byte_timeout=0.02`(Win32
ReadIntervalTimeout)——注意 **pyserial 的 timeout 是总超时**(150ms 内
必返回,不无限攒批),此参数把周期帧的 read 粒度从 ~150ms 一批改善为
单帧粒度(帧时间戳更准);
- 状态栏计数器追加 **积压 N**(events.qsize())——常驻分层观测:数字
持续增长=事件产出快于消费(GUI 侧问题),恒 0=RX 链路断(串口/驱动/
固件侧问题);"卡住"时 UI 整体无响应则是主线程被阻塞,另查。
- 勘误:修复初期曾把 M2S100 卡住归因"read(4096) 攒批 41 秒",该论述
错误(win32 timeout 为总超时),已修正注释;loop:// 虚拟口的 read
立即返回缓冲,**测不出真串口等待行为**,链路自检覆盖不到此处。
- 探测善后:COM5 FilmForge 固件曾残留 100ms 上报态(用户上次 M2S100
生效的证据),已发 M2S0 关闭(M2 查询=OFF)。
待办 / 计划:无新增,沿用 v0.1.0 待办。
### v0.1.0(2026-09-27,首版) ### v0.1.0(2026-09-27,首版)
已完成: 已完成:
+97
View File
@@ -0,0 +1,97 @@
# SerialMate 接收路径问题报告(时间戳失真 + 显示延迟累积)
> 来源:FilmForge 固件联调(GD32F405 RS485,115200,M2 连续温度上报 2~10 帧/s)。
> 现象与固件侧二分验证结论一起整理,供后续修复。
> 日期:2026-10-10
>
> **【已修复 v0.1.1,2026-10-10】** 根因 1 与根因 2 的 1/2/3/4 全部落地,
> 实现与本文建议的差别:日志批量插入每行包 `<p style="margin:0">`(`<br>`
> 连接只算 1 个 block,会让 maximumBlockCount 失效);转义空格转 `&nbsp;`
> 防富文本折叠连续空格;通道缓存放 `_ch_cfg`(`_on_channel` 时重建)。
> 自检已覆盖(selftest_gui 69 项含积压折叠/到达时间戳用例),
> 端到端模拟 600 帧积压:日志 201 行、曲线 600 点全收、时间戳=到达时刻。
> 详见 AGENTS.md §7 v0.1.1。
## 现象(联调实测)
1. **时间戳挤压**:M2 以 500ms/100ms 周期上报时,日志里出现 13 帧"0.2~1ms 间隔"
挤在同一批(如 23:59:55.221~227),完全不是真实到达间隔。
2. **显示延迟累积**:命令 ack 延迟 120~160ms 才显示;长时间运行后越来越卡,
最终表现为"命令发了没反应"(实际帧在队列里排队,晚些时候才显示)。
3. **偶发"丢帧"观感**:与 2 叠加后,ack 迟迟不显示,上位机/人工判断为无响应。
固件侧已用调试串口镜像做过二分验证:**帧到达固件、固件发出响应均为 100%**,
SerialMate 显示层的时间戳与延迟失真是独立存在的第二层问题。
## 根因 1:时间戳在 GUI 消费时刻打,而非字节到达时刻(确凿)
- `link.py` `_dispatch_rx()` 后台线程解析出帧后回调 `on_frame(fr)`;
- `main.py:237` `self.link.on_frame = lambda fr: self.events.put(('frame', fr))`
—— **入队时不带时间戳**;
- `main.py:1291` `_on_frame()` 里才调 `_timestamp()` —— **消费时打戳**。
后台读线程 20ms 轮询、GUI QTimer 30ms drain,再叠加 UI 卡顿,一串积压帧的
时间戳全部落在消费瞬间,毫秒级挤在一起。**这不是到达时间,是显示时间。**
### 修复建议
时间戳统一在 link 后台线程产生(字节到达时刻),随事件入队:
```python
# link.py _dispatch_rx()
ts = time.time() # 或 time.perf_counter()
for fr in self.parser.feed(data):
if self.on_frame is not None:
self.on_frame(fr, ts) # 携带到达时刻
# raw 同理: self.on_raw(data, ts)
# main.py
self.link.on_frame = lambda fr, ts: self.events.put(('frame', fr, ts))
...
def _on_frame(self, fr, ts): # 显示时用 ts 格式化,不再调 _timestamp()
```
`on_raw` / `on_tx`(若也要求发送时刻)/ `on_status` 同理改造。
`_timestamp()` 改为接受 epoch 秒参数的纯格式化函数。
## 根因 2:接收日志无限增长 → UI 递减性卡顿 → 延迟累积(确凿)
- `_append_rx`/`_append_rx_frame` 对 `text_rx` 逐行 `appendPlainText/appendHtml`,
**无任何行数上限**。长跑后 QPlainTextEdit 文档越滚越大,排版/paint 成本上升,
30ms 的 `_drain_events` 开始处理不完,事件积压,显示延迟越拉越大。
- `_on_frame` 每帧调用 `_collect_channels()`(遍历全部通道 UI 控件读
isChecked/text/value),帧率高时放大 UI 线程占用。
### 修复建议
1. 行数上限:`self.text_rx.setMaximumBlockCount(5000)`(一行代码,先止血);
或定时(如每 5s)裁剪超量历史。
2. 批量插入:`_drain_events` 把本轮全部行拼成一个字符串/HTML 片段,**一次**
append,减少 N 次 UI 重排为 1 次。
3. 通道配置缓存:维护 `self._ch_cfg`,仅在 `_on_channel`(用户改动配置)时
重建;`_on_frame` 直接读缓存,不再每帧遍历控件。
4. 帧率自适应:积压超过阈值(如 events.qsize() > 200)时,帧日志折叠为
计数摘要("…+250 帧 (温度上报)"),保曲线、压日志。
## 排除项(验证过没有问题)
- `protocol.FrameParser`(前缀对齐 + LEN + CRC,失配丢 1 字节重同步)解析器
本身健壮,未发现丢帧逻辑缺陷;`src/selftest.py` 覆盖良好。
- `events = queue.Queue()` 无上限,不会丢事件,只会延迟。
- `link.py` 读线程 20ms 轮询 + `read(4096)`,常规速率下不会溢出驱动缓冲
(115200 下 20ms 最多 288 字节)。
## 优先级建议
1. 先做根因 2 的"1. 行数上限"(一行代码)——立刻止血卡顿。
2. 再做根因 1 的时间戳随事件携带——日志时间恢复可信。
3. 根因 2 的 2/3/4 与根因 1 的状态/tx 同步改造,作为一轮完整性优化。
## 复现方法
固件端(FilmForge,GD32F405,RS485 115200):
- `M2S500` 开启 500ms 周期温度上报(帧 `B5 F0 04 "25.6" CRC`);
- 连续观察 10 分钟以上,或把上报间隔调到 `M2S100` 加速日志增长。
预期:修复前时间戳随 UI 卡顿成批挤压、显示延迟随运行时间增大;
修复后时间戳单调且间隔≈设定周期,长时间运行无卡顿增长。
+4 -1
View File
@@ -36,7 +36,10 @@
| CRC 算法 | SUM8 累加和 / CRC8 / CRC16 MODBUS / CRC16 XMODEM / CRC16 CCITT-FALSE | SUM8 | | CRC 算法 | SUM8 累加和 / CRC8 / CRC16 MODBUS / CRC16 XMODEM / CRC16 CCITT-FALSE | SUM8 |
| CRC 覆盖 | 含包头(整帧) / 不含包头(跳过第1字节) / 仅 payload | 不含包头 | | CRC 覆盖 | 含包头(整帧) / 不含包头(跳过第1字节) / 仅 payload | 不含包头 |
- **接收显示**:payload 一键切换 **字符串 / HEX**;可选附原始帧 hex; - **接收显示**:payload 一键切换 **字符串 / HEX**;可选附原始帧 hex;
时间戳;日志保存为 txt。 时间戳为**字节到达时刻**(串口后台线程打点随帧携带,UI 繁忙时日志时间
依然可信);日志保存为 txt。接收日志自动治理:行数上限 5000(超量淘汰
最旧)、批量刷新、事件积压时高频帧日志自动折叠为计数摘要(曲线照常
采集)——长时间高频运行不累积卡顿。
- **曲线**:4 通道,每帧有效数据按通道提取一个数值,支持三种提取方式 - **曲线**:4 通道,每帧有效数据按通道提取一个数值,支持三种提取方式
(二进制定长 u8~f32 大小端 / 文本第 N 个数字 / 自定义正则)+ 倍率, (二进制定长 u8~f32 大小端 / 文本第 N 个数字 / 自定义正则)+ 倍率,
y 轴自动或手动量程,暂停/清空;绘图区可**弹成独立窗口**("弹出"按钮, y 轴自动或手动量程,暂停/清空;绘图区可**弹成独立窗口**("弹出"按钮,
+4 -1
View File
@@ -36,7 +36,10 @@
| CRC 算法 | SUM8 累加和 / CRC8 / CRC16 MODBUS / CRC16 XMODEM / CRC16 CCITT-FALSE | SUM8 | | CRC 算法 | SUM8 累加和 / CRC8 / CRC16 MODBUS / CRC16 XMODEM / CRC16 CCITT-FALSE | SUM8 |
| CRC 覆盖 | 含包头(整帧) / 不含包头(跳过第1字节) / 仅 payload | 不含包头 | | CRC 覆盖 | 含包头(整帧) / 不含包头(跳过第1字节) / 仅 payload | 不含包头 |
- **接收显示**:payload 一键切换 **字符串 / HEX**;可选附原始帧 hex; - **接收显示**:payload 一键切换 **字符串 / HEX**;可选附原始帧 hex;
时间戳;日志保存为 txt。 时间戳为**字节到达时刻**(串口后台线程打点随帧携带,UI 繁忙时日志时间
依然可信);日志保存为 txt。接收日志自动治理:行数上限 5000(超量淘汰
最旧)、批量刷新、事件积压时高频帧日志自动折叠为计数摘要(曲线照常
采集)——长时间高频运行不累积卡顿。
- **曲线**:4 通道,每帧有效数据按通道提取一个数值,支持三种提取方式 - **曲线**:4 通道,每帧有效数据按通道提取一个数值,支持三种提取方式
(二进制定长 u8~f32 大小端 / 文本第 N 个数字 / 自定义正则)+ 倍率, (二进制定长 u8~f32 大小端 / 文本第 N 个数字 / 自定义正则)+ 倍率,
y 轴自动或手动量程,暂停/清空;绘图区可**弹成独立窗口**("弹出"按钮, y 轴自动或手动量程,暂停/清空;绘图区可**弹成独立窗口**("弹出"按钮,
Binary file not shown.
+49
View File
@@ -0,0 +1,49 @@
"""模拟真机 M2S100 场景: 设备 100ms 周期上报 30 帧, 验证帧按帧粒度
到达 GUI 回调(间隔≈100ms, 到达→消费延迟<300ms), 而非攒批后一次涌入。
"""
import os
import sys
import time
import threading
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from link import SerialLink
import protocol as d
got = []
cfg = d.ProtoConfig()
link = SerialLink(cfg, on_status=lambda s, ts: None)
link.on_frame = lambda fr, ts: got.append((time.time(), ts))
link.open('loop://', 115200)
deadline = time.time() + 5
while time.time() < deadline and not link.is_connected():
time.sleep(0.05)
assert link.is_connected(), 'loop:// 未连接'
resp = bytes.fromhex('B5 F0 08 6F 6B 54 4C 3A 32 34 36 48')
def feeder():
for _ in range(30):
link.ser.write(resp)
time.sleep(0.1)
t = threading.Thread(target=feeder)
t.start()
t.join()
time.sleep(0.5) # 等最后一帧走完读线程
n = len(got)
print('收到帧数:', n, '(期望 30)')
if n >= 2:
gaps = [b[0] - a[0] for a, b in zip(got, got[1:])]
lag = [cb - ts for cb, ts in got]
print('回调间隔 min/max/avg(ms): %d / %d / %d'
% (min(gaps) * 1000, max(gaps) * 1000, sum(gaps) / len(gaps) * 1000))
print('到达→消费延迟 max(ms):', round(max(lag) * 1000))
ok = n == 30 and max(gaps) < 0.5 and max(lag) < 0.3
else:
ok = False
print('RESULT:', 'PASS' if ok else 'FAIL')
link.close()
sys.exit(0 if ok else 1)
+26 -15
View File
@@ -30,11 +30,14 @@ DEFAULTS = dict(bytesize=8, parity='N', stopbits=1)
class SerialLink: class SerialLink:
"""串口工作线程。回调(后台线程触发): """串口工作线程。回调(后台线程触发, 第二参数 ts=事件发生的 epoch 秒):
on_status(str) 连接状态变化(已连接/断开/失败/设备拔出) on_status(text, ts) 连接状态变化(已连接/断开/失败/设备拔出)
on_frame(fr) 解析模式: 完整 Frame(protocol.Frame) on_frame(fr, ts) 解析模式: 完整 Frame(protocol.Frame); ts=本批字节到达时刻
on_raw(data) 透传模式: 原始接收 bytes on_raw(data, ts) 透传模式: 原始接收 bytes; ts=到达时刻
on_tx(label, data) 发送回显: label=payload 字符串(自动包装)/None(HEX直发) on_tx(label, data, ts) 发送回显: label=payload 字符串(自动包装)/None(HEX直发);
ts=写入串口时刻
时间戳必须由本模块在读线程打(字节到达时刻), GUI 消费侧再打会随 UI 卡顿
成批挤压失真——日志时间就不再是到达时间。
""" """
def __init__(self, cfg, on_status): def __init__(self, cfg, on_status):
@@ -100,7 +103,7 @@ class SerialLink:
def open(self, port, baud=DEFAULT_BAUD, **kw): def open(self, port, baud=DEFAULT_BAUD, **kw):
if not HAS_SERIAL: if not HAS_SERIAL:
self.on_status('未安装 pyserial: py -m pip install pyserial') self.on_status('未安装 pyserial: py -m pip install pyserial', time.time())
return return
params = {**DEFAULTS, **kw} params = {**DEFAULTS, **kw}
with self._lock: with self._lock:
@@ -123,7 +126,7 @@ class SerialLink:
with self._lock: with self._lock:
ser = self.ser ser = self.ser
if ser is None: if ser is None:
self.on_status('未连接串口, 命令被丢弃') self.on_status('未连接串口, 命令被丢弃', time.time())
return False return False
self.q.put((label, bytes(frame))) self.q.put((label, bytes(frame)))
return True return True
@@ -148,7 +151,7 @@ class SerialLink:
pass pass
self.ser = None self.ser = None
self._open_key = None self._open_key = None
self.on_status('已断开') self.on_status('已断开', time.time())
if target: if target:
port, baud, dbsize, parity, sbits = target port, baud, dbsize, parity, sbits = target
try: try:
@@ -159,12 +162,19 @@ class SerialLink:
parity=parities.get(parity, serial.PARITY_NONE), parity=parities.get(parity, serial.PARITY_NONE),
stopbits=stopbits.get(sbits, serial.STOPBITS_ONE), stopbits=stopbits.get(sbits, serial.STOPBITS_ONE),
timeout=0.15, timeout=0.15,
write_timeout=1.0) # 写超时: 转换器停滞时 write 会 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: with self._lock:
self._open_key = target self._open_key = target
self.parser = None # 换口重建解析器(下次接收时惰性建) self.parser = None # 换口重建解析器(下次接收时惰性建)
self.on_status(f'已连接 {port} @ {baud} {dbsize}{parity}{sbits}') self.on_status(f'已连接 {port} @ {baud} {dbsize}{parity}{sbits}',
time.time())
except Exception as e: except Exception as e:
self.ser = None self.ser = None
with self._lock: with self._lock:
@@ -173,7 +183,7 @@ class SerialLink:
if 'PermissionError' in repr(e) or '拒绝访问' in et \ if 'PermissionError' in repr(e) or '拒绝访问' in et \
or 'Access is denied' in et: or 'Access is denied' in et:
et += '(串口被占用: 检查串口助手/另一个本工具实例)' et += '(串口被占用: 检查串口助手/另一个本工具实例)'
self.on_status(f'连接失败: {et}') self.on_status(f'连接失败: {et}', time.time())
if self.ser is not None: if self.ser is not None:
try: try:
item = None item = None
@@ -185,7 +195,7 @@ class SerialLink:
label, frame = item label, frame = item
self.ser.write(frame) self.ser.write(frame)
if self.on_tx is not None: if self.on_tx is not None:
self.on_tx(label, frame) self.on_tx(label, frame, time.time())
data = self.ser.read(4096) data = self.ser.read(4096)
if data: if data:
self._dispatch_rx(data) self._dispatch_rx(data)
@@ -198,10 +208,11 @@ class SerialLink:
self._open_key = None # 必须同步清除, 否则同参数重连被当作已打开而跳过 self._open_key = None # 必须同步清除, 否则同参数重连被当作已打开而跳过
with self._lock: with self._lock:
self._target = None self._target = None
self.on_status('串口丢失(设备拔出?), 请重新连接') self.on_status('串口丢失(设备拔出?), 请重新连接', time.time())
time.sleep(0.02) time.sleep(0.02)
def _dispatch_rx(self, data): def _dispatch_rx(self, data):
ts = time.time() # 字节到达时刻: 后台线程打, 随帧携带
with self._lock: with self._lock:
parse = self._parse parse = self._parse
if parse and self.parser is None: # 惰性建, 带当前 ProtoConfig if parse and self.parser is None: # 惰性建, 带当前 ProtoConfig
@@ -210,6 +221,6 @@ class SerialLink:
if parse: if parse:
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) self.on_frame(fr, ts)
elif self.on_raw is not None: elif self.on_raw is not None:
self.on_raw(data) self.on_raw(data, ts)
+110 -39
View File
@@ -49,7 +49,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.0' APP_VERSION = '0.1.1'
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)
@@ -69,10 +69,23 @@ FMT_LABELS = (('u8', 'u8'), ('s8', 's8'),
('u32be', 'u32 大端'), ('u32le', 'u32 小端'), ('u32be', 'u32 大端'), ('u32le', 'u32 小端'),
('s32be', 's32 大端'), ('s32le', 's32 小端'), ('s32be', 's32 大端'), ('s32le', 's32 小端'),
('f32be', 'f32 大端'), ('f32le', 'f32 小端')) ('f32be', 'f32 大端'), ('f32le', 'f32 小端'))
# 接收日志治理: 行数上限(超量丢最旧); 事件队列积压超阈值时, 本轮帧日志折叠为
# 计数摘要(曲线照常), 防长跑时排版成本拖垮 30ms 轮询导致显示延迟越积越大
RX_MAX_BLOCKS = 5000
FOLD_QSIZE = 200
def _timestamp(): def _esc_log(text):
t = time.time() """日志行入 HTML 缓冲前的转义: html.escape + 空格改 &nbsp;——
富文本会折叠连续空格, 日志对齐(如 TX 行 '命令 [hex]')依赖原空格"""
return html.escape(text).replace(' ', '&nbsp;')
def _timestamp(t=None):
"""epoch 秒 -> 'HH:MM:SS.mmm' 纯格式化; t=None 取当前时刻。
日志时间必须传入 link 后台线程打的到达时刻, 不能消费时现取(会随 UI 卡顿挤压失真)"""
if t is None:
t = time.time()
return time.strftime('%H:%M:%S', time.localtime(t)) + f'.{int(t * 1000) % 1000:03d}' return time.strftime('%H:%M:%S', time.localtime(t)) + f'.{int(t * 1000) % 1000:03d}'
@@ -232,14 +245,17 @@ class Window(QMainWindow):
self._plot_win = None # 曲线独立窗口(弹出后非 None) self._plot_win = None # 曲线独立窗口(弹出后非 None)
self.tx_history, self._hist_pos = [], -1 self.tx_history, self._hist_pos = [], -1
self._tx_n = self._rx_n = self._tx_b = self._rx_b = 0 self._tx_n = self._rx_n = self._tx_b = self._rx_b = 0
self._rx_buf = [] # 待写入接收日志的 HTML 行(批量 flush)
self._ch_cfg = [] # 通道配置缓存(_build_ui 后重建)
self.link = SerialLink(cfg=self._proto_from_store(), self.link = SerialLink(cfg=self._proto_from_store(),
on_status=lambda s: self.events.put(('status', s))) on_status=lambda s, ts: self.events.put(('status', s, ts)))
self.link.on_frame = lambda fr: self.events.put(('frame', fr)) self.link.on_frame = lambda fr, ts: self.events.put(('frame', fr, ts))
self.link.on_raw = lambda b: self.events.put(('raw', b)) self.link.on_raw = lambda b, ts: self.events.put(('raw', b, ts))
self.link.on_tx = lambda lb, d: self.events.put(('tx', lb, d)) self.link.on_tx = lambda lb, d, ts: self.events.put(('tx', lb, d, ts))
self._build_ui() self._build_ui()
self.plot.on_yrange_changed = self._on_plot_yrange self.plot.on_yrange_changed = self._on_plot_yrange
self.plot.on_span_changed = self._sync_time_slider self.plot.on_span_changed = self._sync_time_slider
self._ch_cfg = self._collect_channels() # 通道配置缓存(_on_channel 时重建)
self._apply_store_to_ui() self._apply_store_to_ui()
self.theme_manager.set_window(self) self.theme_manager.set_window(self)
self.ed_tx.installEventFilter(self) self.ed_tx.installEventFilter(self)
@@ -634,7 +650,8 @@ class Window(QMainWindow):
return out return out
def _on_channel(self, *_): def _on_channel(self, *_):
for i, c in enumerate(self._collect_channels()): self._ch_cfg = self._collect_channels() # 用户改配置才重建, 帧路径只读缓存
for i, c in enumerate(self._ch_cfg):
self.plot.set_channel_name(i, c['name'] if c['enabled'] else '') self.plot.set_channel_name(i, c['name'] if c['enabled'] else '')
self._update_preview() self._update_preview()
@@ -823,7 +840,7 @@ class Window(QMainWindow):
self.text_rx.setLineWrapMode(QPlainTextEdit.NoWrap) self.text_rx.setLineWrapMode(QPlainTextEdit.NoWrap)
self.text_rx.setFont(QFontDatabase.systemFont(QFontDatabase.FixedFont)) self.text_rx.setFont(QFontDatabase.systemFont(QFontDatabase.FixedFont))
self.text_rx.setMinimumHeight(120) self.text_rx.setMinimumHeight(120)
self.text_rx.document().setMaximumBlockCount(5000) self.text_rx.document().setMaximumBlockCount(RX_MAX_BLOCKS)
lay.addWidget(self.text_rx, 1) lay.addWidget(self.text_rx, 1)
return card return card
@@ -1245,61 +1262,114 @@ class Window(QMainWindow):
# ------------------------------------------------------- 事件与回显 # ------------------------------------------------------- 事件与回显
def _drain_events(self): def _drain_events(self):
for _ in range(400): # 单次上限, 防极端速率卡 UI """30ms 轮询消费 link 事件。事件尾部携带 link 后台线程打的发生时刻 ts
(字节到达/写入串口), 日志时间因此是到达时间而非显示时间。
接收日志行先进 _rx_buf 攒批, 本轮结束 _flush_rx 一次写入——把 N 次
UI 重排压成 1 次; 队列积压超 FOLD_QSIZE 时帧日志折叠为计数摘要
(显示本批首尾到达时刻, 曲线照常入库), 防消费不逮时越积越卡。"""
n = 0
folded = None # None=未折叠; {'n':帧数,'t0/t1':首尾到达,'cnt':{描述:计数}}
for _ in range(400): # 单次上限, 防极端速率卡 UI
try: try:
ev = self.events.get_nowait() ev = self.events.get_nowait()
except queue.Empty: except queue.Empty:
return break
kind = ev[0] kind = ev[0]
ts = ev[-1]
if kind == 'status': if kind == 'status':
self._on_status(ev[1]) self._on_status(ev[1], ts)
elif kind == 'frame': elif kind == 'frame':
self._on_frame(ev[1]) if folded is None and self.events.qsize() > FOLD_QSIZE:
folded = {'n': 0, 't0': ts, 't1': ts, 'cnt': {}}
if folded is not None:
self._absorb_frame(ev[1], ts, folded)
else:
self._on_frame(ev[1], ts)
elif kind == 'raw': elif kind == 'raw':
self._on_raw(ev[1]) self._on_raw(ev[1], ts)
elif kind == 'tx': elif kind == 'tx':
self._on_tx(ev[1], ev[2]) self._on_tx(ev[1], ev[2], ts)
n += 1
if folded:
detail = ' '.join(f'{desc} ×{c}' for desc, c in folded['cnt'].items())
self._append_rx(f'← {_timestamp(folded["t0"])}~{_timestamp(folded["t1"])}'
f' ⏳ 显示滞后, 折叠 {folded["n"]} 帧: {detail}')
if n:
self._flush_rx()
self._update_counters()
def _hint(self, text): def _hint(self, text, ts=None):
self.lb_state.setText(text) self.lb_state.setText(text)
self.statusBar().showMessage(text, 8000) self.statusBar().showMessage(text, 8000)
self._append_rx(f'· {_timestamp()} {text}') self._append_rx(f'· {_timestamp(ts)} {text}')
def _on_status(self, text): def _on_status(self, text, ts=None):
"""连接状态: 同步连接按钮文案 + 提示""" """连接状态: 同步连接按钮文案 + 提示"""
if text.startswith('已连接'): if text.startswith('已连接'):
self.btn_conn.setText('断开') self.btn_conn.setText('断开')
elif text.startswith(('已断开', '连接失败', '串口丢失')): elif text.startswith(('已断开', '连接失败', '串口丢失')):
self.btn_conn.setText('连接') self.btn_conn.setText('连接')
self._hint(text) self._hint(text, ts)
def _append_rx(self, line): def _append_rx(self, line):
self.text_rx.appendPlainText(line) """接收日志行: 只入批量缓冲, 由 _flush_rx 统一写入"""
self._rx_buf.append(_esc_log(line))
def _append_rx_frame(self, prefix, core, suffix=''): def _append_rx_frame(self, prefix, core, suffix=''):
"""帧日志行: payload 核心内容垫一层浅背景色, 目光一眼锁定。 """帧日志行: payload 核心内容垫一层浅背景色, 目光一眼锁定。
用半透明 rgba —— 主题无关, 明暗切换后历史行不会残留异色""" 用半透明 rgba —— 主题无关, 明暗切换后历史行不会残留异色"""
self.text_rx.appendHtml( self._rx_buf.append(
f'{html.escape(prefix)}' f'{_esc_log(prefix)}'
f'<span style="background-color:rgba(96,125,180,0.16);">' f'<span style="background-color:rgba(96,125,180,0.16);">'
f'{html.escape(core)}</span>' f'{_esc_log(core)}</span>'
f'{html.escape(suffix)}') f'{_esc_log(suffix)}')
def _on_frame(self, fr): def _flush_rx(self):
"""批量写入接收日志: 每行一个 <p>(一个 block), maximumBlockCount
裁旧行照常生效; 一次 appendHtml 替代逐行 N 次, 长跑不再因排版
成本拖垮事件轮询"""
if not self._rx_buf:
return
self.text_rx.appendHtml(
''.join(f'<p style="margin:0">{line}</p>' for line in self._rx_buf))
self._rx_buf.clear()
def _on_frame(self, fr, ts=None):
self._rx_n += 1 self._rx_n += 1
self._rx_b += len(fr.raw) self._rx_b += len(fr.raw)
ts = f'{_timestamp()} ' if self.chk_ts.isChecked() else '' t = f'{_timestamp(ts)} ' if self.chk_ts.isChecked() else ''
disp = RX_DISPLAYS[self.combo_display.currentIndex()] disp = RX_DISPLAYS[self.combo_display.currentIndex()]
payload = hexs(fr.payload) if disp == 'hex' else \ payload = hexs(fr.payload) if disp == 'hex' else \
(clean_text(fr.payload.decode('ascii', 'replace')) or '(空)') (clean_text(fr.payload.decode('ascii', 'replace')) or '(空)')
if fr.code is None: # 布局无 ID 字段 if fr.code is None: # 布局无 ID 字段
head = f'← {ts}' head = f'← {t}'
else: else:
name = TYPE_NAMES.get(fr.code) name = TYPE_NAMES.get(fr.code)
head = f'← {ts}B=0x{fr.code:02X}' + (f' {name}' if name else '') head = f'← {t}B=0x{fr.code:02X}' + (f' {name}' if name else '')
suffix = f' [{hexs(fr.raw)}]' if self.chk_raw.isChecked() else '' suffix = f' [{hexs(fr.raw)}]' if self.chk_raw.isChecked() else ''
self._append_rx_frame(f'{head} LEN={len(fr.payload)} ', payload, suffix) self._append_rx_frame(f'{head} LEN={len(fr.payload)} ', payload, suffix)
for i, c in enumerate(self._collect_channels()): self._extract_channels(fr)
def _absorb_frame(self, fr, ts, folded):
"""积压期帧处理: 只计数 + 曲线照常入库, 日志行省略(_drain_events 汇总)"""
self._rx_n += 1
self._rx_b += len(fr.raw)
if not folded['n']:
folded['t0'] = ts
folded['n'] += 1
folded['t1'] = ts
if fr.code is None: # 布局无 ID 字段
desc = '帧'
else:
name = TYPE_NAMES.get(fr.code)
desc = f'B=0x{fr.code:02X}' + (f' {name}' if name else '')
folded['cnt'][desc] = folded['cnt'].get(desc, 0) + 1
self._extract_channels(fr)
def _extract_channels(self, fr):
"""按通道配置缓存(self._ch_cfg, _on_channel 时重建)提取数值:
曲线入库 + 最新值标签。帧路径不得遍历 UI 控件——高频时放大 UI 占用"""
for i, c in enumerate(self._ch_cfg):
if not c['enabled']: if not c['enabled']:
continue continue
v = extract_value(fr.payload, c['mode'], offset=c['offset'], v = extract_value(fr.payload, c['mode'], offset=c['offset'],
@@ -1308,29 +1378,30 @@ class Window(QMainWindow):
if v is not None: if v is not None:
self._ch_ui[i]['val'].setText(fmt_val(v)) self._ch_ui[i]['val'].setText(fmt_val(v))
self.plot.add(i, v) self.plot.add(i, v)
self._update_counters()
def _on_raw(self, data): def _on_raw(self, data, ts=None):
self._rx_n += 1 self._rx_n += 1
self._rx_b += len(data) self._rx_b += len(data)
ts = f'{_timestamp()} ' if self.chk_ts.isChecked() else '' t = f'{_timestamp(ts)} ' if self.chk_ts.isChecked() else ''
disp = RX_DISPLAYS[self.combo_display.currentIndex()] disp = RX_DISPLAYS[self.combo_display.currentIndex()]
body = hexs(data) if disp == 'hex' else clean_text(data.decode('ascii', 'replace')) body = hexs(data) if disp == 'hex' else clean_text(data.decode('ascii', 'replace'))
self._append_rx(f'← {ts}{body}') self._append_rx(f'← {t}{body}')
self._update_counters()
def _on_tx(self, label, data): def _on_tx(self, label, data, ts=None):
self._tx_n += 1 self._tx_n += 1
self._tx_b += len(data) self._tx_b += len(data)
if label is None: if label is None:
self._append_rx(f'→ {_timestamp()} [HEX] {hexs(data)}') self._append_rx(f'→ {_timestamp(ts)} [HEX] {hexs(data)}')
else: else:
self._append_rx(f'→ {_timestamp()} {label} [{hexs(data)}]') self._append_rx(f'→ {_timestamp(ts)} {label} [{hexs(data)}]')
self._update_counters()
def _update_counters(self): def _update_counters(self):
"""状态栏: 收发计数 + 事件队列积压量。正常恒为 0; 数字增长=事件
产出快于消费(GUI 卡或数据量异常), 便于用户把"不显示"现场分层"""
self.statusBar().showMessage( self.statusBar().showMessage(
f'发送 {self._tx_n} 帧 / {self._tx_b} 字节 接收 {self._rx_n} 帧 / {self._rx_b} 字节') f'发送 {self._tx_n} 帧 / {self._tx_b} 字节 '
f'接收 {self._rx_n} 帧 / {self._rx_b} 字节 '
f'积压 {self.events.qsize()}')
def _on_theme(self, idx): def _on_theme(self, idx):
self.theme_manager.set_mode(('system', 'light', 'dark')[idx]) self.theme_manager.set_mode(('system', 'light', 'dark')[idx])
+41 -1
View File
@@ -6,6 +6,7 @@
用法: python selftest_gui.py (全部通过退出码 0) 用法: python selftest_gui.py (全部通过退出码 0)
""" """
import os import os
import re
import sys import sys
os.environ['QT_QPA_PLATFORM'] = 'offscreen' os.environ['QT_QPA_PLATFORM'] = 'offscreen'
@@ -94,6 +95,13 @@ p.add(0, 43.0)
check('暂停期间丢弃', p.is_paused() and len(p._times[0]) == n1, True) check('暂停期间丢弃', p.is_paused() and len(p._times[0]) == n1, True)
p.set_paused(False) p.set_paused(False)
# 0e) 时间戳: 纯格式化函数, 传入到达时刻得确定字符串, 不传兜底当前时刻
t_fix = 1759474800 + 0.456
check('时间戳格式化(到达时刻)', m._timestamp(t_fix),
_time.strftime('%H:%M:%S', _time.localtime(t_fix)) + '.456')
check('时间戳格式化(现在兜底)',
re.match(r'\d{2}:\d{2}:\d{2}\.\d{3}$', m._timestamp()) is not None, True)
# 1) 组帧预览: 多条 '/' 分隔 # 1) 组帧预览: 多条 '/' 分隔
win.ed_tx.setText('M1/M731S0T1000') win.ed_tx.setText('M1/M731S0T1000')
check('多条预览', win.lb_preview.text(), check('多条预览', win.lb_preview.text(),
@@ -116,9 +124,10 @@ check('帧构成非法详情', 'PAYLOAD 必须恰好出现一次' in win.lb_prot
win.ed_layout.setText(d.DEFAULT_LAYOUT) win.ed_layout.setText(d.DEFAULT_LAYOUT)
check('帧构成还原预览', win.lb_preview.text(), 'D5 01 02 4D 31 81') check('帧构成还原预览', win.lb_preview.text(), 'D5 01 02 4D 31 81')
# 2) TX 回显行 # 2) TX 回显行(直接驱动处理函数, 断言前 flush 批量日志缓冲)
cfg = win._proto_from_ui() cfg = win._proto_from_ui()
win._on_tx('M1', cfg.build_frame(b'M1')) win._on_tx('M1', cfg.build_frame(b'M1'))
win._flush_rx()
check('TX日志行', 'M1 [D5 01 02 4D 31 81]' in win.text_rx.toPlainText(), True) check('TX日志行', 'M1 [D5 01 02 4D 31 81]' in win.text_rx.toPlainText(), True)
# 3) 帧事件: 日志 + 通道提取(文本数字×0.1) # 3) 帧事件: 日志 + 通道提取(文本数字×0.1)
@@ -130,14 +139,22 @@ win._ch_ui[0]['mode'].setCurrentIndex(1) # 文本数字
win._ch_ui[0]['idx'].setValue(1) win._ch_ui[0]['idx'].setValue(1)
win._ch_ui[0]['scale'].setValue(0.1) win._ch_ui[0]['scale'].setValue(0.1)
win._on_frame(fr) win._on_frame(fr)
win._flush_rx()
last = win.text_rx.toPlainText().splitlines()[-1] last = win.text_rx.toPlainText().splitlines()[-1]
check('帧日志含 TYPE 名', 'B=0xF0 成功' in last and 'LEN=8' in last check('帧日志含 TYPE 名', 'B=0xF0 成功' in last and 'LEN=8' in last
and 'okTL:246' in last, True) and 'okTL:246' in last, True)
check('通道最新值', win._ch_ui[0]['val'].text(), '24.6') check('通道最新值', win._ch_ui[0]['val'].text(), '24.6')
# 时间戳用 link 后台线程打的到达时刻, 而非 GUI 消费时刻
ft = _time.time() - 3.456
win._on_frame(fr, ft)
win._flush_rx()
check('帧日志用到达时刻', m._timestamp(ft)
in win.text_rx.toPlainText().splitlines()[-1], True)
# 4) HEX 显示切换 + 原始帧 # 4) HEX 显示切换 + 原始帧
win.combo_display.setCurrentIndex(1) win.combo_display.setCurrentIndex(1)
win._on_frame(fr) win._on_frame(fr)
win._flush_rx()
last = win.text_rx.toPlainText().splitlines()[-1] last = win.text_rx.toPlainText().splitlines()[-1]
check('HEX显示+原始帧', '6F 6B 54 4C 3A 32 34 36' in last check('HEX显示+原始帧', '6F 6B 54 4C 3A 32 34 36' in last
and '[B5 F0 08 6F 6B 54 4C 3A 32 34 36 48]' in last, True) and '[B5 F0 08 6F 6B 54 4C 3A 32 34 36 48]' in last, True)
@@ -145,6 +162,7 @@ check('HEX显示+原始帧', '6F 6B 54 4C 3A 32 34 36' in last
# 5) 透传行 # 5) 透传行
win.combo_display.setCurrentIndex(1) win.combo_display.setCurrentIndex(1)
win._on_raw(b'\x11\x22') win._on_raw(b'\x11\x22')
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)
# 6) 未连接发送丢弃 # 6) 未连接发送丢弃
@@ -152,6 +170,28 @@ win._send()
win._drain_events() win._drain_events()
check('未连接发送提示', '未连接串口, 命令被丢弃' in win.text_rx.toPlainText(), True) check('未连接发送提示', '未连接串口, 命令被丢弃' in win.text_rx.toPlainText(), True)
# 6b) 通道配置缓存: 帧路径读 _ch_cfg, 用户改配置时重建
win._ch_ui[1]['chk'].setChecked(True)
win._ch_ui[1]['name'].setText('乙')
check('通道缓存随配置重建', win._ch_cfg[1]['enabled']
and win._ch_cfg[1]['name'] == '乙', True)
win._ch_ui[1]['chk'].setChecked(False)
check('通道缓存关闭同步', not win._ch_cfg[1]['enabled'], True)
# 6c) 积压折叠: 队列积压超阈值时帧日志折叠为计数摘要(带首尾到达时刻), 曲线照常入库
rx_n0, plot_n0 = win._rx_n, len(p._vals[0])
now = _time.time()
for k in range(250): # 100ms 间隔的到达时刻, 模拟高频上报
win.events.put(('frame', fr, now - (250 - k) / 10.0))
win._drain_events()
log = win.text_rx.toPlainText()
check('积压折叠摘要行', '折叠 250 帧' in log, True)
check('折叠显示到达跨度', m._timestamp(now - 25.0) in log
and m._timestamp(now - 0.1) in log, True)
check('折叠保曲线', len(p._vals[0]) == plot_n0 + 250, True)
check('折叠计数器照常', win._rx_n == rx_n0 + 250, True)
check('折叠摘要含帧描述', 'B=0xF0 成功 ×250' in log, True)
# 7) 配置往返 # 7) 配置往返
store = win._store_from_ui() store = win._store_from_ui()
check('配置 proto', (store['proto']['tx_hdr'], store['proto']['crc_algo']), check('配置 proto', (store['proto']['tx_hdr'], store['proto']['crc_algo']),
+13 -8
View File
@@ -40,15 +40,16 @@ def wait_for(pred, timeout=5.0):
got = {'status': [], 'frames': [], 'tx': [], 'raw': []} got = {'status': [], 'frames': [], 'tx': [], 'raw': []}
cfg = d.ProtoConfig() cfg = d.ProtoConfig()
link = SerialLink(cfg, on_status=lambda s: got['status'].append(s)) link = SerialLink(cfg, on_status=lambda s, ts: got['status'].append((s, ts)))
link.on_frame = lambda fr: got['frames'].append(fr) link.on_frame = lambda fr, ts: got['frames'].append((fr, ts))
link.on_raw = lambda b: got['raw'].append(b) link.on_raw = lambda b, ts: got['raw'].append((b, ts))
link.on_tx = lambda lb, data: got['tx'].append((lb, data)) link.on_tx = lambda lb, data, ts: got['tx'].append((lb, data, ts))
# 1) 打开虚拟回环口 # 1) 打开虚拟回环口
link.open('loop://', 115200) link.open('loop://', 115200)
check('连接 loop://', wait_for(lambda: got['status'] and check('连接 loop://', wait_for(lambda: got['status'] and
got['status'][-1].startswith('已连接')), True) got['status'][-1][0].startswith('已连接')), True)
check('状态事件携带时刻', isinstance(got['status'][-1][1], float), True)
# 2) 自动包装发送: TX 回显 + 回环字节 # 2) 自动包装发送: TX 回显 + 回环字节
payload = 'M731S0T1000' payload = 'M731S0T1000'
@@ -58,6 +59,7 @@ check('发送回显', wait_for(lambda: len(got['tx']) >= 1), True)
if got['tx']: if got['tx']:
check('TX label', got['tx'][0][0], payload) check('TX label', got['tx'][0][0], payload)
check('TX frame', got['tx'][0][1], frame) check('TX frame', got['tx'][0][1], frame)
check('TX 携带写入时刻', isinstance(got['tx'][0][2], float), True)
# 3) 模拟设备应答: okTL:246(温度 24.6°C) → 解析 + 曲线提取 # 3) 模拟设备应答: okTL:246(温度 24.6°C) → 解析 + 曲线提取
resp = bytes.fromhex('B5 F0 08 6F 6B 54 4C 3A 32 34 36 48') # sum(F0..36)=0x48 resp = bytes.fromhex('B5 F0 08 6F 6B 54 4C 3A 32 34 36 48') # sum(F0..36)=0x48
@@ -65,7 +67,9 @@ check('响应帧向量自校验', cfg.verify_frame(resp), True)
link.ser.write(resp) link.ser.write(resp)
check('应答解析', wait_for(lambda: len(got['frames']) >= 1), True) check('应答解析', wait_for(lambda: len(got['frames']) >= 1), True)
if got['frames']: if got['frames']:
fr = got['frames'][0] fr, fts = got['frames'][0]
check('帧携带到达时间戳', isinstance(fts, float)
and 0 < time.time() - fts < 60, True)
check('TYPE 码', fr.code, 0xF0) check('TYPE 码', fr.code, 0xF0)
check('payload', fr.payload, b'okTL:246') check('payload', fr.payload, b'okTL:246')
v = d.extract_value(fr.payload, 'num', index=1, scale=0.1) v = d.extract_value(fr.payload, 'num', index=1, scale=0.1)
@@ -77,7 +81,8 @@ raw = bytes([0x11, 0x22, 0xB5, 0x00])
link.ser.write(raw) link.ser.write(raw)
check('透传原始字节', wait_for(lambda: len(got['raw']) >= 1), True) check('透传原始字节', wait_for(lambda: len(got['raw']) >= 1), True)
if got['raw']: if got['raw']:
check('透传内容', got['raw'][0], raw) check('透传内容', got['raw'][0][0], raw)
check('透传携带到达时间戳', isinstance(got['raw'][0][1], float), True)
# 5) 切回解析模式, 缓冲已清空, 正常再收一帧 # 5) 切回解析模式, 缓冲已清空, 正常再收一帧
link.set_parse(True) link.set_parse(True)
@@ -85,7 +90,7 @@ link.ser.write(bytes.fromhex('B5 F0 02 6F 6B CC'))
check('切回解析再收帧', wait_for(lambda: len(got['frames']) >= 2), True) check('切回解析再收帧', wait_for(lambda: len(got['frames']) >= 2), True)
link.close() link.close()
check('断开', wait_for(lambda: got['status'] and got['status'][-1] == '已断开'), True) check('断开', wait_for(lambda: got['status'] and got['status'][-1][0] == '已断开'), True)
print('RESULT:', 'FAIL:' + ','.join(fails) if fails else 'ALL PASS') print('RESULT:', 'FAIL:' + ','.join(fails) if fails else 'ALL PASS')
sys.exit(1 if fails else 0) sys.exit(1 if fails else 0)