Files
STM32F4-Base/docs/测试说明/测试工具/串口助手设计说明.md
T
wandering1 4a4ad6bc5f chore(全仓): @build/@test 旧文档别名清退为技能调用名
现状口径:/keil-build 与 /serial-boot-test 是 DSH 会话的技能调用名,
不是 shell 命令;「名 + 命令行参数」的命令形态改为可执行体
run_boot_test.py 的真实调用。

- AGENTS.md:别名说明重写为「技能调用名不是 shell 命令」,两条目改
  「编译(/keil-build)」「串口回归(/serial-boot-test)」
- keil-build / serial-boot-test 两 skill 调用名与示例同步;task-worktree
  方案文档去掉 @build/@test 惯例从句;@task 保留(不在点名范围)
- 123 文件现状陈述全改;台架捕获、变更记录/时间线、rename 映射输入
  等 41 行历史证据保留旧名
- gen_memory_svg.py 6 处措辞随改,3 张 SVG 产物同批重跑
- 顺手补并行方案同行漏网计数 51→52 钩子

验证:提交期快钩子对 123 文件实跑全 Passed;check_doc_links --all
rc=0(3038 处、无法判定 0);check_mermaid --all rc=0;YAML 解析 52
钩子不变;skill-size rc=0;py_compile 全过;字节卫生零漂移。
2026-10-30 13:03:12 +08:00

20 KiB
Raw Blame History

Python 串口助手(serial_tool)设计说明

版本:v1.1(需求 + 设计 + 实施记录) 日期:2026-09-15 状态:已实施(步骤 1–6 完成;本机验收 A1–A5/A8 通过,A6/A7 正路径待 CH340 硬件接入后补验) 关联文档:串口助手使用指南.md(命令用法/工作流/排障) · 自动化测试流程.md · serial-assistant skill(命令速查/陷阱) · .serialtest/config.json(端口单一配置源,2026-10-10 由 .opencode/serial_config.json 迁入) 取代:serial-mcp-server_串口调试.md(实施时删除,工具历史并入本文 §12)

1. 背景与目标

1.1 为什么重写

现串口调试 = Adancurusul/serial-mcp-server(Rust 二进制,手编译置于用户档案)+ MCP/CLI 双形态。两个致命缺陷(2026-09-15 本机实测):

# 缺陷 影响
F1 二进制绑定用户档案:opencode.json 的 mcp.serial 指向 C:\Users\16241\AppData\Local\serial-mcp-server\*.exe,本机实际用户为 Administrator(该档案不存在,二进制缺失) serial MCP 工具不可用、/serial-boot-test 采集步骤断裂;换机器/换用户无法复现
F2 无历史缓冲:read 只能读调用之后到达的字节 抓开机日志被迫走"采集窗口 + first-byte 触发 + EOF 重试"仪式(6 个参数 + JSON 解析 + CH340 释放后重开陷阱特判)

Python 重写一并解决:依赖收敛到仓库 .venv + pyserial(任意机器/用户可复现);常驻 daemon 把全量 RX/TX 持续写入日志文件,历史 = 日志文件本身,采集窗口仪式整体消失。

1.2 目标

# 目标
G1 Agent 可枚举 COM 口、连接;失败 ≤1s 快速返回,绝不阻塞
G2 RX/TX 全量写入纯文本日志文件,Agent 直接读文件即得完整历史
G3 Agent 一切操作 = 带硬超时(5s)的短命令;串口所有权归常驻 daemon
G4 一条 status 命令看全状态(daemon/端口/计数/最后 RX/日志文件/错误)
G5 随时清空日志(原子 + 代际号,新旧内容可区分)
G6 /serial-boot-test 一键流程切换新工具:open → clear → 烧录 → 读文件 → verify,无采集窗口
G7 全流程不关端口(ST-Link 烧录不占 COM),从根上规避 CH340 释放/重开陷阱

2. 需求清单

