Files
HW-Debug-Tools/SerialMate/AGENTS.md
T

22 KiB
Raw Blame History

AGENTS.md — SerialMate 工程档案(开发约束与进度)

人类文档 README.md 回答"这是什么、怎么用";本文记录"代码怎么组织、 怎么构建发布、改动时哪些约束不能破坏",兼作本工具的进度记录。 修改本目录代码前先读完本文。仓库整体的开发需求与进度管理见根目录 agent.md。

1. 项目定位

仓库 HW-Debug-Tools 是 LT8619C→DLPC3421 投影转接板的调试上位机工具集。 SerialMate 是其中的通用串口/RS485 调试助手:收发 + 协议解析 + 曲线。 默认帧格式与 DLPilot 光机 RS485 协议完全一致(D5 01 LEN <payload> CRC), 可直接调试板载 GD32E230 网关;帧格式(包头/板ID/CRC 算法与覆盖范围)可定制, 适配任意"包头+板ID+LEN(=payload长度)+payload+CRC"五段式协议。 协议语义权威同 DLPilot:上游固件仓 protocols.md,本仓不维护副本。

2. 目录契约

SerialMate/
├── src/       源代码区 —— 只有这里可以手改
├── release/   产物区 —— SerialMate.exe + README.md(入库),
│              *-win64.zip(gitignore,只上传 Release)
├── assets/    图标等生成资产(暂无;build_exe.py 已预留 assets/serial-mate.ico)
├── README.md  人类文档
└── AGENTS.md  本文

文件角色速查

