Files
STM32F4-Base/docs/测试说明/串口助手设计说明.md
T
wandering1 0f7b5a87fb chore(编号): 存量编号全量转语义短语,门禁识别面与语料同批重打
登记簿、文档与代码注释里的存量字母加数字编号(N-x / S-x / T-x / E-x / A-x / 卡号)
一律改写为语义短语,净删 2,028 处引用;删除基线与待确认、并行分道方案、排期详述、
采集保护收尾 4 篇(内容已并入 docs/任务规划/README 与登记簿)。

门禁机器与三张账同批重打:check_ids.py 新增变更记录行豁免与第二根护栏
undecidable_total_ceiling;check_test_traceability.py 只加 stdout/stderr UTF-8
reconfigure(TC_RE 未动);check_cubemx_survival.py 的 --write 固定 newline=LF;
id_registry / id_aliases(+19 条 retired-to-phrase)/ id_known_backlog 重打。

test/ 56 个文件同步 TC 标签语义化,其中 18 个 TU 改了 CHECK / printf 里的标签文字,
是本批唯一改变构建产物的文件集合。

为什么合成一笔、不拆四笔(实测撞闸,非偏好):pre-commit 跑钩子前会 stash 未 staged
部分,拆开后中间提交必然是「新规则 + 旧语料」——实跑可判定 85.15% < 基线 86.00%、
不可判定 1,453 > 上限 1,166,编号门禁直接拦下。编号识别面与它描述的语料只能同码移动。

实跑基线(工作区全量):754 文件 / 8,230 处编号字面量 / 可判定 7,079 = 86.01%
(基线 86.0%,容差 0.1pp)/ 不可判定绝对数 1,151(上限 1,166)/ 校验 1-6 错误级 0 /
告警 = 悬空 2(App/algo/freq.c 的 S2,属 §4.4 同号不同义)+ 孤儿 50。

基线从 84.0 抬到 86.0 的理由见 docs/门禁说明/id-check.md §7.6:本批真删了 2,028 处
引用,可判定面随真实内容上升,不是把分母做小。同批新增排除 docs/硬件平台/ 语料,使
识别面少 264 处——芯片手册位号与网表料号不属编号空间(registry 有 excluded_paths_note)。

⚠ 余量只剩 0.11pp(约 10 处)与绝对数 15 处:把「可判定的引用」改成散文会立刻撞第二
根护栏。另遗留一项:id_aliases 里 retired 的 N / S / T / A 别名没有 scope(U1~U5 有),
今后有人新写一个 A-4,校验 3 不会响,只能靠人,建议下批补 scope。

固件不变证据:115 个 .c/.h 剥注释并归一字符串后与 HEAD 逐字节全等,残留代码差异 0;
App / Src / Drivers 的字符串字面量零改动。@build 0 错误 0 警告;40/40 主机套件、2,889
断言全通过;覆盖率 line 89.2% / func 92.6%;pre-commit 48 钩子 --all-files 全绿;
check_doc_links --all 通过(176 文档,章节引用无法判定 0);逐字节卫生核过:14 个带
BOM 文件与 HEAD 一致、0 个 CR、0 个 U+FFFD,字符串字面量纯 ASCII。

.cubemx_survival_baseline.json 的 watchdog-module 哈希 56bf27 变 2bb051 属合法:
Src/freertos.c 的 USER CODE 区内只有注释文字随编号改写,代码未变。

同批带上体积同码凭证与 RAM 台账行(@build 之后盖戳):RW_IRAM1 79,008 B / 60.3%,
RW_CCM 57,376 B / 87.9%,阈值 92%。
2026-10-26 12:28:14 +08:00

