diff --git a/SerialMate/AGENTS.md b/SerialMate/AGENTS.md index 1f7dbd1..cc459bc 100644 --- a/SerialMate/AGENTS.md +++ b/SerialMate/AGENTS.md @@ -54,7 +54,7 @@ SerialMate/ 保证内容贴顶(卡片被拖高后富余高度收在卡底,勿删——否则拉伸被各行均分, 标题上方出现空隙);发送卡行序:板型管理行(下拉+新建+删除+配置+重载) / 预设行(下拉+填入+直接发送) / 模式行(下拉+存为板型+存入预设) / 输入行 / - 周期行;帧日志 payload 高亮走 `_append_rx_frame`(html.escape 后拼 span + 周期行;帧日志 payload 高亮走 `_append_rx_frame`(`_esc_log` 转义后拼 span 背景,toPlainText 内容不变,selftest 的日志断言因此不受影响);预设下拉 显示"名称 · 命令",存入命令走 `_add_command_named`(弹窗核 `_add_command` 包装,测试用核函数),预设直发走 `_send_preset_direct`(经输入框复用 @@ -65,6 +65,21 @@ SerialMate/ 2. **回调线程边界**:SerialLink 的 on_status/on_frame/on_raw/on_tx 全在后台线程 触发,一律 `events.put(...)` 投递,主线程 30ms QTimer 轮询 `_drain_events` 分发(与 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——每行必须包 + `

`(**`
` 连接只算 1 个 block,会让 + maximumBlockCount 裁旧行失效**,offscreen 实验验证过);转义用 `_esc_log` + (空格转 ` `,防富文本折叠连续空格破坏 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 间隔), RS485 总线多帧连发留应答间隙,勿改成批量 drain 一次写。 4. **帧构成语义**:帧由 `FrameLayout` 令牌序列定义(`HDR ID LEN PAYLOAD CRC` @@ -181,6 +196,57 @@ tag/release 命名 `SerialMate-vX.Y.Z`,zip 命名 ## 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(每行一个 `

`, + maximumBlockCount 裁旧行照常生效;`_esc_log` 空格转 ` ` 防富文本 + 折叠连续空格);`_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,首版) 已完成: diff --git a/SerialMate/ISSUE_rx_timestamp_and_lag.md b/SerialMate/ISSUE_rx_timestamp_and_lag.md new file mode 100644 index 0000000..a9bee06 --- /dev/null +++ b/SerialMate/ISSUE_rx_timestamp_and_lag.md @@ -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 全部落地, +> 实现与本文建议的差别:日志批量插入每行包 `

`(`
` +> 连接只算 1 个 block,会让 maximumBlockCount 失效);转义空格转 ` ` +> 防富文本折叠连续空格;通道缓存放 `_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 卡顿成批挤压、显示延迟随运行时间增大; +修复后时间戳单调且间隔≈设定周期,长时间运行无卡顿增长。 diff --git a/SerialMate/README.md b/SerialMate/README.md index 42802de..6e2f336 100644 --- a/SerialMate/README.md +++ b/SerialMate/README.md @@ -36,7 +36,10 @@ | CRC 算法 | SUM8 累加和 / CRC8 / CRC16 MODBUS / CRC16 XMODEM / CRC16 CCITT-FALSE | SUM8 | | CRC 覆盖 | 含包头(整帧) / 不含包头(跳过第1字节) / 仅 payload | 不含包头 | - **接收显示**:payload 一键切换 **字符串 / HEX**;可选附原始帧 hex; - 时间戳;日志保存为 txt。 + 时间戳为**字节到达时刻**(串口后台线程打点随帧携带,UI 繁忙时日志时间 + 依然可信);日志保存为 txt。接收日志自动治理:行数上限 5000(超量淘汰 + 最旧)、批量刷新、事件积压时高频帧日志自动折叠为计数摘要(曲线照常 + 采集)——长时间高频运行不累积卡顿。 - **曲线**:4 通道,每帧有效数据按通道提取一个数值,支持三种提取方式 (二进制定长 u8~f32 大小端 / 文本第 N 个数字 / 自定义正则)+ 倍率, y 轴自动或手动量程,暂停/清空;绘图区可**弹成独立窗口**("弹出"按钮, diff --git a/SerialMate/release/README.md b/SerialMate/release/README.md index 42802de..6e2f336 100644 --- a/SerialMate/release/README.md +++ b/SerialMate/release/README.md @@ -36,7 +36,10 @@ | CRC 算法 | SUM8 累加和 / CRC8 / CRC16 MODBUS / CRC16 XMODEM / CRC16 CCITT-FALSE | SUM8 | | CRC 覆盖 | 含包头(整帧) / 不含包头(跳过第1字节) / 仅 payload | 不含包头 | - **接收显示**:payload 一键切换 **字符串 / HEX**;可选附原始帧 hex; - 时间戳;日志保存为 txt。 + 时间戳为**字节到达时刻**(串口后台线程打点随帧携带,UI 繁忙时日志时间 + 依然可信);日志保存为 txt。接收日志自动治理:行数上限 5000(超量淘汰 + 最旧)、批量刷新、事件积压时高频帧日志自动折叠为计数摘要(曲线照常 + 采集)——长时间高频运行不累积卡顿。 - **曲线**:4 通道,每帧有效数据按通道提取一个数值,支持三种提取方式 (二进制定长 u8~f32 大小端 / 文本第 N 个数字 / 自定义正则)+ 倍率, y 轴自动或手动量程,暂停/清空;绘图区可**弹成独立窗口**("弹出"按钮, diff --git a/SerialMate/src/_sim_periodic.py b/SerialMate/src/_sim_periodic.py new file mode 100644 index 0000000..f493b0c --- /dev/null +++ b/SerialMate/src/_sim_periodic.py @@ -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) diff --git a/SerialMate/src/link.py b/SerialMate/src/link.py index aeb83e3..1f5e864 100644 --- a/SerialMate/src/link.py +++ b/SerialMate/src/link.py @@ -30,11 +30,14 @@ DEFAULTS = dict(bytesize=8, parity='N', stopbits=1) class SerialLink: - """串口工作线程。回调(后台线程触发): - on_status(str) 连接状态变化(已连接/断开/失败/设备拔出) - on_frame(fr) 解析模式: 完整 Frame(protocol.Frame) - on_raw(data) 透传模式: 原始接收 bytes - on_tx(label, data) 发送回显: label=payload 字符串(自动包装)/None(HEX直发) + """串口工作线程。回调(后台线程触发, 第二参数 ts=事件发生的 epoch 秒): + on_status(text, ts) 连接状态变化(已连接/断开/失败/设备拔出) + on_frame(fr, ts) 解析模式: 完整 Frame(protocol.Frame); ts=本批字节到达时刻 + on_raw(data, ts) 透传模式: 原始接收 bytes; ts=到达时刻 + on_tx(label, data, ts) 发送回显: label=payload 字符串(自动包装)/None(HEX直发); + ts=写入串口时刻 + 时间戳必须由本模块在读线程打(字节到达时刻), GUI 消费侧再打会随 UI 卡顿 + 成批挤压失真——日志时间就不再是到达时间。 """ def __init__(self, cfg, on_status): @@ -100,7 +103,7 @@ class SerialLink: def open(self, port, baud=DEFAULT_BAUD, **kw): 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 params = {**DEFAULTS, **kw} with self._lock: @@ -123,7 +126,7 @@ class SerialLink: with self._lock: ser = self.ser if ser is None: - self.on_status('未连接串口, 命令被丢弃') + self.on_status('未连接串口, 命令被丢弃', time.time()) return False self.q.put((label, bytes(frame))) return True @@ -148,7 +151,7 @@ class SerialLink: pass self.ser = None self._open_key = None - self.on_status('已断开') + self.on_status('已断开', time.time()) if target: port, baud, dbsize, parity, sbits = target try: @@ -159,12 +162,19 @@ class SerialLink: parity=parities.get(parity, serial.PARITY_NONE), stopbits=stopbits.get(sbits, serial.STOPBITS_ONE), 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: self._open_key = target 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: self.ser = None with self._lock: @@ -173,7 +183,7 @@ class SerialLink: if 'PermissionError' in repr(e) or '拒绝访问' in et \ or 'Access is denied' in et: et += '(串口被占用: 检查串口助手/另一个本工具实例)' - self.on_status(f'连接失败: {et}') + self.on_status(f'连接失败: {et}', time.time()) if self.ser is not None: try: item = None @@ -185,7 +195,7 @@ class SerialLink: label, frame = item self.ser.write(frame) if self.on_tx is not None: - self.on_tx(label, frame) + self.on_tx(label, frame, time.time()) data = self.ser.read(4096) if data: self._dispatch_rx(data) @@ -198,10 +208,11 @@ class SerialLink: self._open_key = None # 必须同步清除, 否则同参数重连被当作已打开而跳过 with self._lock: self._target = None - self.on_status('串口丢失(设备拔出?), 请重新连接') + self.on_status('串口丢失(设备拔出?), 请重新连接', time.time()) time.sleep(0.02) def _dispatch_rx(self, data): + ts = time.time() # 字节到达时刻: 后台线程打, 随帧携带 with self._lock: parse = self._parse if parse and self.parser is None: # 惰性建, 带当前 ProtoConfig @@ -210,6 +221,6 @@ class SerialLink: if parse: for fr in self.parser.feed(data): if self.on_frame is not None: - self.on_frame(fr) + self.on_frame(fr, ts) elif self.on_raw is not None: - self.on_raw(data) + self.on_raw(data, ts) diff --git a/SerialMate/src/main.py b/SerialMate/src/main.py index 12feb9b..75ac61c 100644 --- a/SerialMate/src/main.py +++ b/SerialMate/src/main.py @@ -49,7 +49,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.0' +APP_VERSION = '0.1.1' REPO_URL = 'https://gitea.hulk.wang/hulk/HW-Debug-Tools' 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 小端'), ('s32be', 's32 大端'), ('s32le', 's32 小端'), ('f32be', 'f32 大端'), ('f32le', 'f32 小端')) +# 接收日志治理: 行数上限(超量丢最旧); 事件队列积压超阈值时, 本轮帧日志折叠为 +# 计数摘要(曲线照常), 防长跑时排版成本拖垮 30ms 轮询导致显示延迟越积越大 +RX_MAX_BLOCKS = 5000 +FOLD_QSIZE = 200 -def _timestamp(): - t = time.time() +def _esc_log(text): + """日志行入 HTML 缓冲前的转义: html.escape + 空格改  —— + 富文本会折叠连续空格, 日志对齐(如 TX 行 '命令 [hex]')依赖原空格""" + return html.escape(text).replace(' ', ' ') + + +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}' @@ -232,14 +245,17 @@ class Window(QMainWindow): self._plot_win = None # 曲线独立窗口(弹出后非 None) self.tx_history, self._hist_pos = [], -1 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(), - on_status=lambda s: self.events.put(('status', s))) - self.link.on_frame = lambda fr: self.events.put(('frame', fr)) - self.link.on_raw = lambda b: self.events.put(('raw', b)) - self.link.on_tx = lambda lb, d: self.events.put(('tx', lb, d)) + 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_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() self.plot.on_yrange_changed = self._on_plot_yrange self.plot.on_span_changed = self._sync_time_slider + self._ch_cfg = self._collect_channels() # 通道配置缓存(_on_channel 时重建) self._apply_store_to_ui() self.theme_manager.set_window(self) self.ed_tx.installEventFilter(self) @@ -634,7 +650,8 @@ class Window(QMainWindow): return out 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._update_preview() @@ -823,7 +840,7 @@ class Window(QMainWindow): self.text_rx.setLineWrapMode(QPlainTextEdit.NoWrap) self.text_rx.setFont(QFontDatabase.systemFont(QFontDatabase.FixedFont)) 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) return card @@ -1245,61 +1262,114 @@ class Window(QMainWindow): # ------------------------------------------------------- 事件与回显 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: ev = self.events.get_nowait() except queue.Empty: - return + break kind = ev[0] + ts = ev[-1] if kind == 'status': - self._on_status(ev[1]) + self._on_status(ev[1], ts) 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': - self._on_raw(ev[1]) + self._on_raw(ev[1], ts) 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.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('已连接'): self.btn_conn.setText('断开') elif text.startswith(('已断开', '连接失败', '串口丢失')): self.btn_conn.setText('连接') - self._hint(text) + self._hint(text, ts) 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=''): """帧日志行: payload 核心内容垫一层浅背景色, 目光一眼锁定。 用半透明 rgba —— 主题无关, 明暗切换后历史行不会残留异色""" - self.text_rx.appendHtml( - f'{html.escape(prefix)}' + self._rx_buf.append( + f'{_esc_log(prefix)}' f'' - f'{html.escape(core)}' - f'{html.escape(suffix)}') + f'{_esc_log(core)}' + f'{_esc_log(suffix)}') - def _on_frame(self, fr): + def _flush_rx(self): + """批量写入接收日志: 每行一个

(一个 block), maximumBlockCount + 裁旧行照常生效; 一次 appendHtml 替代逐行 N 次, 长跑不再因排版 + 成本拖垮事件轮询""" + if not self._rx_buf: + return + self.text_rx.appendHtml( + ''.join(f'

{line}

' for line in self._rx_buf)) + self._rx_buf.clear() + + def _on_frame(self, fr, ts=None): self._rx_n += 1 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()] payload = hexs(fr.payload) if disp == 'hex' else \ (clean_text(fr.payload.decode('ascii', 'replace')) or '(空)') if fr.code is None: # 布局无 ID 字段 - head = f'← {ts}' + head = f'← {t}' else: 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 '' 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']: continue v = extract_value(fr.payload, c['mode'], offset=c['offset'], @@ -1308,29 +1378,30 @@ class Window(QMainWindow): if v is not None: self._ch_ui[i]['val'].setText(fmt_val(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_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()] body = hexs(data) if disp == 'hex' else clean_text(data.decode('ascii', 'replace')) - self._append_rx(f'← {ts}{body}') - self._update_counters() + self._append_rx(f'← {t}{body}') - def _on_tx(self, label, data): + def _on_tx(self, label, data, ts=None): self._tx_n += 1 self._tx_b += len(data) if label is None: - self._append_rx(f'→ {_timestamp()} [HEX] {hexs(data)}') + self._append_rx(f'→ {_timestamp(ts)} [HEX] {hexs(data)}') else: - self._append_rx(f'→ {_timestamp()} {label} [{hexs(data)}]') - self._update_counters() + self._append_rx(f'→ {_timestamp(ts)} {label} [{hexs(data)}]') def _update_counters(self): + """状态栏: 收发计数 + 事件队列积压量。正常恒为 0; 数字增长=事件 + 产出快于消费(GUI 卡或数据量异常), 便于用户把"不显示"现场分层""" 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): self.theme_manager.set_mode(('system', 'light', 'dark')[idx]) diff --git a/SerialMate/src/selftest_gui.py b/SerialMate/src/selftest_gui.py index df03f2c..d2f1df3 100644 --- a/SerialMate/src/selftest_gui.py +++ b/SerialMate/src/selftest_gui.py @@ -6,6 +6,7 @@ 用法: python selftest_gui.py (全部通过退出码 0) """ import os +import re import sys 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) 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) 组帧预览: 多条 '/' 分隔 win.ed_tx.setText('M1/M731S0T1000') check('多条预览', win.lb_preview.text(), @@ -116,9 +124,10 @@ check('帧构成非法详情', 'PAYLOAD 必须恰好出现一次' in win.lb_prot win.ed_layout.setText(d.DEFAULT_LAYOUT) check('帧构成还原预览', win.lb_preview.text(), 'D5 01 02 4D 31 81') -# 2) TX 回显行 +# 2) TX 回显行(直接驱动处理函数, 断言前 flush 批量日志缓冲) cfg = win._proto_from_ui() 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) # 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]['scale'].setValue(0.1) win._on_frame(fr) +win._flush_rx() last = win.text_rx.toPlainText().splitlines()[-1] check('帧日志含 TYPE 名', 'B=0xF0 成功' in last and 'LEN=8' in last and 'okTL:246' in last, True) 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 显示切换 + 原始帧 win.combo_display.setCurrentIndex(1) win._on_frame(fr) +win._flush_rx() last = win.text_rx.toPlainText().splitlines()[-1] 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) @@ -145,6 +162,7 @@ check('HEX显示+原始帧', '6F 6B 54 4C 3A 32 34 36' in last # 5) 透传行 win.combo_display.setCurrentIndex(1) win._on_raw(b'\x11\x22') +win._flush_rx() check('透传HEX行', win.text_rx.toPlainText().splitlines()[-1].endswith('11 22'), True) # 6) 未连接发送丢弃 @@ -152,6 +170,28 @@ win._send() win._drain_events() 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) 配置往返 store = win._store_from_ui() check('配置 proto', (store['proto']['tx_hdr'], store['proto']['crc_algo']), diff --git a/SerialMate/src/selftest_loop.py b/SerialMate/src/selftest_loop.py index 2064431..10ffcc2 100644 --- a/SerialMate/src/selftest_loop.py +++ b/SerialMate/src/selftest_loop.py @@ -40,15 +40,16 @@ def wait_for(pred, timeout=5.0): got = {'status': [], 'frames': [], 'tx': [], 'raw': []} cfg = d.ProtoConfig() -link = SerialLink(cfg, on_status=lambda s: got['status'].append(s)) -link.on_frame = lambda fr: got['frames'].append(fr) -link.on_raw = lambda b: got['raw'].append(b) -link.on_tx = lambda lb, data: got['tx'].append((lb, data)) +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_raw = lambda b, ts: got['raw'].append((b, ts)) +link.on_tx = lambda lb, data, ts: got['tx'].append((lb, data, ts)) # 1) 打开虚拟回环口 link.open('loop://', 115200) 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 回显 + 回环字节 payload = 'M731S0T1000' @@ -58,6 +59,7 @@ check('发送回显', wait_for(lambda: len(got['tx']) >= 1), True) if got['tx']: check('TX label', got['tx'][0][0], payload) check('TX frame', got['tx'][0][1], frame) + check('TX 携带写入时刻', isinstance(got['tx'][0][2], float), True) # 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 @@ -65,7 +67,9 @@ check('响应帧向量自校验', cfg.verify_frame(resp), True) link.ser.write(resp) check('应答解析', wait_for(lambda: len(got['frames']) >= 1), True) 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('payload', fr.payload, b'okTL:246') 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) check('透传原始字节', wait_for(lambda: len(got['raw']) >= 1), True) if got['raw']: - check('透传内容', got['raw'][0], raw) + check('透传内容', got['raw'][0][0], raw) + check('透传携带到达时间戳', isinstance(got['raw'][0][1], float), True) # 5) 切回解析模式, 缓冲已清空, 正常再收一帧 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) 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') sys.exit(1 if fails else 0)