文件 角色
src/main.py Fluent GUI 入口(含 --seconds N 冒烟参数),argparse 仅此一处
src/protocol.py 协议模块(纯标准库):FrameLayout 帧构成令牌、ProtoConfig 组帧/校验、FrameParser 增量解析、CRC 算法表、extract_value 数值提取
src/link.py 串口工作线程 SerialLink(pyserial 只准在这里 import),支持 loop:// 虚拟回环
src/plot.py 滚动曲线控件(纯 QPainter,刻意不引 matplotlib/pyqtgraph)
src/config.py BASE_DIR 定位与 serial_mate.json / board_profiles.json 读写(缺键补默认,读容错)
src/theme.py 主题三件套同步(Fluent setTheme + QPalette/QSS + DWM 标题栏),与 EDID-Viewer 同源
src/selftest.py 协议自检(无依赖、无硬件,改 protocol.py 后必跑)
src/selftest_loop.py 链路自检(loop:// 回环验证 组帧→发送→接收→解析→提取,缺 pyserial 跳过)
src/selftest_gui.py GUI 功能自检(offscreen 驱动事件处理函数:预览/回显/提取/配置往返,改 main.py 后建议重跑)
src/build_exe.py PyInstaller onefile 打包 → release/SerialMate.exe
src/package_release.py exe+README → release/SerialMate-v<版本>-win64.zip
src/publish_gitea.py 建/复用 Gitea Release 并上传 zip(GITEA_TOKEN,需 --insecure)

3. 代码不变量(改代码必须维持)

  1. 依赖分层:protocol.py / config.py 纯标准库;pyserial 只准在 link.py import(main.py 经 SerialLink 间接使用);plot.py 只依赖 PySide6; GUI 硬依赖 PySide6-Fluent-Widgets + pyserial(串口工具缺 pyserial 即无意义, 与 DLPilot 的"缺失降级"策略不同,只保留 try-import 提示)。 1a. 卡片布局约定:_card(side=True) 把短副标题放标题右侧省一行(串口/ 协议/发送卡);右列是纵向 QSplitter(接收/发送高度用户可拖拽,拉伸余量 默认给接收、发送卡保持自然高度防空腔);发送卡末尾 lay.addStretch(1) 保证内容贴顶(卡片被拖高后富余高度收在卡底,勿删——否则拉伸被各行均分, 标题上方出现空隙);发送卡行序:板型管理行(下拉+新建+删除+配置+重载) / 预设行(下拉+填入+直接发送) / 模式行(下拉+存为板型+存入预设) / 输入行 / 周期行;帧日志 payload 高亮走 _append_rx_frame(_esc_log 转义后拼 span 背景,toPlainText 内容不变,selftest 的日志断言因此不受影响);预设下拉 显示"名称 · 命令",存入命令走 _add_command_named(弹窗核 _add_command 包装,测试用核函数),预设直发走 _send_preset_direct(经输入框复用 组帧/历史链路)。 1b. 窗口最小高度:首帧布局后 _apply_minimum_size 按内容实际最小高度 抬高窗口下限(高度拉过矮时布局约束无法满足,曲线卡绘图区会被压塌并与 通道行互相叠压——用户实测);勿删, 调整曲线卡行数/高度后该值自动跟随。
  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——每行必须包 <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 间隔), RS485 总线多帧连发留应答间隙,勿改成批量 drain 一次写。
  4. 帧构成语义:帧由 FrameLayout 令牌序列定义(HDR ID LEN PAYLOAD CRC 令牌 + 十六进制字面量,收发共用布局、仅包头值分方向);约束:LEN/PAYLOAD/CRC 恰好各一次、CRC 必须最后、LEN 前至少一个 HDR 或字面量(同步定界)、ID 最多一次。 LEN 口径(LEN_SEMS):payload 长度 / 到帧尾(payload+CRC) / 整帧; CRC 算法/覆盖范围见 protocol.CRC_ALGOS/CRC_SCOPES,覆盖按帧内位置定义 (with_header=第1字节起 / no_header=跳过第1字节 / payload_only=仅载荷), 任意布局通用;2 字节 CRC 线上字节序 MODBUS 低前、其余高前,按算法约定单序 校验(改动需同步 selftest 向量)。
  5. 解析重同步策略:前缀令牌逐位对齐(HDR/字面量须匹配、ID 通配),LEN 值经 口径换算为 payload 长度,负值视为伪帧头逐出;CRC 失配丢 1 字节重同步—— 通用工具不预设合法 LEN 区间(与 DLPilot 的 LEN 范围预判不同)。
  6. ID 位不校验只透出:Frame.code 原样上抛(布局无 ID 时为 None,GUI 日志 与 selftest 均需兼容 None),TYPE_NAMES 仅用于显示译码(DLPilot 语义)。
  7. BASE_DIR 语义(config.py):frozen 时 =exe 所在目录,源码运行时 =src/; 设置平铺存 serial_mate.json,不建子目录。tx.periodic 不随配置 自动启动(防误连发),其余配置启动恢复。
  8. 曲线数据流:每帧对每个启用通道调 extract_value(失败静默跳过), PlotWidget.add 内部 deque(maxlen=3000) 滚动丢弃;数值格式统一 fmt_val(4 位有效数字)。
  9. 主题:theme.py 三件套同步(见 EDID-Viewer 教训:只 setTheme 会黑块); plot 曲线颜色固定 4 色,文字/网格色取自 QPalette 自动跟随明暗。 9a. 曲线时间窗:PlotWidget 持有 _span(窗宽秒, None=跟随全部) 与 _offset(右端距最新秒数, 0=实时);横轴域换算在 visible_domain(纯计算, selftest 有断言)。窗口不向数据收敛(用户实测反馈):选 5min 而数据只有 23s 时显示完整 5min 轴、曲线贴右端随采集填满(示波器式),右下角标注 "已采集 Xs / 窗口 Ns";滞后超出全部历史 = 空窗保宽。弹窗内 时间窗下拉+ 滑条 只改这两个状态;滑条 0..1000 线性映射 (1000-v)/1000×maxpan,实时 跟随=拉到最右(1000);poll 定时器 _sync_time_slider 回写位置(用户拖动中 跳过);嵌回卡片时强制重置为跟随全部(卡内无滑条, 防困在滞后窗里)。纵轴 数值刻度画在每条横向参考线上。 悬停读值:mouseTracking 开启, mouseMove 只记录 _hover_pos 并请求重绘; 读值内容由 paintEvent 每帧经 _hover_lines_at(_hover_pos) 重算(光标 时间 T 经 _plot_geometry 换算, _nearest 二分取 ±10px 容差内最近采样), 画在顶部居中反色小框(_hover_box)——鼠标停住不动时数值也随实时数据持续 刷新(勿改回 mouseMove 里一次性算好/或 QToolTip, 那样不动鼠标就不更新—— 用户实测反馈)。_nearest/_hover_lines_at 有单测。绘图区内边距统一用 M_LEFT/TOP/RIGHT/BOTTOM 类常量(绘制与鼠标换算共用, 勿在 paint 里另写)。 9b. 暂停(两级语义):主界面"暂停"= _paused, add() 丢弃暂停期间的数据; 弹窗"暂停渲染"= _frozen, 数据照常入库但跳过重绘——冻结是绝对状态: PlotWidget 覆写 update() 直接吞掉一切重绘请求(任何代码路径都无法在 冻结期打破), 只有 set_frozen(False) 恢复并一次性渲染积累;冻结时画面 中央显示"已冻结渲染(采集继续)"角标让状态可见。两者互不干扰, 均有 selftest 断言。 9c. 弹窗曲线交互:自动Y(勾选按钮, 与主界面 chk_auto_y 双向同步, 关闭时 以 auto_yrange() 初始化手动量程);图上左键拖动 = 平移手动 Y 量程 (on_yrange_changed 回调同步主界面 Ymin/Ymax 输入框, 阻断信号防回环); 滚轮 = 缩放时间窗 2~900s(滞后按比例缩放, 经 on_span_changed 回调让 _sync_time_slider 把时间窗下拉同步为匹配档位/自定义(-1));"实时"按钮 = offset 归零跳回最新。自动 Y 量程抽取为 auto_yrange()(纯计算, 有单测)。 11a. 曲线弹出窗口:绘图区在曲线卡留有固定槽位布局 _plot_slot,弹出 = setParent(None) 后装入顶层窗口(通道/暂停/Y 轴控件留在主界面控制同一 PlotWidget 实例);关掉弹出窗口(eventFilter 捕 Close)或主窗 closeEvent 都会走 _dock_plot() 嵌回 —— _dock_plot 必须先摘 self._plot_win 引用 再 win.close()(防 Close 递归),且 setParent(None) 后必须补 show() (重挂布局不会自动恢复可见)。
  10. 多实例:有意不加单实例锁(双口双开的合法场景),串口冲突靠 pyserial 报错提示("串口被占用"文案在 link.py,勿删)。
  11. 板型档案:board_profiles.json(BASE_DIR 平铺,用户本机数据不入库, 首次运行自动生成默认"DLPilot光机")。档案 = name + proto(7 个帧协议字段, 不含 parse —— 解析开关是查看偏好) + commands[[名,命令]..];切换板型 应用协议参数并装载命令表,"存入"命令以命令文本为名去重;启动恢复选中 板型时只装载命令表不重复应用协议参数(协议以上次会话 store 为准); GUI 增删后经 config.save_profiles 落盘,"配置/重载"支持手改 JSON。

