# AGENTS.md — SerialMate 工程档案(开发约束与进度) > 人类文档 [README.md](README.md) 回答"这是什么、怎么用";本文记录"代码怎么组织、 > 怎么构建发布、改动时哪些约束不能破坏",兼作本工具的进度记录。 > 修改本目录代码前先读完本文。仓库整体的开发需求与进度管理见根目录 `agent.md`。 ## 1. 项目定位 仓库 `HW-Debug-Tools` 是 LT8619C→DLPC3421 投影转接板的调试上位机工具集。 SerialMate 是其中的**通用串口/RS485 调试助手**:收发 + 协议解析 + 曲线。 默认帧格式与 DLPilot 光机 RS485 协议完全一致(`D5 01 LEN 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`(html.escape 后拼 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。 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/ 下执行) ```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 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.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 量程;绘图区可**弹出独立窗口**(嵌回自动重置视图); 纵轴每条参考线标数值刻度;时间窗(跟随/10s~15min, 示波器式不向数据收敛, 右下角"已采集/窗口"标注)+ 横向滑条回看历史;悬停读值框固定顶部、鼠标 不动也随数据持续刷新;**暂停渲染**(冻结显示继续收数, 与主界面"暂停" 两级语义, 冻结有中央角标);自动Y 开关、图上拖动自定义 Y 轴(同步主界面 输入框)、滚轮缩放时间窗(2~900s)、实时按钮;窗口最小高度按内容自适应 (防高度拉小时通道行叠压绘图区)。 - 板型档案:发送卡"板型"下拉一键切换协议参数 + 命令预设;**新建板型**(协议 沿用当前、命令预设空白,防遗留其他板子的命令)/存为板型(同名覆盖)/ **删除板型**(确认弹窗,至少保留一个,删除后切第一个板型且协议参数不动)/ 存入预设命令(弹窗自定义显示名称)/配置=内嵌表格编辑器(**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 解析侧双字节序兼容开关(当前按算法约定单序)。