# 需求 优先级 来源
R1 ports:枚举 COM 口(名称/友好名/厂商,标注配置目标口) P0 用户 #1
R2 open:连接端口;不存在/被占用 ≤1s 返回明确错误;参数 port/baud/DTR/RTS P0 用户 #1
R3 RX/TX 全量时间戳日志(单文件,纯文本,行格式见 §5) P0 用户 #2
R4 全部命令硬超时 5s + 失败返回 JSON(exit code 区分),CLI 永不挂起 P0 用户 #3
R5 status:单条 JSON 全状态 P0 用户 #4
R6 clear:原子清空 + 代际递增 + 文件内标记行 P0 用户 #5
R7 常驻 daemon + 单实例 + CLI 自动拉起(Agent 无需记生命周期) P0 补充
R8 日志 20MB 上限,超限原地截头保留尾部 10MB(不重开端口/不 rename) P0 补充(用户确认)
R9 开端口不翻转 DTR/RTS(防 STM32 自动复位电路误复位),可配置 P0 补充
R10 串口读错/拔线 → daemon 存活,state=error,可重开;daemon 被杀可自恢复 P0 补充
R11 /serial-boot-test 集成:run_boot_test.py 采集步改写 + verify_log.py 兼容 + 旧文档/配置迁移 P0 用户场景
R12 send(TX 进日志)、log --tail/--grep、start/stop/restart、selftest 无头自检 P1 补充

3. 总体方案

3.1 形态:常驻 daemon + 薄 CLI + "日志文件即接口"

flowchart LR
    A["Agent(opencode bash 工具)"] -->|"短命令(硬超时 5s)"| C["serial_tool.py CLI"]
    C -->|"HTTP 127.0.0.1:18765(JSON)"| D["daemon 进程(detached 单实例)"]
    D --> S["串口读线程(pyserial,后台)"]
    S --> W["行拆分/解码/flush"]
    W --> F[("state/serial.log<br/>纯文本,Agent 直接读")]
    D --> M[("state/meta.json<br/>pid/gen/版本")]
    D --> L[("state/daemon.log<br/>daemon 自身 stderr")]
  • 单文件实现:.dsh/skills/serial-assistant/scripts/serial_tool.py(stdlib + pyserial,约 1000 行);解释器统一 .venv\Scripts\python.exe
  • Agent 调用式:& '.venv\Scripts\python.exe' '.dsh/skills/serial-assistant/scripts/serial_tool.py' <命令> [参数](任意 cwd;state 目录按脚本位置解析,与 cwd 无关)
  • 历史 = 日志文件:Agent 用 Read/Grep 直接看历史,无需专门 API(log 命令只是便捷封装)
  • 配置沿用 .serialtest/config.json(portName/baudRate 为默认值;新增可选字段 httpPort,默认 18765)