4. 构建与发布(全部在 src/ 下执行)

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 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
python publish_gitea.py ../release/SerialMate-v<版本>-win64.zip --replace --insecure

发布约定与 Gitea 实例怪癖(TLS 抽风加 --insecure、assets 两种形状兼容、 token 读 GITEA_TOKEN 用户级环境变量)同 DLPilot,详见 DLPilot/AGENTS.md §5; tag/release 命名 SerialMate-vX.Y.Z,zip 命名 SerialMate-vX.Y.Z-win64.zip(publish_gitea.py 从文件名推 tag)。

5. 已知坑(本工具踩过/继承的)

  • qfluentwidgets.CaptionLabel 无 setDim 方法(本机版本),灰字用 CaptionLabel 本身即可;ComboBox 不要用 currentData(qfluentwidgets 自绘下拉 userData 支持不稳),一律 currentIndex + 平行元组映射。
  • qfluentwidgets 控件默认水平 Expanding:紧凑行不设 stretch 项时空白会被 CheckBox/Label 等瓜分出大空隙 —— 一律走 Window._row()(行尾默认补 stretch;Qt 规则:有 stretch 因子时额外空间只按因子分配),需要自管拉伸的 行传 trailing=False。
  • qfluentwidgets SpinBox/DoubleSpinBox 设固定宽后内部行编辑器会塌缩 (minimumSizeHint 142/193 只是参考,强设小于它的宽度时行编辑器只剩几像素, 数值被裁成残字——整数 0 在 96px 时渲染成 "]"):整数 SpinBox ≥110px、 DoubleSpinBox ≥130px 才能完整显示数值;曲线卡偏移/序号/倍率/Y 量程框的 宽度有 selftest 断言把守, 调布局时勿低于阈值。
  • 组帧/解析回路自测时必须让 tx_hdr == rx_hdr(收发方向包头本就允许不同, D5 组的帧 B5 解析器看不见——selftest 的 fuzz/回路用例已按此约定)。
  • offscreen 冒烟报 QFontDatabase: Cannot find font directory 属 Qt offscreen 平台噪音,桌面运行无此问题,勿当 bug 修。
  • onefile 运行时定位 exe 用 sys.executable(__file__ 在 _MEIPASS), config.py 已按此实现。