275 lines
24 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 工具不可用、@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 | @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 | @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 + "日志文件即接口"
```mermaid
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 静默(@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` 返回示例
```json
{
"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`,@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. 集成与迁移(@test 切换)
### 7.1 新 @test 流程(`run_boot_test.py` 采集步改写,退出码约定不变:0/1/2/3)
```mermaid
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 随 @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 | **@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 | @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 仓库内)** | 当前方案 |
## 13. 变更记录
| 日期 | 变更 |
|---|---|
| 2026-09-15 | v1.1 实施完成(S1–S6)。实施中实测发现并修复 4 个设计文档未覆盖的 Windows 实现级缺陷:① `SO_REUSEADDR` 在 Windows 允许多个活跃 socket 绑同端口 → HTTP 服务改**独占绑定**(`SO_EXCLUSIVEADDRPLACE`,发行版未导出时用数值 0x40 兜底)+ restart 强制确认旧实例死亡,防"already_running"假成功留下旧代码;② 写句柄 `"ab"` 追加模式在 Windows **禁读** → 溢出截头的"读尾部"必崩,改 `"a+b"`(自测用独立句柄故未暴露,真实 daemon 溢出路径已 E2E 验证);③ Windows 对**不存在**的 COM 口常报 Access Denied → 误报"port busy",改以 `list_ports` 枚举结果为"不存在/占用"判定准绳;④ 静默判据默认 2s 会截断 boot(任务间 3~4s 自然静默间隙),`-SettleMs` 默认改 8000ms。验收状态:A1–A5/A8 PASS(本机无 CH340,用 COM1 替代口验证;A5 溢出用进程内驱动真实 writer_loop 验证);A6/A7 正路径待硬件接入补验 |
| 2026-10-10 | **第一批可靠性修复**(5 项,均实测复现后修,`selftest` 加 T5/T6 共 6 个回归用例,16/16 PASS):① `wait_idle` `no_data` 改按**本次增量**判——旧实现比 daemon 累计 `rx_bytes` 是否为 0,常驻 daemon(本机 rx_bytes=1.2MB)下板子全程静默也返回 `idle:true`,@test 的 no_data 拦截形同虚设;同时加**首字节门**(首字节到达前不启动 settle 计时,`--first-timeout-ms` 兜底)并把返回的 `rx_bytes` 改为本次增量;② `log` 改**先过滤后截尾**(旧顺序 + 默认 `--tail 50` = 开机 marker 必然查不到,即 AGENTS.md"log --grep 实测不可信"的真根因),并加 `-i` / `--regex` 与 `scanned`/`matched` 计数;③ `wait-idle` 的 CLI 超时按 `--max-ms` 自动推导(旧版固定 5s 掐断长等待 → daemon.log 累积 16 次 `ConnectionAbortedError` traceback,现 handler 侧静音 `ConnectionError`);同时 CLI 不再把**超时**误判成"daemon 不可达"——旧实现超时与拒连走同一分支,一次 1s 超时就会额外拉起一个 daemon(新实例绑不上 HTTP 口,打 `HTTP port busy` 后退出;daemon.log 里 36 条 `listening` 大半是这种抖动攒出来的),现超时快速失败并提示调大 `--timeout`,只有真·拒连才自动拉起(`selftest` T7 锁);④ **§13 09-15 那条"独占绑定"其实是坏的**:`SO_EXCLUSIVEADDRPLACE` 是拼错的名字(真名 `SO_EXCLUSIVEADDRUSE`,值 **-5**),数值兜底 `0x0040` 是无效选项 → `setsockopt` 抛 `WinError 10042` 被 `except` 吞掉,独占从未生效;现改真名 + 失败打 WARN + `status.daemon.exclusive_bind` 可验(实测 true);⑤ 配置读取改 `utf-8-sig`(旧版遇 BOM 静默回退成"没配端口")并把解析错误显式回显;另加 `daemon.log` 超 1MB 原地保尾 256KB。顺带纠正一条误诊:进程列表里两个 `--serve` 是 **venv stub + 基解释器父子对**,不是"多 daemon 并存" |
| 2026-10-10 | **第三批:采集卫生 + 查询面**(`selftest` 加 T8/T9/T10,23/23 PASS):① 无换行残行 **64KB 封顶**(旧实现二进制垃圾无限攒内存,且把后续正常行拼成一条读不出的长行);② `open`/`close`/`clear` 作为**会话边界**丢弃残行(`splitter_gen` 计数,串口线程下轮生效,避免跨线程直接改缓冲)——旧实现上一段会话被复位截断的半行会粘进新会话首行;③ `cap_truncate` **按行边界**截头(旧实现按字节切 → 截出来的首行是半行,破坏"只含完整行"不变量);④ `open` 幂等分支不再吞 `--dtr/--rts`:不带参数 = **保持当前 DTR/RTS**(旧实现把"没给"当 False,重开即静默拉低线路),显式给则现场应用并回显;⑤ `ports` 回 `configured_present`/`hint`(单一候选才给,多候选不猜),`open` 的 `port not found` 也带 `[hint: ...]`;⑥ `status` 回 `rx_age_ms` |