3.2 "不卡死"的归属(对用户 #3 的重新表述)

串口读是长时操作,归 daemon 后台线程;Agent 侧只存在短命令。"不卡死" = 命令层契约:每条 CLI 命令有 5s 硬截止(--timeout 可覆盖),到期即返回超时 JSON 与 exit 2,不做任何无限等待。

4. 命令契约

4.1 子命令一览

命令 用途 关键参数 返回(stdout,恒 JSON)
ports 枚举 COM 口 — {"ok":true,"ports":[{"name":"COM6","description":"USB 串行设备 (CH340)"},…],"configured":"COM6"}
open 开端口(幂等:同端口已开 = 直接成功,不重开) --port --baud --dtr 0/1 --rts 0/1(默认取配置文件;DTR/RTS 默认 0) {"ok":true,"port":"COM6","baud":115200,"gen":7,"since":"…ISO…"}
close 关端口(flush 后释放) — {"ok":true}
send 发 TX 字节(进日志);默认精确字节不加换行 位置参数文本(UTF-8)或 --hex 0D0A;--nl 追加 CRLF {"ok":true,"bytes":12}
status 全状态(见 §4.2) — 见 §4.2
log 读日志(先过滤后截尾,2026-10-10) --tail N(默认 50;0 = 全部)--grep P -i --regex {"ok":true,"gen":8,"lines":["…"],"scanned":N,"matched":M}
wait-idle 等 RX 静默(/serial-boot-test 收尾判据) --settle-ms(默认 8000)--max-ms(默认 30000)--first-timeout-ms(默认同 max) {"ok":true,"rx_bytes":本次增量,"idle":bool,"waited_ms":N,"reason":"idle|no_data|timeout"}
clear 清空日志:原地截断 + 写 [SYS] log cleared (gen=N+1) — {"ok":true,"gen":8}
start / stop / restart daemon 生命周期 — {"ok":true,"pid":…} / stop 后 {"ok":true,"stopped":true}
selftest 无头自检(行拆分器/解码回退/截头算法 单测回环,无需硬件) — 0=PASS

4.2 status 返回示例

{
  "ok": true,
  "daemon": {"pid": 1234, "version": "1.0.0", "uptime_s": 61},
  "port": {"ready": true, "name": "COM6", "baud": 115200, "dtr": false, "rts": false,
           "since": "2026-09-15T10:29:58.112+08:00"},
  "counters": {"rx_bytes": 1834, "tx_bytes": 12, "rx_lines": 57, "errors": 0},
  "last_rx": {"ts": "2026-09-15T10:31:02.441+08:00", "line": "UI menu init: OK", "age_ms": 320},
  "log": {"path": "D:\\…\\tools\\serial_tool\\state\\serial.log", "size": 4523, "gen": 7},
  "state": "active",
  "error": null
}

state ∈ {stopped, active, error};port.ready = 已开 && state≠error。Agent 轮询等待只用本命令。

4.3 CLI 退出码契约

exit 含义
0 命令成功(ok:true)
1 领域错误(ok:false + error:端口不存在/被占用/参数错…)——快速失败,≤1s
2 daemon 不可达或命令超时(5s 硬截止)
3 参数/用法错误

4.4 自动拉起

任何命令(除 start 本身)发现 daemon 未运行 → CLI 以 sys.executable(同一 .venv 解释器)detached 拉起 daemon(CREATE_NO_WINDOW,stderr → state/daemon.log),轮询 HTTP 就绪(≤5s)后重试原命令一次;仍失败按 exit 2 返回。Agent 无需任何显式 start 步骤。

5. 日志文件规范

5.1 位置与配套文件(目录整体 gitignore)

以下均为运行时产物,未创建于仓库(首次运行工具后自动生成):

文件 用途
.dsh/skills/serial-assistant/scripts/state/serial.log RX/TX/SYS 全量日志(Agent 直接读)
.dsh/skills/serial-assistant/scripts/state/meta.json(运行时未创建) daemon 元数据(pid / 版本 / 当前 gen)
.dsh/skills/serial-assistant/scripts/state/daemon.log daemon 自身 stderr(排障用)

5.2 行格式

[SYS] 2026-09-15T10:29:58.112+08:00 port opened COM6 115200 (gen=7)
[SYS] 2026-09-15T10:29:58.301+08:00 log cleared (gen=8)
[RX] 2026-09-15T10:30:03.445+08:00 App init OK
[RX] 2026-09-15T10:30:07.912+08:00 UI menu init: OK
[TX] 2026-09-15T10:30:09.872+08:00 AT+PING
[SYS] 2026-09-15T10:31:00.000+08:00 log truncated to 10MB (cap=20MB)
规则 规定
方向标记 [RX] 串口入 / [TX] Agent 发出 / [SYS] 工具事件(open/close/error/cleared/truncated)
时间戳 本地 ISO8601 含毫秒与偏移(2026-09-15T10:30:03.445+08:00);行时间戳 = 该行首字节到达时刻
完整行不变量 文件只含完整行:残行留 daemon 内存,200ms 无后续数据即冲刷成行(或 clear/close 时强制冲刷)→ Agent 读文件永不见半行。两条补充(2026-10-10 第三批):① 无换行残行 64KB 封顶(PARTIAL_MAX_BYTES)——板子跑飞/烧录期吐二进制垃圾时旧实现会无限攒在内存里(长会话可到几十 MB),且后续每条正常行都被拼在这坨垃圾尾巴上变成不可读长行;超限即当一行落地(解码回退成 HEX:)并自动重新对齐换行。② open / close / clear 是会话边界,残行一并丢弃(splitter_gen 计数,串口线程下一轮循环生效)——旧实现残行跨会话留存,上一段末尾被复位截断的半行会粘进新会话第一行,开机日志首行因此不可读
分行 按 \n 分(\r 剥除);纯二进制流(无换行)按 200ms 窗口成行
解码回退 UTF-8 → GBK → 仍失败则该行文本域写 HEX:<原始字节 hex>(MCU 流 = ASCII 为主 + UTF-8 中文,前两级足够)
flush 每行即 flush(串口速率低,无缓冲压力)→ 文件随时可读、内容即时可见

5.3 清空与代际

  • clear = 写句柄上原地截断(seek(0)+truncate)+ 写 [SYS] log cleared (gen=N+1) 标记行;对写线程原子(单写者,截断与写入同一线程串行)
  • 代际 gen 持久化于 meta.json(daemon 重启不丢);Agent 以"最后一个 cleared/opened 标记行"或 status.log.gen 区分新旧内容

5.4 体积上限(20MB,用户确认)

  • 每次写后检查:size > 20MB → 原地截头:在同一写句柄上读尾部 10MB → 截空 → 写回(Windows 无"丢弃头部"单系统调用,ftruncate 只能截尾;单写者线程内执行,无并发风险)。按行边界对齐(2026-10-10):取到的尾部会丢掉首个换行之前的残片——旧实现按字节偏移切,截出来的第一行是半行,直接破坏"文件只含完整行"这条不变量(Grep / verify_log 读到的首行是垃圾)
  • 禁止 rename 轮转(Windows 下写句柄跟随被 rename 的文件)与重开文件/端口(CH340 释放后立即重开 → 读 0 字节 EOF 陷阱)
  • 截头后写 [SYS] … log truncated to 10MB (cap=20MB);文件头内容可能随时消失 → Agent 读日志应看尾部(log --tail 或直接读文件末段)

6. daemon 行为规范

项 规定
单实例 HTTP 端口(默认 18765,.serialtest/config.json 的 httpPort 可改)绑定即锁;端口被占 = 已有实例。Windows 上真独占靠 SO_EXCLUSIVEADDRUSE(真名,值 -5;2026-10-10 修:旧代码写成 SO_EXCLUSIVEADDRPLACE + 数值 0x0040,setsockopt 抛 WinError 10042 被静默吞掉,"独占绑定"从未生效),失败现打 WARN,status.daemon.exclusive_bind 可验。⚠️ 进程列表里出现两个 --serve 是正常的:.venv\Scripts\python.exe 是 venv stub,会派生基解释器(本机 anaconda)子进程跑真 daemon(status.daemon.pid = 子进程),不是多实例
拉起方式 CLI detached 子进程(CREATE_NO_WINDOW),解释器 = sys.executable(保证同一 .venv)
open 语义 幂等(同端口已开 = 成功返回,不重开);端口不存在 → ≤1s 报错并附可用端口列表;被占用 → ≤1s 报错(Windows 共享冲突即时返回)并提示持有者
DTR/RTS 默认不翻转(dtr=False, rts=False,防 STM32 自动复位电路误复位目标板);--dtr/--rts 可显式置位
读线程容错 串口读异常/设备拔出 → 记 [SYS] … serial error: …,state=error(status.error 非空),daemon 不退出;重插后 open 重开恢复
重开退避 错误恢复路径重开端口前 ≥1.5s 退避(CH340 特性);正常流程不重开
stop 关端口(flush 尾行)→ 写 [SYS] port closed → 退出
进程被强杀 Windows 进程退出自动释放 COM 端口;下次命令自动拉起新实例(open 幂等路径)
wait-idle 判据(2026-10-10 修) no_data = 本次等待期间零字节(旧实现比的是 daemon 累计 rx_bytes 是否为 0 → 常驻 daemon 下永不触发,板子全程静默也误报 idle:true,/serial-boot-test 的 no_data 拦截形同虚设);首字节到达前不启动 settle 计时(first_timeout_ms 兜底,默认 = max_ms);返回 rx_bytes = 本次增量
CLI 超时与断连噪声(2026-10-10) wait-idle 未显式带 --timeout 时按 max_ms/1000 + 5 自动推导(旧版固定 5s 会在 daemon 还在等时掐断连接);handler 侧 ConnectionError(客户端已断开)不再往 daemon.log 打 traceback(本机曾累积 16 次);daemon.log 超 1MB 在拉起新实例前原地保尾 256KB
配置读取(2026-10-10) 按 utf-8-sig 读(容忍 BOM,与 run_boot_test.py 同口径);文件缺失 / JSON 语法错 / baudRate·httpPort 非整数不再静默回退成"没配端口",错误文本进 configError,由 status / ports / open 显式回显

7. 集成与迁移(/serial-boot-test 切换)

7.1 新 /serial-boot-test 流程(run_boot_test.py 采集步改写,退出码约定不变:0/1/2/3)

flowchart LR
    A["open(自动拉起 daemon;幂等)"] --> B["clear → gen=N"]
    B --> C["stlink-flash skill(stlink_flash.py flash:ST-Link 烧录+复位,端口全程打开)"]
    C --> D["wait-idle:rx 静默 8s(-SettleMs)或超时 30s(-MaxWaitMs)"]
    D --> E["提取本代际 [RX] 行,剥 [RX] 时间戳前缀 → boot txt"]
    E --> F["verify_log.py(标记匹配)"]
  • 对比旧流程:6 个采集窗口参数 + JSON 解析 + EOF 重试 全部删除;采集 = 端口常开 + 读文件
  • verify_log.py 与纯文本日志契约一致(2026-09-20 随 /serial-boot-test 迁移 Python,判定语义不变)
  • 参数保留:-Port -BaudRate -FlashCmd -Required -Forbidden -OutDir;删除 -DurationMs 等窗口参数,新增 -SettleMs(静默判据,默认 8000——boot 任务间有 3~4s 自然静默间隙,2000 会截断)-MaxWaitMs(默认 30000)

7.2 配套变更清单

对象 变更
requirements.txt 增 pyserial(保持纯 ASCII 注释)
.gitignore 增 .dsh/skills/serial-assistant/scripts/state/
opencode.json(仓库根) 删除 mcp.serial 段(指向缺失二进制,已失效)
测试说明/serial-mcp-server_串口调试.md 已删除(内容精华并入本文;工具历史见 §12)
AGENTS.md "串口调试"节改写为 serial_tool(配置源仍 serial_config.json);"自动化测试"节同步新流程
.opencode/command/test.md Step 0/2/Notes 改写(前置检查 = serial_tool 可用性 + ports;流程 = §7.1)
docs/测试说明/流程与规范/自动化测试流程.md 采集机制描述同步(去 serial-mcp-server 表述)
.opencode/serial_config.json 结构不变(可选增 httpPort);note 更新

8. 非目标与 v2 候选

v1 明确不做:多端口并发、MCP 封装、协议级 HEX 分析、硬件流控(RTS/CTS)、波特率自适应、GUI。 v2 候选:多端口(每口一 daemon 实例或子会话)、MCP wrapper(如需工具面板呈现)、按 tag 过滤的日志订阅(WebSocket/SSE)。

9. 测试与验收

9.1 无头自检(selftest,0=PASS)

行拆分器(CRLF/残行冲刷/纯二进制窗口)、解码回退链(UTF-8/GBK/HEX)、截头算法(合成大文件 → ftruncate 语义)——纯进程内,无需硬件。

9.2 端到端验收(逐项过)

# 用例 判据
A1 ports 列表含 CH340 端口,configured 标注正确
A2 端口被占用(另一进程先开 COM6)→ open ≤1s 返回 ok:false(占用),无挂起
A3 open→status→send→log→clear→close 全走一遍 全 ok:true;日志含 TX 行与 cleared 标记;gen 递增
A4 stop 后强杀 daemon(taskkill)→ 再发任意命令 自动拉起成功,open 幂等路径无 CH340 EOF 错误
A5 日志灌至 >20MB 自动截头至 ~10MB,写入连续、无重开端口,[SYS] truncated 行存在
A6 运行中拔 CH340 → status daemon 存活,state=error;重插 + open 恢复
A7 /serial-boot-test E2E ×3(含一轮"上轮端口未关"场景) 三轮全 PASS(Required 标记齐、无 Forbidden),boot 日志完整
A8 selftest 0=PASS

10. 实施步骤(按序执行)

# 步骤 产物 验证
步骤 1 requirements.txt 增 pyserial + .venv 安装 requirements.txt pip show pyserial 有版本 ✅
步骤 2 daemon 核心:HTTP 服务 + 串口读线程 + 行日志写(含 clear/截头/DTR 策略) .dsh/skills/serial-assistant/scripts/serial_tool.py(daemon 部分) start→status→send→stop 手动走通 ✅
步骤 3 CLI 全子命令 + 自动拉起 + 超时契约 + selftest serial_tool.py(CLI 部分) selftest 0=PASS + 命令走查 ✅
步骤 4 验收 A1–A6、A8 测试通过记录 A1–A5/A8 PASS ✅(本机无 CH340,A6 待硬件)
步骤 5 /serial-boot-test 集成:run_boot_test.py 采集步改写 + 参数收敛 run_boot_test.py 负路径(端口缺失→exit 2)+ 假烧录全链路 + verify 正/负判定 ✅;A7 正路径待 CH340 接入
步骤 6 文档/配置迁移(§7.2 全清单) docs + 配置 全库 grep 无 serial-mcp-server 残留引用(本文 §12 工具历史除外) ✅

提交:全部完成后单提交(pre-commit 门禁;纯宿主侧 Python,不涉及 Keil 构建)。

11. 风险与开放问题

# 项 策略
O1 换机器缺 CH340 驱动 → ports 无目标口 ports 返回带提示("未找到 CH340,请装驱动");文档列驱动要求
O2 截头瞬间 Agent 恰在读文件 → 视图错位 已知可接受:Agent 读尾部;log 命令经 daemon 读更稳
O3 daemon 强杀瞬间丢 ≤1 行(内存残行) 可接受(debug 日志场景);close 路径保证冲刷
O4 HTTP 端口 18765 与其他服务冲突 .serialtest/config.json httpPort 可改
O5 TX 用途(本工程 USART1 单向输出) 保留能力,面向未来交互/RS485 调试;v1 仅保证进日志
O6 daemon 常驻内存(Python 进程 ~20MB) 可接受;stop 即释放

12. 工具历史

代 方案 结局
1 Serial Studio MCP(GUI 依赖、导出文件定位问题) 已清理(2026-09 前)
2 Adancurusul/serial-mcp-server(Rust,MCP+CLI) 被本工具取代:二进制绑定用户档案(本机缺失)+ 无历史缓冲(采集窗口仪式)
3 serial_tool(本文,Python 仓库内) 当前方案