6. 验证清单(改完代码后)

  1. python -m compileall -q . —— 全模块可编译;
  2. python selftest.py —— 协议回归(改 protocol.py 后必跑);
  3. python selftest_loop.py —— 链路回环(改 link.py 后必跑);
  4. python main.py --seconds 5 —— GUI 冒烟自动开关;
  5. python build_exe.py → 双击 release/SerialMate.exe 确认界面渲染;
  6. (有硬件时)USB-RS485 接光机发 M1/M201/M731 验证真机收发与曲线。

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,首版)

已完成:

  • 协议模块:帧构成令牌序列可定制(HDR ID LEN PAYLOAD CRC 令牌 + 十六进制 字面量同步字节,收发共用布局,支持字段换序/无板ID/多字节同步头;LEN 口径三种: payload 长度 / 到帧尾 / 整帧)、5 种 CRC 算法 × 3 种覆盖范围(按帧内位置定义, 任意布局通用)、增量解析(前缀逐位对齐 + CRC 失败逐字节重同步)、 二进制/文本/正则三种曲线数值提取;自测 64 项全 PASS(含 DLPilot 实测帧向量、 CRC 标准校验值 check("123456789")、布局换序/字面量/LEN 口径向量与布局模糊往返)。
  • 串口链路:参数热切换、发送队列(半双工友好 + 未连接丢弃)、解析/透传双模式 热切、loop:// 虚拟回环自检 13 项全 PASS(组帧→发送→接收→解析→曲线提取)。
  • GUI(PySide6-Fluent-Widgets):串口/协议/曲线/接收/发送五卡,帧构成与 LEN 口径 可视化编辑(非法输入即时提示),payload 字符串/HEX 切换(帧日志的 payload 段垫半透明底色高亮——主题无关 rgba, 明暗切换不残留, 经 appendHtml 实现)、 原始帧 hex、时间戳、日志保存;发送:自动包装(/ 分隔多条)、HEX 直发、 周期发送、组帧实时预览、历史回溯(↑↓)、回车发送;主题跟随系统。
  • 曲线(plot.py + 弹窗,2026-09-27 实机反馈多轮迭代):4 通道三种提取方式 + 倍率,自动/手动 Y 量程;绘图区可弹出独立窗口(嵌回自动重置视图); 纵轴每条参考线标数值刻度;时间窗(跟随/10s15min, 示波器式不向数据收敛, 右下角"已采集/窗口"标注)+ 横向滑条回看历史;悬停读值框固定顶部、鼠标 不动也随数据持续刷新;暂停渲染(冻结显示继续收数, 与主界面"暂停" 两级语义, 冻结有中央角标);自动Y 开关、图上拖动自定义 Y 轴(同步主界面 输入框)、滚轮缩放时间窗(2900s)、实时按钮;窗口最小高度按内容自适应 (防高度拉小时通道行叠压绘图区)。
  • 板型档案:发送卡"板型"下拉一键切换协议参数 + 命令预设;新建板型(协议 沿用当前、命令预设空白,防遗留其他板子的命令)/存为板型(同名覆盖)/ 删除板型(确认弹窗,至少保留一个,删除后切第一个板型且协议参数不动)/ 存入预设命令(弹窗自定义显示名称)/配置=内嵌表格编辑器(CommandTable: 第 0 列 ≡ 手柄按住上下拖动整行排序——内部 QDrag,move_row 整行搬移; 网格线/隔行底色按 palette 明暗自适配,空行清晰可见;+加行 −删行;空命令与 重复命令保存时忽略/去重,写回 board_profiles.json)/重载;预设下拉 "名称 · 命令"同显。
  • 发布流水线:build_exe/package_release/publish_gitea 与全仓一致;exe 已重建 入库,zip/Release 未发布(待用户验收)。

待办 / 计划:

  • 【无图标】assets/serial-mate.ico 未生成(build_exe.py 已预留路径, 参照 DLPilot logos/make_ico.py 流程补)。
  • 【真机已过】用户已实机联调(COM15 真机收发/曲线提取/多板型档案实际使用, 2026-09-27 多轮界面反馈即来自真机数据);温度曲线量纲(倍率取值)待用户 按传感器手册确认。
  • 【计划】发送区多条 payload 的"批处理脚本"化(延时/循环/条件等待应答)。
  • 【计划】曲线数据导出 CSV;接收日志按日期自动分文件。
  • 【考虑】CRC 解析侧双字节序兼容开关(当前按算法约定单序)。