diff --git a/.gitignore b/.gitignore
index d0373114..11953678 100644
--- a/.gitignore
+++ b/.gitignore
@@ -111,3 +111,6 @@ docs/images/*.png
# Python venv (unified env, see AGENTS.md "Python 环境"; rebuild via requirements.txt)
.venv/
+
+# Serial assistant (tools/serial_tool) runtime state (daemon log/meta, local runtime artifacts)
+tools/serial_tool/state/
diff --git a/.opencode/command/test.md b/.opencode/command/test.md
index e5298ec8..bbc0a0a9 100644
--- a/.opencode/command/test.md
+++ b/.opencode/command/test.md
@@ -6,21 +6,21 @@ agent: build
Run the full automated test loop: **build → download → capture serial debug → verify markers → report/iterate**.
Prerequisites (checked in step 0):
-- `serial-mcp-server` CLI installed at `%LOCALAPPDATA%\serial-mcp-server\serial-mcp-server.exe` (Adancurusul/serial-mcp-server, v0.3.0).
+- Repo `.venv` exists with `pyserial` installed (rebuild per AGENTS.md "Python 环境" if missing).
- ST-Link probe connected; target powered. The USB-serial (CH340) may stay idle while flashing — ST-Link download does not conflict with the serial port.
## Procedure
-### Step 0 — Pre-flight: serial-mcp-server binary + port
+### Step 0 — Pre-flight: serial assistant + port
-Verify the capture binary and target port exist:
+Verify the serial assistant works and the target port exists:
```powershell
-Test-Path "$env:LOCALAPPDATA\serial-mcp-server\serial-mcp-server.exe"
-& "$env:LOCALAPPDATA\serial-mcp-server\serial-mcp-server.exe" list-ports --json
+& '.venv\Scripts\python.exe' 'tools/serial_tool/serial_tool.py' selftest # SELFTEST PASS
+& '.venv\Scripts\python.exe' 'tools/serial_tool/serial_tool.py' ports # JSON: 端口列表 + configured 标注
```
-Read `.opencode/serial_config.json` for the authoritative `portName` (COM6) and match it in the list. If the binary is missing, build it from source (see `docs/测试说明/serial-mcp-server_串口调试.md`).
+Read `.opencode/serial_config.json` for the authoritative `portName` (COM6) and confirm it appears in the `ports` output. If the port is missing, the CH340 driver or the cable is the problem — do not continue.
### Step 1 — Build verify
@@ -34,41 +34,41 @@ Interpretation (per AGENTS.md): exit code 0 = PASS (0 errors, 0 warnings). 1/2+
### Step 2 — Flash + capture boot log (one orchestrated call)
-Use `test/run_boot_test.ps1` which starts the capture (waits for first byte),
-then flashes, then collects and verifies — all in one call so the capture never
-times out before flashing:
+Use `test/run_boot_test.ps1` which opens the port + clears the log (arming),
+flashes, waits for RX silence, extracts the boot log and verifies — all in one
+call. The serial daemon buffers the full stream in `tools/serial_tool/state/serial.log`,
+so nothing can be lost between steps:
```powershell
powershell -ExecutionPolicy Bypass -File "test\run_boot_test.ps1" `
- -Port COM6 -BaudRate 115200 `
- -DurationMs 30000 -InitialTimeoutMs 90000 -IdleTimeoutMs 8000 -MaxBytes 65536 `
+ -SettleMs 8000 -MaxWaitMs 30000 `
-FlashCmd "MDK-ARM\download.bat" `
-Required "App init OK;Network Init;net init: OK" `
- -Forbidden "FAIL;ERROR;HardFault" `
- -OutDir "C:\Users\16241\AppData\Local\Temp\opencode"
+ -Forbidden "FAIL;ERROR;HardFault"
```
-Exit codes: **0 = PASS**, 1 = FAIL (marker miss / forbidden hit), 2 = capture/flash/parse error.
+`-Port` defaults to `.opencode/serial_config.json`; override only if needed.
+Exit codes: **0 = PASS**, 1 = FAIL (marker miss / forbidden hit), 2 = capture/flash error, 3 = preflight/usage error.
### Step 3 — Iterate
- **PASS** → report the verdict and which markers matched; done.
- **FAIL** → the `[MISS]`/`[HIT]` lines + the log tail show what went wrong. Fix the code, then loop again from **Step 1**. Do not skip the build step.
-## Alternative: manual MCP capture (interactive debug)
+## Alternative: interactive serial debug
-For interactive command/response (not boot-log capture):
+For interactive command/response (not boot-log capture), drive the serial assistant directly:
-1. `serial_list_ports` → match `portName` from `.opencode/serial_config.json` (COM6).
-2. `serial_open` (port=COM6, baud_rate=115200) → note the returned `connection_id`.
-3. `serial_write` / `serial_read` (connection_id, timeout_ms) for command-response.
-4. `serial_close` to release the port.
+1. `ports` → confirm the target port.
+2. `open --port COM6 --baud 115200` (idempotent; auto-starts the daemon).
+3. `send "text"` / `send --hex 0D0A` to transmit; `log --tail 20` (or Read the log file directly) to see RX history.
+4. `close` when done (the daemon itself keeps running; `stop` only if the port must be freed for another tool).
## Notes
- **串口端口**:以 `.opencode/serial_config.json` 为准(本机为 COM6)。每次连接前先读它。
-- **⚠️ 无历史缓冲**:serial-mcp-server 的 `read` 只能读调用后的字节。抓开机日志必须走 `run_boot_test.ps1`(先采集等 first-byte → 再烧录触发)。
-- **⚠️ EOF 陷阱**:COM 刚释放立即重开会报 `UnexpectedEof`(tokio-serial 读 0 字节)。`serial_capture.ps1` 内置自动重试;别裸跑 CLI capture。
-- **参数**:`IdleTimeoutMs` 需 ≥ boot 自然静默间隙(本机 ~3-4s,用 8000);`InitialTimeoutMs` 需覆盖编译+烧录耗时(用 90000)。
+- **全量历史 = 日志文件**:daemon 把 RX/TX/SYS 持续写入 `tools/serial_tool/state/serial.log`,直接 Read/Grep 即可,无需采集窗口仪式。
+- **⚠️ 静默判据**:boot 任务间有 ~3-4s 自然静默间隙,`-SettleMs` 必须 ≥ 8000(默认已如此),否则日志截断。
+- **daemon 生命周期**:任何命令自动拉起 daemon(无需显式 start);`restart` 保证加载最新代码;强杀 daemon 后下条命令自动恢复(串口需重新 `open`)。
- UI (LVGL) task `ui_task` 已有 `UI port init: OK` / `UI menu init: OK` 打印,可用 `-Required "UI port init: OK;UI menu init: OK"` 自动回归 UI 初始化。
-- Full capture/analysis details: `docs/测试说明/serial-mcp-server_串口调试.md` and `docs/测试说明/自动化测试流程.md`.
+- Full command contract / daemon behavior / known traps: `docs/测试说明/串口助手设计说明.md` and `docs/测试说明/自动化测试流程.md`.
diff --git a/.opencode/serial_config.json b/.opencode/serial_config.json
index 7b96eef6..92307f32 100644
--- a/.opencode/serial_config.json
+++ b/.opencode/serial_config.json
@@ -2,5 +2,5 @@
"portName": "COM6",
"portHint": "USB 串行设备",
"baudRate": 115200,
- "note": "MCU 调试串口(USART1 → CH340 → 本机枚举为 COM6)。执行 @test 前先读取本文件,再用 serial_list_ports 匹配 portName 选择端口;如本机端口变化,请人工修改此文件。"
+ "note": "MCU 调试串口(USART1 → CH340 → 本机枚举为 COM6)。Python 串口助手(tools/serial_tool)与 @test 均以此文件为端口/波特率默认值(单一配置源);执行前先读取。如本机端口变化,请人工修改此文件。"
}
diff --git a/AGENTS.md b/AGENTS.md
index ba5985bf..81d2f5e3 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -113,20 +113,23 @@ Src/main.c → CubeMX 外设初始化 → app_main_init → osKernelStart
- `docs/问题记录/SD2506_Trap_Records.md` — SD2506 RTC 已知陷阱
- `docs/测试说明/CH395F_Test_Guide.md` — 网络测试说明(含构建模式与执行顺序)
-## 串口调试(serial-mcp-server MCP/CLI)
+## 串口调试(Python 串口助手 serial_tool)
-通过 **Adancurusul/serial-mcp-server** 自主读取/控制 **USART1 串口**(PA9 TX / PA10 RX,115200bps)的打印流。
+通过仓库内置 **Python 串口助手**(`tools/serial_tool/serial_tool.py`,常驻 daemon + 薄 CLI)自主读取/控制 **USART1 串口**(PA9 TX / PA10 RX,115200bps)的打印流。
+- **统一入口**:`& '.venv\Scripts\python.exe' 'tools/serial_tool/serial_tool.py' <命令> [参数]`(daemon 未运行自动拉起;全部命令带硬超时 5s,JSON 输出,exit 0/1/2/3)
+- **全量历史 = 日志文件** `tools/serial_tool/state/serial.log`(RX/TX/SYS 时间戳行;Agent 直接 Read/Grep 读文件,无采集窗口仪式);20MB 上限自动截头保尾 10MB
+- **常用命令**:`ports`(枚举+配置口标注)/ `open`(幂等,不存在/占用 ≤1s 报错)/ `send`(位置参数文本 或 `--hex 0D0A`,`--nl` 加 CRLF)/ `status`(全状态)/ `log --tail N --grep P` / `clear`(代际+1)/ `wait-idle --settle-ms 8000 --max-ms 30000` / `close` / `start|stop|restart` / `selftest`(无头自检)
- **串口端口单一配置源:`.opencode/serial_config.json`**(当前本机为 `COM6`)。端口变化只改这个文件。
-- 用法、采集窗口模式、已知陷阱等详见 **`docs/测试说明/serial-mcp-server_串口调试.md`**
+- 命令契约、日志行格式、daemon 行为、已知陷阱详见 **`docs/测试说明/串口助手设计说明.md`**
## 自动化测试(AI 迭代闭环)
-改完代码后运行 **`@test`** 可自动执行完整闭环:**构建验证 → 串口捕获武装(serial-mcp-server 采集窗口)→ 烧录(`MDK-ARM/download.bat`,下载后 Reset & Run)→ 解析捕获 JSON → `test/verify_log.ps1` 标记校验 → PASS/FAIL 判定并迭代**。
+改完代码后运行 **`@test`** 可自动执行完整闭环:**构建验证 → 开串口 + 清空日志(serial_tool,daemon 常驻,全程不关端口)→ 烧录(`MDK-ARM/download.bat`,下载后 Reset & Run)→ `wait-idle`(等 RX 静默 8s 或超时 30s)→ 提取 `[RX]` 行 → `test/verify_log.ps1` 标记校验 → PASS/FAIL 判定并迭代**。
-- **推荐一键命令**:`test/run_boot_test.ps1` = 采集(含 EOF 重试)→ 烧录 → 提取日志 → verify 全流程,退出码 0=PASS / 1=FAIL / 2=采集或烧录错误 / 3=日志文件错误
+- **推荐一键命令**:`test/run_boot_test.ps1` = open → clear → 烧录 → wait-idle → 提取日志 → verify 全流程,退出码 0=PASS / 1=FAIL / 2=采集或烧录错误 / 3=用法/预检错误
- 判定标准:`@test` 内部先 `@build`(0 错误 0 警告),再 `@download`(退出码 0),日志用 `verify_log.ps1` 校验(默认 Required:`App init OK`/`Network Init`/`net init: OK`;Forbidden:`FAIL`/`ERROR`/`HardFault`)
-- **采集必须在烧录前武装**(采集进程先打开 COM 等 first-byte,烧录触发复位后才抓得到开机日志);`run_boot_test.ps1` 的 `-FlashCmd` 分支已保证顺序
+- daemon 常驻全量日志文件:开串口 + clear 必须在烧录**前**完成(`run_boot_test.ps1` 已保证顺序);旧 serial-mcp-server 的"采集窗口/first-byte/EOF 重试"仪式已整体移除
- 每次按模块定制标记(网络/存储/RTC/ADC/RS485)见 `docs/测试说明/自动化测试流程.md`
- UI 已有 DBG 打印(`UI port init: OK` / `UI menu init: OK` / `UI net sink: started`),可用 `-Required "UI port init: OK;UI menu init: OK"` 自动回归 UI 初始化;屏幕内容仍需人工目检
diff --git a/README.md b/README.md
index b9985ed4..d83b010b 100644
--- a/README.md
+++ b/README.md
@@ -140,7 +140,6 @@ STM32F4-Base/
├── test/ # 测试代码与脚本
│ ├── run_boot_test.ps1 # 自动化启动测试
│ ├── verify_log.ps1 # 日志校验脚本
-│ ├── serial_capture.ps1 # 串口捕获脚本
│ ├── test_config.c/h # 测试任务配置
│ ├── ch395f_test_task.c/h # CH395F 硬件测试
│ ├── gd5f_test_task.c/h # NAND Flash 测试
@@ -175,7 +174,8 @@ STM32F4-Base/
│ ├── tests/ # 无头回归脚本 + 黄金参考(菜单像素回归 / 内容页黄金参考)
│ └── SDL2/ # SDL2 2.32.10 预编译库
├── tools/ # 开发工具
-│ └── font_gen/ # 中文字体生成工具
+│ ├── font_gen/ # 中文字体生成工具
+│ └── serial_tool/ # Python 串口助手(常驻 daemon + CLI,见 docs/测试说明/串口助手设计说明.md)
├── ui_cn_chars.txt # 中文字体字符集
├── requirements.txt # Python 依赖清单(统一 .venv 环境,见 AGENTS.md「Python 环境」)
├── STM32F407-Demo.ioc # CubeMX 工程文件
@@ -296,10 +296,10 @@ powershell -ExecutionPolicy Bypass -File test\run_boot_test.ps1
### 串口调试
-通过 `serial-mcp-server` 自主读取 USART1 串口打印:
+通过仓库内置 Python 串口助手(`tools/serial_tool/`)自主读取 USART1 串口打印:
-- 端口配置:`.opencode/serial_config.json`
-- 详见 `docs/测试说明/serial-mcp-server_串口调试.md`
+- 端口配置:`.opencode/serial_config.json`(单一配置源)
+- 详见 `docs/测试说明/串口助手设计说明.md`
## 开发规范
diff --git a/docs/测试说明/serial-mcp-server_串口调试.md b/docs/测试说明/serial-mcp-server_串口调试.md
deleted file mode 100644
index 08140314..00000000
--- a/docs/测试说明/serial-mcp-server_串口调试.md
+++ /dev/null
@@ -1,106 +0,0 @@
-# serial-mcp-server 串口调试指南
-
-通过 **Adancurusul/serial-mcp-server**(Rust,MIT)自主读取/控制 **USART1 串口**(PA9 TX / PA10 RX,115200bps)的打印流(MCU printf)。
-
-## 项目与安装
-
-- 仓库:https://github.com/Adancurusul/serial-mcp-server
-- 本机安装:`%LOCALAPPDATA%\serial-mcp-server\serial-mcp-server.exe`(自源码 `cargo build --release` 编译,v0.3.0)
-- 两种形态:
- - **MCP stdio server**(`serve` 子命令)—— opencode.json 中 `mcp.serial`,工具带 `serial_` 前缀
- - **CLI**(`list-ports` / `open` / `read` / `write` / `close` 等)—— 适合脚本化/一次性抓取
-- **串口端口单一配置源:`.opencode/serial_config.json`**(当前本机 `COM6` / 115200)。端口变化只改这个文件
-
-## 核心能力边界(重要)
-
-**⚠️ 该项目没有"读取历史消息/历史缓冲"功能**(对比 qarnet/serial-mcp 有常驻环形缓冲):
-
-- MCP `read` 只能读到**调用之后**到达的字节,之前的数据直接丢弃
-- 连接打开期间**没有后台 ring buffer**(源码 `src/serial/connection.rs` 只持有流,无历史)
-- 抓开机日志必须用 **采集窗口模式**(`duration_ms` + `start_trigger=first-byte`):**先启动采集等第一字节,再触发设备输出**,否则错过
-
-## 采集窗口模式(capture)
-
-这是该项目抓完整连续输出的唯一方式,等价于"有边界的持续读"。
-
-### CLI 参数
-
-```
-serial-mcp-server read --port COM6 --baud 115200 \
- --duration-ms 30000 \ # 采集窗口时长(从触发点起算)
- --start-trigger first-byte \ # 触发方式:first-byte(默认)/ immediate
- --initial-timeout-ms 90000 \ # first-byte 模式最多等多久来第一字节(超时空返回)
- --idle-timeout-ms 8000 \ # 采集开始后连续静默多久提前结束
- --max-bytes 65536 \ # 累计字节硬上限
- --json
-```
-
-### 结束原因(completion_reason)
-
-| 值 | 含义 |
-|---|---|
-| `initial_timeout` | 等 first-byte 超时,返回空(不算错误) |
-| `duration_elapsed` | 从触发点采满 `duration-ms` |
-| `idle_timeout` | 采集中途静默超过 `idle-timeout-ms` 提前收工 |
-| `max_bytes` | 累计达 `max-bytes` |
-
-### 返回结构
-
-`read` 对象含:`data`(合并完整字节)、`bytes_read`、`status`(ok/timeout)、`completion_reason`、`waited_ms`(等到首字节耗时)、`elapsed_ms`(采集时长)、`chunks[]`(每块 offset/bytes_read/waited_ms/elapsed_ms)。
-
-## 抓取 MCU 开机日志(@test 核心场景)
-
-**顺序必须:先启动采集(打开 COM 等 first-byte)→ 再烧录触发复位 → 采集自动抓取。**
-
-```powershell
-# 一键:采集(含 EOF 重试)→ 烧录 → 提取日志 → verify 校验(在仓库根目录执行,路径用仓库相对路径)
-powershell -ExecutionPolicy Bypass -File "test\run_boot_test.ps1" `
- -Port COM6 -BaudRate 115200 -DurationMs 30000 -InitialTimeoutMs 90000 `
- -IdleTimeoutMs 8000 -MaxBytes 65536 `
- -FlashCmd "MDK-ARM\download.bat" `
- -Required "App init OK;Network Init;net init: OK;PHY: 100M FULL" `
- -Forbidden "FAIL;ERROR;HardFault"
-```
-
-退出码:0=PASS / 1=FAIL / 2=采集或烧录错误。
-
-### 底层脚本(`test/serial_capture.ps1`)
-
-- **`-FlashCmd` 分支**:一个调用内完成 稳定启动采集 → 烧录 → 等采集结束,避免工具调用间隔导致采集超时
-- **EOF 自动重试**:COM 被上一个进程释放后立即重开会报
- `UnexpectedEof: serial stream returned zero bytes during capture`(tokio-serial 读 0 字节,第三方项目 capture.rs 视为致命错误)。脚本检测到后退避 1.5s 重开,最多 3 次
-- 采集稳定启动判据:进程存活超过 `-StabilityCheckMs`(默认 1.5s)
-
-### 参数经验值(本机固件)
-
-| 参数 | 值 | 理由 |
-|---|---|---|
-| `idle-timeout-ms` | 8000 | boot 阶段有自然静默间隙(defaultTask 与 netTask 之间约 3~4s),太小会在网络初始化前提前结束 |
-| `initial-timeout-ms` | 90000 | 容错:烧录编译+下载耗时可能 40s+,采集必须等得住 |
-| `duration-ms` | 30000 | 覆盖完整开机流程(boot ~5s + net init ~3s + FTP 延迟 7s) |
-
-## 交互式调试(MCP)
-
-用于主动下发命令、读取响应(非开机日志抓取):
-
-1. `serial_list_ports` → 找 `COM6`(读 `.opencode/serial_config.json` 确认)
-2. `serial_open`(port=COM6, baud_rate=115200)→ 返回 `connection_id`(UUID)
-3. `serial_write`(connection_id, data, encoding=utf8/hex/base64)
-4. `serial_read`(connection_id, timeout_ms=1000)→ 单次读,只能读到调用后的字节
-5. `serial_close`
-
-MCP `read` 也支持采集窗口字段:`duration_ms`、`start_trigger`、`initial_timeout_ms`、`idle_timeout_ms`(传 `duration_ms` 即进入 capture 模式)。
-
-## 常见陷阱
-
-| 现象 | 原因/处理 |
-|---|---|
-| `UnexpectedEof: serial stream returned zero bytes` | COM 刚被释放立即重开(tokio-serial 读 0 字节)。用 `serial_capture.ps1`(含重试)或间隔几秒再开 |
-| 抓到日志缺少 `Network Init` | `idle-timeout-ms` 太小,在网络初始化前的静默间隙提前结束;调到 8000ms |
-| 烧录后无数据 | 采集必须在烧录**前**启动;`initial-timeout-ms` 太短导致采集先超时 |
-| `Error: Connection ID ... not found` | connection_id 是每次 open 生成的 UUID,读/写/关都要用它 |
-| 端口被占用 | 先 `serial_close` 释放;Keil ST-Link 下载不占 COM,可并存 |
-
-## 工具历史
-
-本项目是 Serial Studio MCP 的替代方案(更轻量、无 GUI 依赖、无导出文件定位问题)。Serial Studio 相关配置、bridge 与文档已全部清理。
diff --git a/docs/测试说明/串口助手设计说明.md b/docs/测试说明/串口助手设计说明.md
new file mode 100644
index 00000000..22f6a000
--- /dev/null
+++ b/docs/测试说明/串口助手设计说明.md
@@ -0,0 +1,266 @@
+# Python 串口助手(serial_tool)设计说明
+
+> **版本**:v1.1(需求 + 设计 + 实施记录)
+> **日期**:2026-09-15
+> **状态**:**已实施**(S1–S6 完成;本机验收 A1–A5/A8 通过,A6/A7 正路径待 CH340 硬件接入后补验)
+> **关联文档**:`自动化测试流程.md` · `AGENTS.md`(串口调试/自动化测试节) · `.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.ps1` 采集步改写 + `verify_log.ps1` 兼容 + 旧文档/配置迁移 | 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
纯文本,Agent 直接读")]
+ D --> M[("state/meta.json
pid/gen/版本")]
+ D --> L[("state/daemon.log
daemon 自身 stderr")]
+```
+
+- **单文件实现**:`tools/serial_tool/serial_tool.py`(stdlib + pyserial,约 1000 行);解释器统一 `.venv\Scripts\python.exe`
+- **Agent 调用式**:`& '.venv\Scripts\python.exe' 'tools/serial_tool/serial_tool.py' <命令> [参数]`(任意 cwd;state 目录按脚本位置解析,与 cwd 无关)
+- **历史 = 日志文件**:Agent 用 Read/Grep 直接看历史,无需专门 API(`log` 命令只是便捷封装)
+- **配置沿用** `.opencode/serial_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` | 读日志(文件尾) | `--tail N`(默认 50;`0` = 全部)`--grep P` | `{"ok":true,"gen":8,"lines":["…"]}` |
+| `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)
+
+| 文件 | 用途 |
+|---|---|
+| `tools/serial_tool/state/serial.log` | RX/TX/SYS 全量日志(Agent 直接读) |
+| `tools/serial_tool/state/meta.json` | daemon 元数据(pid / 版本 / 当前 gen) |
+| `tools/serial_tool/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 读文件永不见半行 |
+| 分行 | 按 `\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` 只能截尾;单写者线程内执行,无并发风险)
+- **禁止** rename 轮转(Windows 下写句柄跟随被 rename 的文件)与重开文件/端口(CH340 释放后立即重开 → 读 0 字节 EOF 陷阱)
+- 截头后写 `[SYS] … log truncated to 10MB (cap=20MB)`;文件头内容可能随时消失 → **Agent 读日志应看尾部**(`log --tail` 或直接读文件末段)
+
+## 6. daemon 行为规范
+
+| 项 | 规定 |
+|---|---|
+| 单实例 | HTTP 端口(默认 18765,`serial_config.json` 的 `httpPort` 可改)绑定即锁;端口被占 = 已有实例 |
+| 拉起方式 | 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` 幂等路径) |
+
+## 7. 集成与迁移(@test 切换)
+
+### 7.1 新 @test 流程(`run_boot_test.ps1` 采集步改写,退出码约定不变:0/1/2/3)
+
+```mermaid
+flowchart LR
+ A["open(自动拉起 daemon;幂等)"] --> B["clear → gen=N"]
+ B --> C["MDK-ARM\download.bat(ST-Link 烧录+复位,端口全程打开)"]
+ C --> D["wait-idle:rx 静默 8s(-SettleMs)或超时 30s(-MaxWaitMs)"]
+ D --> E["提取本代际 [RX] 行,剥 [RX] 时间戳前缀 → boot txt"]
+ E --> F["verify_log.ps1(标记匹配,脚本不动)"]
+```
+
+- 对比旧流程:6 个采集窗口参数 + JSON 解析 + EOF 重试 **全部删除**;采集 = 端口常开 + 读文件
+- `verify_log.ps1` 无需修改(剥前缀后与现有纯文本契约一致)
+- 参数保留:`-Port` `-BaudRate` `-FlashCmd` `-Required` `-Forbidden` `-OutDir`;删除 `-DurationMs` 等窗口参数,新增 `-SettleMs`(静默判据,**默认 8000**——boot 任务间有 3~4s 自然静默间隙,2000 会截断)`-MaxWaitMs`(默认 30000)
+
+### 7.2 配套变更清单
+
+| 对象 | 变更 |
+|---|---|
+| `requirements.txt` | 增 `pyserial`(**保持纯 ASCII** 注释) |
+| `.gitignore` | 增 `tools/serial_tool/state/` |
+| `opencode.json`(仓库根) | **删除 `mcp.serial` 段**(指向缺失二进制,已失效) |
+| `docs/测试说明/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. 实施步骤(按序执行)
+
+| # | 步骤 | 产物 | 验证 |
+|---|---|---|---|
+| S1 | `requirements.txt` 增 pyserial + `.venv` 安装 | requirements.txt | `pip show pyserial` 有版本 ✅ |
+| S2 | daemon 核心:HTTP 服务 + 串口读线程 + 行日志写(含 clear/截头/DTR 策略) | `tools/serial_tool/serial_tool.py`(daemon 部分) | `start→status→send→stop` 手动走通 ✅ |
+| S3 | CLI 全子命令 + 自动拉起 + 超时契约 + `selftest` | serial_tool.py(CLI 部分) | `selftest` 0=PASS + 命令走查 ✅ |
+| S4 | 验收 A1–A6、A8 | 测试通过记录 | A1–A5/A8 PASS ✅(本机无 CH340,A6 待硬件) |
+| S5 | @test 集成:`run_boot_test.ps1` 采集步改写 + 参数收敛 | run_boot_test.ps1 | 负路径(端口缺失→exit 2)+ 假烧录全链路 + verify 正/负判定 ✅;**A7 正路径待 CH340 接入** |
+| S6 | 文档/配置迁移(§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 与其他服务冲突 | `serial_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 正路径待硬件接入补验 |
diff --git a/docs/测试说明/测试开发标准流程.md b/docs/测试说明/测试开发标准流程.md
index b656c1bd..99378800 100644
--- a/docs/测试说明/测试开发标准流程.md
+++ b/docs/测试说明/测试开发标准流程.md
@@ -9,7 +9,7 @@
|---|---|
| `docs/测试说明/自动化测试流程.md` | @test 闭环细节(命令 / 标记 / 失败模式) |
| `docs/测试说明/测试文档模板规范.md` | 测试文档模板、命名规则(TM / TC / DP) |
-| `docs/测试说明/serial-mcp-server_串口调试.md` | 串口调试 / 采集工具与陷阱 |
+| `docs/测试说明/串口助手设计说明.md` | 串口调试 / 采集工具与陷阱(Python 串口助手) |
| `docs/UI 设计/UI_自动化测试流程.md` | UI 模拟器回归(ui-verify)细节 |
| `docs/代码规范/pre-commit使用说明.md` | pre-commit 门禁配置与范围 |
| `docs/项目部署环境要求.md` | 构建 / 测试工具链环境 |
@@ -187,8 +187,7 @@ static void phaseN_run(void) {
| 工具 | 位置 | 作用 |
|---|---|---|
-| serial-mcp-server v0.3.0 | `%LOCALAPPDATA%\serial-mcp-server\serial-mcp-server.exe` | 串口 CLI / MCP:列端口、采集、读写 |
-| `test/serial_capture.ps1` | `test/` | 底层采集编排(稳定启动 → 烧录 → 收集;内置 EOF 自动重试) |
+| Python 串口助手(serial_tool) | `tools/serial_tool/serial_tool.py`(.venv + pyserial) | 常驻 daemon + 薄 CLI:列端口 / 开关 / 收发 / 全量日志 / wait-idle |
| `test/run_boot_test.ps1` | `test/` | 一键闭环:采集 → 烧录 → 提取日志 → 校验 |
| `test/verify_log.ps1` | `test/` | 日志标记校验(Required / Forbidden → PASS / FAIL) |
| `.opencode/serial_config.json` | 仓库 | **串口端口单一配置源**(当前 COM6 / 115200);换端口只改这个文件 |
@@ -267,9 +266,9 @@ flowchart TD
### 6.3 三条铁律
-1. **采集必须先于烧录武装** —— serial-mcp-server 无历史缓冲(只能读调用后字节);开机日志必须"先采集、后烧录"(`run_boot_test.ps1` 已保证顺序);
+1. **采集必须先于烧录武装** —— 开串口 + clear 在烧录前完成(daemon 常驻全量日志兜底,不会漏字节;`run_boot_test.ps1` 已保证顺序);
2. **端口以 `.opencode/serial_config.json` 为准** —— 每次连接前先读它;
-3. **参数覆盖时间窗** —— `InitialTimeoutMs=90000`(覆盖编译 + 烧录)、`IdleTimeoutMs=8000`(覆盖 boot ~3-4s 自然静默间隙);`UnexpectedEof` 陷阱由 `serial_capture.ps1` 内置自动重试,勿裸跑 CLI。
+3. **静默判据覆盖时间窗** —— `-SettleMs=8000`(覆盖 boot ~3-4s 自然静默间隙)、`-MaxWaitMs=30000`(整体超时兜底);
---
@@ -337,7 +336,7 @@ flowchart LR
| `storage_test.h` / `storage_test_task.c` | STORAGE 套件:FTL + FatFS 集成(TC-STO-01~04)+ 恢复测试 |
| `gd5f_test.h` / `gd5f_test_task.c` | GD5F 套件:NAND 驱动 10 阶段 |
| `sd2506_test.h` / `sd2506_test_task.c` | RTC 套件:SD2506 RTC 2 阶段组 |
-| `run_boot_test.ps1` / `serial_capture.ps1` / `verify_log.ps1` | 自动化闭环三件套(纯 ASCII) |
+| `run_boot_test.ps1` / `verify_log.ps1` + `serial_tool` | 自动化闭环三件套(ps1 纯 ASCII / Python 仓库内置) |
| `ftp_test.py` / `ftp_capture.py` / `ftp_up_dl_test.py` / `ftp_speed_test.py` | FTP PC 端测试工具 |
| `ch395f_socket_test.py` | CH395F PC 端 socket 回显 / 大文件传输工具 |
| `time_sync_server.py` | 对时推送 PC 端服务器 |
@@ -350,7 +349,7 @@ flowchart LR
| `docs/测试说明/GD5F2GQ5UE_Test_Guide.md` | GD5F + 存储集成(TC-GD5F / TC-STO) |
| `docs/测试说明/SD2506_Test_Guide.md` | RTC(TC-RTC-xxx) |
| `docs/测试说明/FTP_Test_Guide.md` | FTP 服务(手动 + 阶段 8 自动化) |
-| `docs/测试说明/serial-mcp-server_串口调试.md` | 串口调试工具 |
+| `docs/测试说明/串口助手设计说明.md` | 串口调试工具(Python 串口助手) |
| `docs/测试说明/自动化测试流程.md` | @test 闭环细节 |
| `docs/测试说明/测试文档模板规范.md` | 测试文档模板与命名 |
diff --git a/docs/测试说明/自动化测试流程.md b/docs/测试说明/自动化测试流程.md
index fb03ed6a..0afcfb2c 100644
--- a/docs/测试说明/自动化测试流程.md
+++ b/docs/测试说明/自动化测试流程.md
@@ -7,10 +7,10 @@
```
@test 命令触发
│
- ├─ Step 0 预检 serial-mcp-server 二进制 + list-ports 确认 COM
+ ├─ Step 0 预检 .venv + serial_tool(selftest/ports 确认 COM)
├─ Step 1 构建验证 MDK-ARM/build.bat (0 错误 0 警告才继续)
- ├─ Step 2 烧录 + 采集(一条命令编排,先采集等 first-byte 再烧录)
- │ test/run_boot_test.ps1 (内含 serial_capture.ps1 + download.bat + verify_log.ps1)
+ ├─ Step 2 烧录 + 采集(一条命令编排:开串口 + 清日志 → 烧录 → wait-idle → 提取)
+ │ test/run_boot_test.ps1 (内置 serial_tool + download.bat + verify_log.ps1)
├─ Step 3 判定:
│ PASS → 报告,结束
│ FAIL → 依据 [MISS]/[HIT] + 日志尾部,修改代码后回到 Step 1
@@ -23,10 +23,9 @@
| `@build` | `.opencode/command/build.md` | 仅编译验证(0 错误 0 警告) |
| `@download` | `.opencode/command/download.md` | 编译并烧录(ST-Link) |
| `@test` | `.opencode/command/test.md` | 完整闭环:build → capture+download → verify → iterate |
-| `test/run_boot_test.ps1` | `test/run_boot_test.ps1` | **一键闭环**:采集(含 EOF 重试)→ 烧录 → 提取日志 → verify,退出码 0=PASS/1=FAIL/2=错误 |
-| `test/serial_capture.ps1` | `test/serial_capture.ps1` | 底层采集编排:稳定启动采集 → 烧录 → 等采集结束;内置 EOF 自动重试 |
+| `test/run_boot_test.ps1` | `test/run_boot_test.ps1` | **一键闭环**:open + clear → 烧录 → wait-idle(静默 8s/超时 30s)→ 提取 `[RX]` → verify,退出码 0=PASS/1=FAIL/2=采集或烧录错误/3=用法错误 |
+| `tools/serial_tool/serial_tool.py` | `tools/serial_tool/` | Python 串口助手(常驻 daemon + 薄 CLI):ports/open/send/status/log/clear/wait-idle/selftest;全量日志 `state/serial.log`(20MB 自动截头) |
| `test/verify_log.ps1` | `test/verify_log.ps1` | 对日志做标记校验,输出 PASS/FAIL 并返回退出码 |
-| serial-mcp-server | `%LOCALAPPDATA%\serial-mcp-server\serial-mcp-server.exe` | CLI/MCP 串口工具(Adancurusul/serial-mcp-server) |
## 判定标准
@@ -52,11 +51,11 @@
| 现象 | 处理 |
|---|---|
-| `serial-mcp-server` 不存在 | 需先编译安装(见 `docs/测试说明/serial-mcp-server_串口调试.md`) |
-| 采集报 `UnexpectedEof: zero bytes` | 已由 `serial_capture.ps1` 自动重试处理;若仍失败,检查 COM 是否被其他程序占用 |
-| 抓到日志缺 `Network Init` | `IdleTimeoutMs` 太小(boot 阶段有 ~3-4s 静默间隙),调到 8000ms |
-| 烧录后无数据 | 采集必须在烧录**前**启动(`run_boot_test.ps1` 已保证);`InitialTimeoutMs` 需覆盖编译+烧录耗时(90000) |
-| 端口被占用 | 先 `serial_close` / 结束残留 `serial-mcp-server` 进程;Keil ST-Link 不占 COM |
+| `selftest` FAIL / `ModuleNotFoundError: pyserial` | venv 未建或依赖缺失:按 AGENTS.md「Python 环境」重建 `.venv` + `pip install -r requirements.txt` |
+| daemon 无响应 / 自动拉起失败 | 查 `tools/serial_tool/state/daemon.log`;`serial_tool.py restart` 重建 daemon;检查 COM 是否被其他程序占用 |
+| 抓到日志缺 `Network Init` | `-SettleMs` 太小(boot 任务间有 ~3-4s 自然静默间隙),用默认 8000ms |
+| 烧录后无数据(`no_data`) | 开串口 + clear 必须在烧录**前**完成(`run_boot_test.ps1` 已保证);核对 COM 号、板子供电,看 `state/serial.log` 有无 SYS 异常行 |
+| 端口被占用(`port busy`) | 先 `serial_tool.py close` 或 `restart`;检查其他程序占用;Keil ST-Link 不占 COM |
| 日志中无 UI 相关输出 | 见下节「UI 自动化」 |
## UI 屏幕自动化
@@ -72,10 +71,11 @@
`-Required "App init OK;Network Init;net init: OK;PHY: 100M FULL"`。
- **测试套件构建**:`test/test_config.h` 开启某个 `TEST_SUITE_*` 后,把套件打印的
`=== ... Test:` 标题作为 Required 标记,配合 `test/` 下各用例输出判断。
-- **长时间采集**:serial-mcp-server 支持 `--duration-ms` 长窗口采集(MCP `read` 传 `duration_ms` 即进入 capture 模式)。
+- **长时间采集**:daemon 全量持续写入 `state/serial.log`(20MB 上限自动截头保尾 10MB),直接读文件即可;或用 `log --tail 0 --grep <标记>` 过滤。
## 变更记录
| 日期 | 变更 |
|---|---|
| 2026-09-13 | 「UI 屏幕自动化(当前缺口)」更新:UI 初始化 DBG 打印已实现(`ui_task.c`),屏幕内容已纳入模拟器像素回归;测试分层与门禁见 `测试开发标准流程.md` |
+| 2026-09-15 | 采集机制整体切换:serial-mcp-server(采集窗口/first-byte/EOF 重试)→ 仓库内置 Python 串口助手(daemon 常驻全量日志 + `wait-idle` 静默判据 `-SettleMs`/`-MaxWaitMs`);`serial_capture.ps1` 删除,流程/脚本清单/失败模式表同步 |
diff --git a/docs/项目部署环境要求.md b/docs/项目部署环境要求.md
index 5487fa75..4e51e6f5 100644
--- a/docs/项目部署环境要求.md
+++ b/docs/项目部署环境要求.md
@@ -21,7 +21,7 @@ flowchart LR
X[STM32CubeMX 6.17.0
外设配置/代码再生成]
S[CMake + VS2019
LVGL PC 模拟器]
P[Python .venv
字体/回归脚本]
- T[serial-mcp-server
串口调试/自动测试]
+ T[Python 串口助手 serial_tool
串口调试/自动测试]
end
subgraph TGT["② 目标板(DTU 硬件)"]
M[STM32F407ZGTx @168MHz
1MB Flash / 192KB SRAM]
@@ -116,7 +116,7 @@ cmake --build build --config Debug # 日常增量
|---|---|---|
| 统一入口 | **仓库 `.venv\Scripts\python.exe`** | 一切依赖第三方库的脚本(字体生成、截图转 PNG、RemoDispBus 回归)都用它 |
| 解释器版本 | Python ≥ 3.10(任意发行版) | 种子解释器仅用于建 venv 一次;本机为 3.12.4 |
-| 依赖 | `fonttools==4.51.0`、`pillow==10.3.0` | 清单 = 仓库根 `requirements.txt`(**必须保持纯 ASCII**:Windows 下 pip 按 GBK 代码页解码,中文注释会 UnicodeDecodeError) |
+| 依赖 | `fonttools==4.51.0`、`pillow==10.3.0`、`pyserial==3.5` | 清单 = 仓库根 `requirements.txt`(**必须保持纯 ASCII**:Windows 下 pip 按 GBK 代码页解码,中文注释会 UnicodeDecodeError) |
| 入库情况 | `.venv/` 已 gitignore | **换机/首次必须重建** |
```powershell
@@ -146,12 +146,12 @@ python tools/font_gen/extract_cn.py # 任意 python3:
| 项 | 要求 | 说明 |
|---|---|---|
-| serial-mcp-server | v0.3.0(Rust,自源码 `cargo build --release`) | 安装于 `%LOCALAPPDATA%\serial-mcp-server\`;MCP(AI 交互)与 CLI(脚本采集)两用 |
+| Python 串口助手 | `tools/serial_tool/serial_tool.py`(仓库内置,.venv + pyserial) | 常驻 daemon + 薄 CLI + 全量日志文件;无需单独安装,`pip install -r requirements.txt` 即就绪 |
| 调试串口 | USART1(PA9 TX / PA10 RX),115200 8N1 | **端口单一配置源 `.opencode/serial_config.json`**(本机 COM6);换端口只改这个文件 |
-| 一键测试 | `test\run_boot_test.ps1`(即 `@test`) | 采集窗口武装 → 烧录 → 日志标记校验;默认 COM6 / 115200,退出码 0=PASS / 1=FAIL / 2=采集或烧录错误 |
+| 一键测试 | `test\run_boot_test.ps1`(即 `@test`) | 开串口 + 清日志 → 烧录 → wait-idle(静默 8s/超时 30s)→ 提取 → 标记校验;默认端口取 `serial_config.json` / 115200,退出码 0=PASS / 1=FAIL / 2=采集或烧录错误 / 3=用法错误 |
-> 关键顺序:**串口采集必须先于烧录复位武装**(`-FlashCmd` 分支已保证)。
-> 参数经验值与陷阱见 `docs/测试说明/serial-mcp-server_串口调试.md`、`docs/测试说明/自动化测试流程.md`。
+> 采集 = 端口常开 + daemon 全量日志(`tools/serial_tool/state/serial.log`),无采集窗口/first-byte/EOF 重试仪式。
+> 命令契约与陷阱见 `docs/测试说明/串口助手设计说明.md`、`docs/测试说明/自动化测试流程.md`。
### 2.7 代码审查钩子(pre-commit)
@@ -230,18 +230,15 @@ pre-commit run --all-files # 首次全库基线扫描
> 手动下载:uv → https://astral.sh/uv/ (一行安装 `powershell -c "irm https://astral.sh/uv/install.ps1 | iex"`)· pre-commit → https://pre-commit.com/ · cppcheck 2.21.0 → https://github.com/CppQA/cppcheck/releases (2.21.0 tag 的 Windows x64 zip,解压至 `C:\Program Files\Cppcheck` 并加入 PATH)
-**第 7 步:serial-mcp-server**(串口调试 / 自动测试,Rust 源码编译)
+**第 7 步:Python 串口助手**(串口调试 / 自动测试,仓库内置)
+
+无需单独安装:`tools/serial_tool/` 随仓库提供,依赖 `pyserial`(已入 `requirements.txt`,第 4 步建 `.venv` 时一并安装)。建成后验证:
```powershell
-winget install Rustlang.Rustup
-git clone https://github.com/Adancurusul/serial-mcp-server
-cd serial-mcp-server; cargo build --release
-New-Item -ItemType Directory -Force "$env:LOCALAPPDATA\serial-mcp-server" | Out-Null
-Copy-Item target\release\serial-mcp-server.exe "$env:LOCALAPPDATA\serial-mcp-server\"
+& '.venv\Scripts\python.exe' tools\serial_tool\serial_tool.py selftest # → SELFTEST PASS
+& '.venv\Scripts\python.exe' tools\serial_tool\serial_tool.py ports # 枚举 COM 口
```
-> 手动下载:Rust → https://www.rust-lang.org/tools/install (运行 `rustup-init.exe`)· serial-mcp-server 源码 → https://github.com/Adancurusul/serial-mcp-server
-
**第 8 步:下载器驱动**(ST-Link)
- ST 官网下载 **STM32CubeProgrammer** 运行一次(自动安装 ST-Link USB 驱动):https://www.st.com/en/development-tools/stm32cubeprog.html
@@ -251,7 +248,7 @@ Copy-Item target\release\serial-mcp-server.exe "$env:LOCALAPPDATA\serial-mcp-ser
**第 9 步:串口配置**
```powershell
-& "$env:LOCALAPPDATA\serial-mcp-server\serial-mcp-server.exe" serial_list_ports # 查 COM 号
+& '.venv\Scripts\python.exe' tools\serial_tool\serial_tool.py ports # 查 COM 号
```
把查到的端口写入 `.opencode\serial_config.json` 的 `portName`(唯一配置源,换口只改这里)。
@@ -267,6 +264,7 @@ cmake --build build --config Debug # 0 警告
& '.venv\Scripts\python.exe' simulator\tests\check_menu_rows.py # PASS
& '.venv\Scripts\python.exe' simulator\tests\check_content_pages.py --update # 首次生成参考图
& '.venv\Scripts\python.exe' simulator\tests\check_content_pages.py # 再跑 → PASS
+& '.venv\Scripts\python.exe' tools\serial_tool\serial_tool.py selftest # → SELFTEST PASS
pre-commit run --all-files
```
@@ -279,7 +277,7 @@ pre-commit run --all-files
- [ ] `git clone` 仓库 → `pre-commit install`;cppcheck 入 PATH
- [ ] 建 `.venv` 并 `pip install -r requirements.txt`
- [ ] `npm i -g lv_font_conv`
-- [ ] serial-mcp-server 编译放置,写好 `.opencode/serial_config.json`(本机 COM 口)
+- [ ] 写好 `.opencode/serial_config.json`(本机 COM 口),`serial_tool selftest` PASS
- [ ] 下载器(ST-Link 等)USB 驱动已装,Keil `Options for Target → Debug` 驱动一致
- [ ] 第 10 步冒烟验证全部通过
@@ -302,8 +300,6 @@ pre-commit run --all-files
| uv | https://astral.sh/uv/ | `powershell -c "irm https://astral.sh/uv/install.ps1 \| iex"` |
| pre-commit | https://pre-commit.com/ | 一般 `uv tool install pre-commit` |
| cppcheck | https://github.com/CppQA/cppcheck/releases | 2.21.0 的 Windows x64 zip(`cppcheck-2.21.0-windows-x64.zip` 形式),解压入 PATH |
-| Rust(rustup) | https://www.rust-lang.org/tools/install | 运行 `rustup-init.exe` |
-| serial-mcp-server | https://github.com/Adancurusul/serial-mcp-server | 源码 `cargo build --release`(v0.3.0) |
| STM32CubeProgrammer | https://www.st.com/en/development-tools/stm32cubeprog.html | 含 ST-Link USB 驱动 |
| ST-Link V2 驱动 | https://www.st.com/en/development-tools/stm321025.html | `ST-LinkV2-USB-Driver` 包 |
| SDL2(仅仓库预编译库缺失时) | https://github.com/libsdl-org/SDL/releases | 2.32.10,一般不需要 |
@@ -453,7 +449,7 @@ pre-commit run --all-files
| pip 装依赖报 UnicodeDecodeError | `requirements.txt` 含中文注释 | 保持该文件**纯 ASCII**(pip 按 GBK 代码页解码) |
| 字体生成 / 转 PNG 报缺模块 | 误用 PATH 里的 msys64 python | 一律用 `.venv\Scripts\python.exe` |
| 内容页回归 exit 2(参考缺失) | `tests/ref/` 是本地生成物、不入库 | 先 `check_content_pages.py --update` 生成参考,再跑回归 |
-| 串口采集不到 / 端口占用 | COM 号变化 / 上一进程未释放 | 改 `.opencode/serial_config.json`;`serial_capture.ps1` 已带 EOF 自动重试 |
+| 串口采集不到 / 端口占用 | COM 号变化 / 上一进程未释放 | 改 `.opencode/serial_config.json`;`serial_tool.py ports` 查实际端口,`open` 占用 ≤1s 报 `port busy` |
| 烧录失败 | 下载器未选 / USB 驱动缺失 | Keil `Options for Target → Debug` 选对驱动;安装下载器 USB 驱动 |
| 下载报 Flash 算法 / FLM 文件找不到 | 未安装 `Keil::STM32F4xx_DFP` pack(FLM 来自 pack 目录,不在仓库) | Pack Installer 安装该 pack 后重新下载 |
| UV4 启动弹 license 窗口 / 无法编译 | 90 天评估 license 过期或未激活 | `Options for Target → License` 查看/输入;长期使用购买正式 license |
@@ -472,7 +468,7 @@ pre-commit run --all-files
| 网口配置 / 生效链路 | `docs/网络配置说明.md` |
| 远程显示协议 | `docs/UI 设计/RemoDispBus协议与接收设计说明.md` |
| LVGL 模拟器部署 | `docs/UI 设计/LVGL模拟器部署与使用说明.md`、`simulator/README.md` |
-| 串口调试 / 自动化测试 | `docs/测试说明/serial-mcp-server_串口调试.md`、`docs/测试说明/自动化测试流程.md` |
+| 串口调试 / 自动化测试 | `docs/测试说明/串口助手设计说明.md`、`docs/测试说明/自动化测试流程.md` |
| pre-commit / cppcheck | `docs/代码规范/pre-commit使用说明.md`、`docs/代码规范/cppcheck使用说明.md` |
| 各模块测试指南 | `docs/测试说明/CH395F_Test_Guide.md`、`GD5F2GQ5UE_Test_Guide.md`、`SD2506_Test_Guide.md`、`FTP_Test_Guide.md` |
| 已知硬件陷阱 | `docs/问题记录/CH395F_Trap_Records.md` 等 |
@@ -485,6 +481,7 @@ pre-commit run --all-files
|---|---|
| 2026-09-13 | 初版:汇总开发机(Keil / VS2019+SDL2 / Python venv / 字体 / 串口工具 / pre-commit)、目标板(引脚 / 资源预算 / 下载 / 功能开关)、网络现场部署(默认参数 / 端口 / 验收清单)、远端客户端四类环境要求,附一次性搭建清单(含分步安装命令与冒烟验证)与常见环境问题速查 |
| 2026-09-13 | §2.8 各组件补手动下载网址;新增 §2.9 手动下载来源速查表 |
+| 2026-09-15 | 串口调试/自动测试工具由 serial-mcp-server 切换为仓库内置 Python 串口助手(serial_tool,.venv + pyserial,免安装):§2.4 依赖清单、§2.6、§2.8 第 7/9/10 步、搭建清单、§2.9 下载表(去 Rust/serial-mcp-server)、速查表、相关文档同步 |
| 2026-09-13 | 补 STM32CubeMX 6.17.0 + STM32Cube FW_F4 V1.28.3(与 .ioc 一致):拓扑图 / §2.2 / 第 2 步 / §2.9 / 搭建清单,常见问题速查增加再生成版本不匹配条目 |
| 2026-09-13 | 新增"Keil 初始化配置(机器侧项,非项目自带)":license / Device Pack(含 FLM 来源解析路径)/ 下载器驱动,并列出项目自带免配置项;§六 速查补 FLM 缺失、license 过期两条 |
| 2026-09-13 | 补 RAM/Flash 链接布局说明:手动 scatter 文件 `STM32F407-Demo.sct`(在仓库)为链接依据(128KB = SRAM1+SRAM2,SRAM3/CCM 未映射),新机器无需重设、勿改 GUI Target 页 |
diff --git a/opencode.json b/opencode.json
index 942fc7e3..720ece5c 100644
--- a/opencode.json
+++ b/opencode.json
@@ -1,14 +1,3 @@
{
- "$schema": "https://opencode.ai/config.json",
- "mcp": {
- "serial": {
- "type": "local",
- "command": [
- "C:\\Users\\16241\\AppData\\Local\\serial-mcp-server\\serial-mcp-server.exe",
- "serve"
- ],
- "enabled": true,
- "timeout": 30000
- }
- }
+ "$schema": "https://opencode.ai/config.json"
}
diff --git a/requirements.txt b/requirements.txt
index 641fa693..9b600663 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -3,3 +3,4 @@
# NOTE: keep this file ASCII-only (pip on Windows decodes it with the locale codepage).
fonttools==4.51.0 # tools/font_gen/make_font.py (font generation, verified version)
pillow==10.3.0 # simulator/i1_to_png.py (screenshot to PNG, verified on this machine)
+pyserial==3.5 # tools/serial_tool/serial_tool.py (serial assistant daemon + CLI)
diff --git a/test/run_boot_test.ps1 b/test/run_boot_test.ps1
index ed7f1ed4..41e5b925 100644
--- a/test/run_boot_test.ps1
+++ b/test/run_boot_test.ps1
@@ -1,25 +1,36 @@
<#
-run_boot_test.ps1 - Full automated boot-log capture + verify in one call.
+run_boot_test.ps1 - Automated boot-log capture + verify in one call.
-Orchestrates:
- 1. serial_capture.ps1 (with -FlashCmd): stable-start capture -> flash -> collect
- 2. Extract the captured text from the JSON
- 3. verify_log.ps1 on the extracted boot log
+Uses the repo's Python serial assistant (tools/serial_tool/serial_tool.py,
+resident daemon on 127.0.0.1:18765, auto-started on first call):
+ 1. open COM port + clear log (fresh generation for this run)
+ 2. flash via -FlashCmd (download.bat: download + Reset & Run)
+ 3. wait-idle: until RX silence >= SettleMs, or MaxWaitMs timeout
+ 4. extract [RX] payload lines from the log file (prefix/timestamp stripped)
+ 5. verify_log.ps1 marker check
+
+The capture is armed (port open, log cleared) BEFORE flashing, and the
+resident daemon buffers everything in tools/serial_tool/state/serial.log -
+no capture-window timing ritual, no first-byte wait, no EOF retries.
Usage:
powershell -ExecutionPolicy Bypass -File run_boot_test.ps1 `
[-Port COM6] [-BaudRate 115200] `
- [-DurationMs 30000] [-InitialTimeoutMs 90000] [-IdleTimeoutMs 8000] `
+ [-SettleMs 8000] [-MaxWaitMs 30000] `
[-FlashCmd "D:\...\download.bat"] `
- [-Required "App init OK;Network Init;net init: OK;PHY: 100M FULL"] `
+ [-Required "App init OK;Network Init;net init: OK"] `
[-Forbidden "FAIL;ERROR;HardFault"] `
[-OutDir C:\temp] [-ShowTailLines 15]
+ -Port default: .opencode/serial_config.json "portName" entry.
+ -SettleMs: RX silence that ends the capture (default 8000 - boot has a
+ natural 3~4s silent gap between tasks; 2000 would truncate the log).
+
Exit codes:
0 = PASS (captured + all required markers + no forbidden)
1 = FAIL (marker miss / forbidden hit)
2 = capture or flash error
- 3 = verify file error
+ 3 = usage / preflight error
NOTE: ASCII-only on purpose - project source files contain Chinese and must
never be rewritten by encoding-sensitive PowerShell. Keep this script ASCII.
@@ -27,12 +38,10 @@ never be rewritten by encoding-sensitive PowerShell. Keep this script ASCII.
[CmdletBinding()]
param(
- [string]$Port = "COM6",
+ [string]$Port = "",
[int]$BaudRate = 115200,
- [int]$DurationMs = 30000,
- [int]$InitialTimeoutMs = 90000,
- [int]$IdleTimeoutMs = 8000,
- [int]$MaxBytes = 65536,
+ [int]$SettleMs = 8000,
+ [int]$MaxWaitMs = 30000,
[string]$FlashCmd = "",
[string]$Required = "App init OK;Network Init;net init: OK",
[string]$Forbidden = "FAIL;ERROR;HardFault",
@@ -43,22 +52,37 @@ param(
$ErrorActionPreference = 'Stop'
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
-$captureScript = Join-Path $scriptDir "serial_capture.ps1"
+$repoRoot = Split-Path -Parent $scriptDir
+$py = Join-Path $repoRoot ".venv\Scripts\python.exe"
+$tool = Join-Path $repoRoot "tools\serial_tool\serial_tool.py"
$verifyScript = Join-Path $scriptDir "verify_log.ps1"
+$cfgFile = Join-Path $repoRoot ".opencode\serial_config.json"
-# Default flash command: resolve relative to this script's repo location
-# (the old hardcoded absolute path pointed at a stale checkout location).
+# -Port default: the single source of truth is .opencode/serial_config.json
+if (-not $Port) {
+ if (Test-Path -LiteralPath $cfgFile) {
+ $Port = (Get-Content -LiteralPath $cfgFile -Raw | ConvertFrom-Json).portName
+ }
+}
+if (-not $Port) {
+ Write-Output "RUN_TEST_ERROR: no -Port given and no portName in $cfgFile"
+ exit 3
+}
+
+# Default flash command: resolve relative to the repo (no stale absolute paths)
if (-not $FlashCmd) {
- $FlashCmd = Join-Path $scriptDir "..\MDK-ARM\download.bat"
+ $FlashCmd = Join-Path $repoRoot "MDK-ARM\download.bat"
}
-if (-not (Test-Path -LiteralPath $captureScript)) {
- Write-Output "RUN_TEST_ERROR: serial_capture.ps1 not found"
- exit 2
+foreach ($p in @($py, $tool, $verifyScript)) {
+ if (-not (Test-Path -LiteralPath $p)) {
+ Write-Output "RUN_TEST_ERROR: missing $p"
+ exit 3
+ }
}
-if (-not (Test-Path -LiteralPath $verifyScript)) {
- Write-Output "RUN_TEST_ERROR: verify_log.ps1 not found"
- exit 2
+if (-not (Test-Path -LiteralPath $FlashCmd)) {
+ Write-Output "RUN_TEST_ERROR: flash command not found: $FlashCmd"
+ exit 3
}
if (-not (Test-Path -LiteralPath $OutDir)) {
@@ -66,52 +90,83 @@ if (-not (Test-Path -LiteralPath $OutDir)) {
}
$stamp = Get-Date -Format "yyyyMMdd_HHmmss"
-$capJson = Join-Path $OutDir "boot_capture_$stamp.json"
$bootTxt = Join-Path $OutDir "boot_log_$stamp.txt"
+function Invoke-Tool {
+ param(
+ [Parameter(Position = 0)][string]$Command,
+ [Parameter(ValueFromRemainingArguments = $true)][string[]]$Rest
+ )
+ $script:toolOut = (& $py $tool $Command @Rest 2>$null) -join "`n"
+ $script:toolRc = $LASTEXITCODE
+}
+
Write-Output "=== Boot test $stamp ==="
-Write-Output "Capture: $capJson"
+Write-Output "Port: $Port @ ${BaudRate} settle=${SettleMs}ms max=${MaxWaitMs}ms"
-& $captureScript -OutFile $capJson -Port $Port -BaudRate $BaudRate `
- -DurationMs $DurationMs -InitialTimeoutMs $InitialTimeoutMs `
- -IdleTimeoutMs $IdleTimeoutMs -MaxBytes $MaxBytes `
- -FlashCmd $FlashCmd -MaxRetries 3 -RetryDelayMs 1500
-$capRc = $LASTEXITCODE
-if ($capRc -ne 0) {
- Write-Output "RUN_TEST_ERROR: capture failed (rc=$capRc)"
+# 1. open port (auto-starts the serial daemon if it is not running)
+Invoke-Tool "open" "--port" $Port "--baud" $BaudRate "--timeout" 15
+if ($script:toolRc -ne 0) {
+ Write-Output "RUN_TEST_ERROR: open failed: $($script:toolOut)"
exit 2
}
-if (-not (Test-Path -LiteralPath $capJson)) {
- Write-Output "RUN_TEST_ERROR: no capture JSON at $capJson"
+# 2. clear log (fresh generation; only data after this belongs to the run)
+Invoke-Tool "clear" "--timeout" 15
+if ($script:toolRc -ne 0) {
+ Write-Output "RUN_TEST_ERROR: clear failed: $($script:toolOut)"
exit 2
}
-try {
- $json = Get-Content -LiteralPath $capJson -Raw -Encoding UTF8 | ConvertFrom-Json
-} catch {
- Write-Output "RUN_TEST_ERROR: cannot parse capture JSON: $_"
+# 3. flash (download + Reset & Run). A failed flash does not abort: the
+# board may still have reset; capture + marker check will judge the result.
+Write-Output "FLASH_START: $FlashCmd"
+$sw = [System.Diagnostics.Stopwatch]::StartNew()
+& $FlashCmd
+$flashRc = $LASTEXITCODE
+$sw.Stop()
+Write-Output ("FLASH_RC: {0} elapsed_ms={1}" -f $flashRc, $sw.ElapsedMilliseconds)
+if ($flashRc -ne 0) {
+ Write-Output "RUN_TEST_WARN: flash rc=$flashRc (continuing: board may still have reset)"
+}
+
+# 4. wait for RX silence (settle) or the overall timeout
+$waitTimeoutS = [int]($MaxWaitMs / 1000) + 20
+Invoke-Tool "wait-idle" "--settle-ms" $SettleMs "--max-ms" $MaxWaitMs "--timeout" $waitTimeoutS
+if ($script:toolRc -ne 0) {
+ Write-Output "RUN_TEST_ERROR: wait-idle failed: $($script:toolOut)"
+ exit 2
+}
+$wait = $script:toolOut | ConvertFrom-Json
+Write-Output ("WAIT: reason={0} rx_bytes={1} waited_ms={2}" -f $wait.reason, $wait.rx_bytes, $wait.waited_ms)
+if ($wait.reason -eq "no_data") {
+ Write-Output "RUN_TEST_ERROR: no RX data at all (check port / board power / flash)"
exit 2
}
-if (-not $json.read) {
- Write-Output "RUN_TEST_ERROR: capture JSON has no 'read' object"
+# 5. extract [RX] payload lines (strip the "[RX] " prefix)
+Invoke-Tool "log" "--tail" 0 "--timeout" 15
+if ($script:toolRc -ne 0) {
+ Write-Output "RUN_TEST_ERROR: log read failed: $($script:toolOut)"
exit 2
}
-
-Write-Output ("Capture: bytes_read={0} completion_reason={1} waited_ms={2} elapsed_ms={3}" -f `
- $json.read.bytes_read, $json.read.completion_reason, $json.read.waited_ms, $json.read.elapsed_ms)
-
-$data = [string]$json.read.data
-if ([string]::IsNullOrEmpty($data)) {
- Write-Output "RUN_TEST_ERROR: capture returned no data (device silent?)"
- Write-Output "Hint: raise -InitialTimeoutMs if flashing is slow, or check the device boots."
+$logJson = $script:toolOut | ConvertFrom-Json
+$rx = New-Object System.Collections.Generic.List[string]
+foreach ($l in $logJson.lines) {
+ if ($l -is [string] -and $l.StartsWith("[RX] ")) {
+ $rest = $l.Substring(5)
+ $sp = $rest.IndexOf(" ")
+ if ($sp -ge 0) { $rx.Add($rest.Substring($sp + 1)) } else { $rx.Add($rest) }
+ }
+}
+if ($rx.Count -eq 0) {
+ Write-Output "RUN_TEST_ERROR: no [RX] lines in log (device silent?)"
exit 2
}
+[System.IO.File]::WriteAllLines($bootTxt, $rx, (New-Object System.Text.UTF8Encoding($false)))
+Write-Output "Boot log: $bootTxt (lines=$($rx.Count) gen=$($logJson.gen))"
-[System.IO.File]::WriteAllText($bootTxt, $data, [System.Text.Encoding]::UTF8)
-Write-Output "Boot log: $bootTxt"
-
+# 6. marker verification
& $verifyScript -LogPath $bootTxt -Required $Required -Forbidden $Forbidden `
-ShowTailLines $ShowTailLines
$verifyRc = $LASTEXITCODE
diff --git a/test/serial_capture.ps1 b/test/serial_capture.ps1
deleted file mode 100644
index a4dae639..00000000
--- a/test/serial_capture.ps1
+++ /dev/null
@@ -1,159 +0,0 @@
-<#
-serial_capture.ps1 - Capture a serial boot log with serial-mcp-server CLI.
-
-Workflow (deterministic boot-log capture after flashing):
-1. Open COM via serial-mcp-server `read` CAPTURE mode and WAIT for the first
- byte (up to -InitialTimeoutMs). This starts BEFORE flashing.
-2. Flash the target (download.bat) -> MCU resets and prints the boot log.
-3. The running capture picks up the first byte, then collects for -DurationMs.
-4. The process exits and writes a single structured JSON file at -OutFile.
-
-Known trap (Adancurusul/serial-mcp-server, fixed here by retry):
- When COM is released by a previous process and reopened immediately,
- tokio-serial read returns 0 bytes and the project's capture.rs turns that
- into a fatal `UnexpectedEof` error (serial stream returned zero bytes).
- This is transient: a short backoff then reopening succeeds. This script
- detects that error, backs off, and restarts the capture.
-
-Usage:
- powershell -ExecutionPolicy Bypass -File serial_capture.ps1 `
- -OutFile C:\temp\boot.json `
- [-Port COM6] [-BaudRate 115200] `
- [-DurationMs 20000] [-InitialTimeoutMs 60000] `
- [-IdleTimeoutMs 1500] [-MaxBytes 65536] `
- [-FlashCmd "D:\...\download.bat"] `
- [-MaxRetries 3] [-RetryDelayMs 1500]
-
-Modes:
- -FlashCmd: orchestrates the whole sequence in ONE call: (retry-stable)
- start capture, run the flash/download command, then wait for capture to
- finish. Use this to avoid tool-call gaps letting the capture timeout before
- flashing.
- No -FlashCmd: starts the capture, waits for it to be stable (survives
- -StabilityCheckMs), prints the PID, and returns. Caller flashes, then calls
- with -WaitOnly to reap.
-
-Exit codes:
- 0 = capture completed (ok or clean timeout)
- 1 = capture failed to start after retries
- 2 = capture error status in JSON
-
-NOTE: ASCII-only on purpose - project source files contain Chinese and must
-never be rewritten by encoding-sensitive PowerShell. Keep this script ASCII.
-#>
-
-[CmdletBinding()]
-param(
- [Parameter(Mandatory = $true)]
- [string]$OutFile,
-
- [string]$Port = "COM6",
- [int]$BaudRate = 115200,
- [int]$DurationMs = 20000,
- [int]$InitialTimeoutMs = 60000,
- [int]$IdleTimeoutMs = 1500,
- [int]$MaxBytes = 65536,
- [string]$FlashCmd = "",
- [int]$MaxRetries = 3,
- [int]$RetryDelayMs = 1500,
- [int]$StabilityCheckMs = 1500
-)
-
-$ErrorActionPreference = 'Stop'
-
-$exe = "$env:LOCALAPPDATA\serial-mcp-server\serial-mcp-server.exe"
-if (-not (Test-Path -LiteralPath $exe)) {
- Write-Output "CAPTURE_ERROR: serial-mcp-server binary not found: $exe"
- exit 1
-}
-
-$outDir = Split-Path -Parent $OutFile
-if ($outDir -and -not (Test-Path -LiteralPath $outDir)) {
- New-Item -ItemType Directory -Path $outDir -Force | Out-Null
-}
-
-$baseArgs = @(
- "read",
- "--port", $Port,
- "--baud", ([string]$BaudRate),
- "--duration-ms", ([string]$DurationMs),
- "--start-trigger", "first-byte",
- "--initial-timeout-ms", ([string]$InitialTimeoutMs),
- "--idle-timeout-ms", ([string]$IdleTimeoutMs),
- "--max-bytes", ([string]$MaxBytes),
- "--json"
-)
-
-function Start-CaptureProcess {
- param([int]$attempt)
- $out = $OutFile
- $err = "$OutFile.err"
- if (Test-Path -LiteralPath $out) { Remove-Item -LiteralPath $out -Force }
- if (Test-Path -LiteralPath $err) { Remove-Item -LiteralPath $err -Force }
- if ($attempt -gt 0) {
- Write-Host "RETRY: capture attempt $attempt (backoff $RetryDelayMs ms)"
- Start-Sleep -Milliseconds $RetryDelayMs
- }
- $p = Start-Process -FilePath $exe -ArgumentList $baseArgs `
- -RedirectStandardOutput $out -RedirectStandardError $err `
- -WindowStyle Hidden -PassThru
- if ($p -eq $null -or $p.HasExited) {
- Write-Output "CAPTURE_ERROR: process failed to start (attempt $attempt)"
- return $null
- }
- return $p
-}
-
-function Test-CaptureErrored {
- $err = "$OutFile.err"
- if (Test-Path -LiteralPath $err) {
- $e = Get-Content -LiteralPath $err -Raw -ErrorAction SilentlyContinue
- if ($e -match "UnexpectedEof|zero bytes|Error:") { return $true }
- }
- return $false
-}
-
-$proc = $null
-for ($i = 0; $i -le $MaxRetries; $i++) {
- $proc = Start-CaptureProcess -attempt $i
- if ($proc -eq $null) { continue }
-
- # Wait a moment: if the process died with EOF/error immediately, retry.
- Start-Sleep -Milliseconds $StabilityCheckMs
- if (-not $proc.HasExited) { break } # stable, keep it
- if (-not (Test-CaptureErrored)) { break } # exited cleanly, no error
- Write-Host "CAPTURE_EOF: transient EOF on attempt $i, retrying..."
- $proc = $null
-}
-
-if ($proc -eq $null) {
- Write-Output "CAPTURE_ERROR: could not start a stable capture after retries"
- exit 1
-}
-
-Write-Output "CAPTURE_PID: $($proc.Id)"
-
-if ($FlashCmd) {
- if (-not (Test-Path -LiteralPath $FlashCmd)) {
- Stop-Process -Id $proc.Id -Force -ErrorAction SilentlyContinue
- Write-Output "CAPTURE_ERROR: flash command not found: $FlashCmd"
- exit 1
- }
- Write-Output "FLASH_START: $FlashCmd"
- & $FlashCmd
- $flashRc = $LASTEXITCODE
- Write-Output "FLASH_RC: $flashRc"
- $proc.WaitForExit()
- Write-Output "CAPTURE_DONE"
- if (Test-Path -LiteralPath $OutFile) {
- $content = Get-Content -LiteralPath $OutFile -Raw -ErrorAction SilentlyContinue
- if ($content -match '"status":\s*"error"') {
- Write-Output "CAPTURE_ERROR: capture returned error status"
- Write-Output $content
- exit 2
- }
- }
- exit 0
-}
-
-exit 0
diff --git a/test/verify_log.ps1 b/test/verify_log.ps1
index 32294206..308d065c 100644
--- a/test/verify_log.ps1
+++ b/test/verify_log.ps1
@@ -1,9 +1,9 @@
<#
verify_log.ps1 - Check a captured MCU debug log against boot/test markers.
-Used by the @test loop: after flashing, the fresh boot log is captured to a
-file (serial-mcp-server capture), then this script greps it and prints a
-structured verdict.
+Used by the @test loop: after flashing, the fresh boot log is extracted from
+the serial assistant log (tools/serial_tool), then this script greps it and
+prints a structured verdict.
Usage:
powershell -ExecutionPolicy Bypass -File verify_log.ps1 `
diff --git a/tools/serial_tool/serial_tool.py b/tools/serial_tool/serial_tool.py
new file mode 100644
index 00000000..74260ff5
--- /dev/null
+++ b/tools/serial_tool/serial_tool.py
@@ -0,0 +1,1009 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""serial_tool — Python 串口助手(常驻 daemon + 薄 CLI + 日志文件即接口)。
+
+设计文档:docs/测试说明/串口助手设计说明.md
+
+架构:
+ CLI(短命进程,硬超时) --HTTP JSON--> daemon(detached 单实例,127.0.0.1)
+ daemon = HTTP 服务 + 串口读线程(唯一串口 I/O 者) + 日志写线程(唯一文件写者)
+ 历史 = state/serial.log(纯文本、只含完整行,Agent 直接读文件即是历史)
+
+用法(仓库根目录,统一入口 .venv):
+ & '.venv\\Scripts\\python.exe' 'tools/serial_tool/serial_tool.py' <命令> [选项]
+
+命令:
+ ports 枚举 COM 口(CLI 本地执行,不依赖 daemon)
+ open / close 开(幂等)/ 关串口
+ send | --hex 发送 TX(进日志);--nl 追加 CRLF
+ status 全状态(单条 JSON)
+ log --tail N --grep P 读日志(CLI 直接读文件,daemon 死了也能看历史)
+ clear 清空日志(原子截断 + 代际递增)
+ wait-idle 等 RX 静默(@test 用;--settle-ms / --max-ms)
+ start / stop / restart daemon 生命周期(其余命令会自动拉起 daemon)
+ selftest 无头自检(行拆分/解码回退/截头算法,无需硬件)
+
+退出码:0=成功 1=领域错误 2=daemon 不可达/超时 3=用法错误
+"""
+import argparse
+import json
+import os
+import queue
+import signal
+import subprocess
+import sys
+import tempfile
+import threading
+import time
+import urllib.error
+import urllib.request
+from datetime import datetime
+from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
+from pathlib import Path
+
+import serial
+import serial.tools.list_ports
+
+VERSION = "1.0.0"
+
+SCRIPT = Path(__file__).resolve()
+TOOL_DIR = SCRIPT.parent
+REPO_ROOT = TOOL_DIR.parents[1]
+STATE_DIR = TOOL_DIR / "state"
+LOG_FILE = STATE_DIR / "serial.log"
+META_FILE = STATE_DIR / "meta.json"
+DAEMON_LOG = STATE_DIR / "daemon.log"
+CONFIG_FILE = REPO_ROOT / ".opencode" / "serial_config.json"
+
+DEFAULT_HTTP_PORT = 18765
+CAP_BYTES = 20 * 1024 * 1024 # 日志体积上限
+KEEP_BYTES = 10 * 1024 * 1024 # 超限后保留的尾部
+PARTIAL_FLUSH_S = 0.2 # 残行冲刷窗口(秒)
+REOPEN_SETTLE_S = 1.5 # 重开端口退避(CH340 释放后立即重开的 EOF 陷阱)
+DEFAULT_TIMEOUT_S = 5.0 # CLI→daemon 硬超时(秒)
+
+
+# ---------------------------------------------------------------- 配置 / 元数据
+
+def load_config():
+ cfg = {"portName": "", "baudRate": 115200, "httpPort": DEFAULT_HTTP_PORT}
+ try:
+ with open(CONFIG_FILE, "r", encoding="utf-8") as f:
+ raw = json.load(f)
+ cfg["portName"] = str(raw.get("portName", ""))
+ cfg["baudRate"] = int(raw.get("baudRate", 115200))
+ cfg["httpPort"] = int(raw.get("httpPort", DEFAULT_HTTP_PORT))
+ except (OSError, ValueError):
+ pass
+ return cfg
+
+
+def load_meta():
+ try:
+ with open(META_FILE, "r", encoding="utf-8") as f:
+ return json.load(f)
+ except (OSError, ValueError):
+ return {}
+
+
+def save_meta(meta):
+ tmp = META_FILE.with_name(META_FILE.name + ".tmp")
+ with open(tmp, "w", encoding="utf-8") as f:
+ json.dump(meta, f, ensure_ascii=False, indent=2)
+ os.replace(tmp, META_FILE)
+
+
+def now_str():
+ return datetime.now().astimezone().isoformat(timespec="milliseconds")
+
+
+def line_at(ts_epoch, direction, text):
+ # Windows CRT 仅支持 1980+ 时间戳,越界时回退当前时间(不致整行丢失)
+ try:
+ ts = datetime.fromtimestamp(ts_epoch).astimezone().isoformat(timespec="milliseconds")
+ except (OSError, OverflowError, ValueError):
+ ts = now_str()
+ return "[%s] %s %s" % (direction, ts, text)
+
+
+# ---------------------------------------------------------------- 行拆分 / 解码 / 截头
+
+class LineSplitter:
+ """字节流 → 完整行。残行留内存(保证日志文件只含完整行)。"""
+
+ def __init__(self):
+ self.buf = b""
+ self.buf_ts = 0.0
+
+ def feed(self, data, now):
+ out = []
+ if not data:
+ return out
+ if not self.buf:
+ self.buf_ts = now
+ self.buf += data
+ while True:
+ i = self.buf.find(b"\n")
+ if i < 0:
+ break
+ raw = self.buf[:i].rstrip(b"\r")
+ self.buf = self.buf[i + 1:]
+ ts = self.buf_ts
+ self.buf_ts = now if self.buf else 0.0
+ out.append((ts, raw))
+ return out
+
+ def flush_partial(self):
+ if not self.buf:
+ return []
+ out = [(self.buf_ts or time.time(), self.buf.rstrip(b"\r"))]
+ self.buf = b""
+ self.buf_ts = 0.0
+ return out
+
+
+def decode_line(raw):
+ """UTF-8 → GBK → HEX 转义(MCU 流 = ASCII 为主 + UTF-8 中文,前两级足够)。"""
+ for enc in ("utf-8", "gbk"):
+ try:
+ return raw.decode(enc)
+ except UnicodeDecodeError:
+ continue
+ return "HEX:" + raw.hex()
+
+
+def cap_truncate(f, size, keep=KEEP_BYTES, cap=CAP_BYTES):
+ """原地截头:保留尾部 keep 字节。
+ Windows 无"丢弃头部"的单系统调用(ftruncate/SetEndOfFile 只截尾部),
+ 故在同一句柄上:读尾部 → 截空 → 写回。单写者线程内执行,无并发风险。"""
+ if size <= cap:
+ return False
+ f.seek(size - keep)
+ tail = f.read(keep)
+ f.seek(0)
+ os.ftruncate(f.fileno(), 0)
+ f.write(tail)
+ return True
+
+
+# ---------------------------------------------------------------- daemon 核心
+
+class Cmd:
+ """提交给串口线程的命令(带完成事件与结果盒)。"""
+
+ def __init__(self, action, **params):
+ self.action = action
+ self.params = params
+ self.done = threading.Event()
+ self.result = {"ok": False, "error": "not executed"}
+
+ def finish(self, result):
+ self.result = result
+ self.done.set()
+
+
+class Daemon:
+ def __init__(self):
+ self.cfg = load_config()
+ self.log_q = queue.Queue() # ("line", str) / ("clear", Event|None) → 写线程
+ self.cmd_q = queue.Queue() # Cmd → 串口线程
+ self.lock = threading.Lock()
+ self.server = None
+
+ self.state = "stopped" # stopped / active / error
+ self.error = None
+ self.port_name = None
+ self.port_baud = None
+ self.dtr = False
+ self.rts = False
+ self.port_since = None
+ self.ser = None
+ self.last_released = 0.0
+ self.last_data_at = 0.0
+ self.born = time.time()
+ self.rx_bytes = 0
+ self.tx_bytes = 0
+ self.rx_lines = 0
+ self.tx_lines = 0
+ self.errors = 0
+ self.last_rx_ts = None
+ self.last_rx_line = None
+ self.last_rx_at = 0.0
+ self.gen = int(load_meta().get("gen", 0))
+ self._stop = threading.Event()
+
+ # -- 日志(一律经写线程,保序 + 单写者) --
+
+ def emit(self, full_line):
+ self.log_q.put(("line", full_line))
+
+ def emit_clear(self, event=None):
+ self.log_q.put(("clear", event))
+
+ def sys_line(self, text):
+ return line_at(time.time(), "SYS", text)
+
+ def _save_meta(self):
+ save_meta({
+ "pid": os.getpid(),
+ "version": VERSION,
+ "gen": self.gen,
+ "port": ({"name": self.port_name, "baud": self.port_baud,
+ "dtr": self.dtr, "rts": self.rts} if self.port_name else None),
+ "since": self.port_since,
+ })
+
+ # -- 写线程(日志文件唯一写者) --
+
+ def writer_loop(self):
+ # a+b:追加写 + 可读(截头需从同一句柄读尾部;"ab" 在 Windows 禁读会崩)
+ f = open(LOG_FILE, "a+b")
+ try:
+ while not self._stop.is_set():
+ try:
+ kind, payload = self.log_q.get(timeout=0.2)
+ except queue.Empty:
+ continue
+ if kind == "line":
+ f.write(payload.encode("utf-8") + b"\n")
+ f.flush()
+ elif kind == "clear":
+ self.gen += 1
+ f.seek(0)
+ os.ftruncate(f.fileno(), 0)
+ f.write(self.sys_line("log cleared (gen=%d)" % self.gen).encode("utf-8") + b"\n")
+ f.flush()
+ self._save_meta()
+ if payload is not None:
+ payload.set()
+ size = f.tell()
+ if size > CAP_BYTES:
+ cap_truncate(f, size)
+ f.write(self.sys_line("log truncated to %dMB (cap=%dMB)"
+ % (KEEP_BYTES // 1048576, CAP_BYTES // 1048576)
+ ).encode("utf-8") + b"\n")
+ f.flush()
+ finally:
+ f.close()
+
+ # -- 串口线程(Serial 对象唯一持有者) --
+
+ def serial_loop(self):
+ splitter = LineSplitter()
+ while not self._stop.is_set():
+ ser = self.ser
+ if ser is not None:
+ try:
+ n = ser.in_waiting
+ if n:
+ data = ser.read(min(n, 4096))
+ if data:
+ t = time.time()
+ self.rx_bytes += len(data)
+ self.last_data_at = t
+ for ts, raw in splitter.feed(data, t):
+ self._rx_line(ts, raw)
+ except (serial.SerialException, OSError) as e:
+ self._port_error("read: %s" % e)
+ ser = None
+ if ser is not None and splitter.buf:
+ if time.time() - self.last_data_at > PARTIAL_FLUSH_S:
+ for ts, raw in splitter.flush_partial():
+ self._rx_line(ts, raw)
+ try:
+ cmd = self.cmd_q.get_nowait()
+ except queue.Empty:
+ cmd = None
+ if cmd is not None:
+ self._run_cmd(cmd)
+ else:
+ time.sleep(0.01)
+
+ def _rx_line(self, ts, raw):
+ if not raw:
+ return
+ text = decode_line(raw)
+ self.rx_lines += 1
+ self.last_rx_ts = ts
+ self.last_rx_line = text
+ self.last_rx_at = time.time()
+ self.emit(line_at(ts, "RX", text))
+
+ def _port_error(self, msg):
+ with self.lock:
+ if self.ser is not None:
+ try:
+ self.ser.close()
+ except (serial.SerialException, OSError):
+ pass
+ self.ser = None
+ self.state = "error"
+ self.error = msg
+ self.errors += 1
+ self.last_released = time.time()
+ self.emit(self.sys_line("serial error: %s (port released, reopen with 'open')" % msg))
+
+ def _close_port_locked(self):
+ if self.ser is not None:
+ try:
+ self.ser.close()
+ except (serial.SerialException, OSError):
+ pass
+ self.ser = None
+ self.port_name = None
+ self.port_baud = None
+ self.port_since = None
+ self.state = "stopped"
+ self.error = None
+ self.last_released = time.time()
+
+ # -- 命令执行(均在串口线程) --
+
+ def _run_cmd(self, cmd):
+ try:
+ if cmd.action == "open":
+ self._cmd_open(cmd)
+ elif cmd.action == "close":
+ self._cmd_close(cmd)
+ elif cmd.action == "send":
+ self._cmd_send(cmd)
+ else:
+ cmd.finish({"ok": False, "error": "unknown action %s" % cmd.action})
+ except Exception as e: # 命令失败不得杀死串口线程
+ cmd.finish({"ok": False, "error": "cmd %s failed: %s" % (cmd.action, e)})
+
+ def _cmd_open(self, cmd):
+ p = cmd.params
+ name = p.get("port") or self.cfg.get("portName") or ""
+ if not name:
+ cmd.finish({"ok": False, "error": "no port specified (and no portName in config)"})
+ return
+ baud = int(p.get("baud") or self.cfg.get("baudRate") or 115200)
+ dtr = bool(p.get("dtr", False))
+ rts = bool(p.get("rts", False))
+
+ with self.lock:
+ if self.ser is not None:
+ if self.port_name == name and self.port_baud == baud:
+ cmd.finish({"ok": True, "port": name, "baud": baud, "gen": self.gen,
+ "since": self.port_since, "note": "already open (idempotent)"})
+ return
+ self._close_port_locked()
+
+ # 重开退避:距上次释放 <1.5s 或 daemon 启动 <1.5s(CH340 特性)
+ for since_ts in (self.last_released, self.born):
+ if since_ts:
+ wait = REOPEN_SETTLE_S - (time.time() - since_ts)
+ if wait > 0:
+ time.sleep(wait)
+ break
+
+ try:
+ # 先构造后 open:dtr/rts 状态写入 DCB 初始值(避免开端口瞬间的复位脉冲)
+ ser = serial.Serial()
+ ser.port = name
+ ser.baudrate = baud
+ ser.timeout = 0
+ ser.dtr = dtr
+ ser.rts = rts
+ ser.open()
+ except serial.SerialException as e:
+ msg = str(e)
+ names = sorted(self._list_port_names())
+ if name not in names:
+ # Windows 对不存在的 COM 口常报 Access Denied(而非 FileNotFound),
+ # 故以端口枚举结果为准,避免把"不存在"误报成"被占用"
+ err = "port not found: %s (available: %s)" % (name, ", ".join(names) or "(none)")
+ elif "Access is denied" in msg or "busy" in msg.lower():
+ err = "port busy: %s (held by another process)" % name
+ else:
+ err = "port open failed: %s (%s)" % (name, msg)
+ cmd.finish({"ok": False, "error": err})
+ return
+
+ with self.lock:
+ self.ser = ser
+ self.port_name = name
+ self.port_baud = baud
+ self.dtr = dtr
+ self.rts = rts
+ self.port_since = now_str()
+ self.state = "active"
+ self.error = None
+ self.last_data_at = time.time()
+ self.emit(self.sys_line("port opened %s %d (gen=%d)" % (name, baud, self.gen)))
+ self._save_meta()
+ cmd.finish({"ok": True, "port": name, "baud": baud, "dtr": dtr, "rts": rts,
+ "gen": self.gen, "since": self.port_since})
+
+ def _cmd_close(self, cmd):
+ with self.lock:
+ if self.ser is None:
+ cmd.finish({"ok": True, "note": "port not open"})
+ return
+ self._close_port_locked()
+ self.emit(self.sys_line("port closed"))
+ self._save_meta()
+ cmd.finish({"ok": True})
+
+ def _cmd_send(self, cmd):
+ p = cmd.params
+ if p.get("hex"):
+ try:
+ data = bytes.fromhex(p["hex"])
+ except ValueError:
+ cmd.finish({"ok": False, "error": "bad --hex value"})
+ return
+ else:
+ data = (p.get("text") or "").encode("utf-8")
+ if p.get("nl"):
+ data += b"\r\n"
+ with self.lock:
+ ser = self.ser
+ if ser is None:
+ cmd.finish({"ok": False, "error": "port not open (run 'open' first)"})
+ return
+ try:
+ ser.write(data)
+ except (serial.SerialException, OSError) as e:
+ self._port_error("write: %s" % e)
+ cmd.finish({"ok": False, "error": "write failed: %s" % e})
+ return
+ self.tx_bytes += len(data)
+ self.tx_lines += 1
+ text = data.decode("utf-8", "replace").replace("\r", "\\r").replace("\n", "\\n")
+ self.emit(line_at(time.time(), "TX", text if text else "HEX:" + data.hex()))
+ cmd.finish({"ok": True, "bytes": len(data)})
+
+ @staticmethod
+ def _list_port_names():
+ try:
+ return [p.device for p in serial.tools.list_ports.comports()]
+ except Exception:
+ return []
+
+ # -- 查询 --
+
+ def status(self):
+ with self.lock:
+ port = {"ready": self.ser is not None and self.state != "error",
+ "name": self.port_name, "baud": self.port_baud,
+ "dtr": self.dtr, "rts": self.rts, "since": self.port_since}
+ counters = {"rx_bytes": self.rx_bytes, "tx_bytes": self.tx_bytes,
+ "rx_lines": self.rx_lines, "tx_lines": self.tx_lines,
+ "errors": self.errors}
+ state, err = self.state, self.error
+ gen = self.gen
+ last = None
+ if self.last_rx_at:
+ last = {"ts": datetime.fromtimestamp(self.last_rx_ts).astimezone()
+ .isoformat(timespec="milliseconds"),
+ "line": self.last_rx_line,
+ "age_ms": int(max(0, time.time() - self.last_rx_at) * 1000)}
+ size = LOG_FILE.stat().st_size if LOG_FILE.exists() else 0
+ return {"ok": True,
+ "daemon": {"pid": os.getpid(), "version": VERSION,
+ "uptime_s": int(time.time() - self.born)},
+ "port": port, "counters": counters, "last_rx": last,
+ "log": {"path": str(LOG_FILE), "size": size, "gen": gen},
+ "state": state, "error": err}
+
+ def wait_idle(self, settle_s, max_s):
+ t0 = time.time()
+ last = self.rx_bytes
+ last_change = t0
+ while time.time() - t0 < max_s:
+ time.sleep(0.1)
+ if self.rx_bytes != last:
+ last = self.rx_bytes
+ last_change = time.time()
+ elif time.time() - last_change >= settle_s:
+ break
+ if last == 0:
+ reason = "no_data"
+ elif time.time() - last_change >= settle_s:
+ reason = "idle"
+ else:
+ reason = "timeout"
+ return {"ok": True, "rx_bytes": last, "idle": reason == "idle",
+ "waited_ms": int((time.time() - t0) * 1000), "reason": reason}
+
+ # -- 生命周期 --
+
+ def run(self):
+ STATE_DIR.mkdir(parents=True, exist_ok=True)
+ self._save_meta()
+ threading.Thread(target=self.writer_loop, name="writer", daemon=True).start()
+ threading.Thread(target=self.serial_loop, name="serial", daemon=True).start()
+ port = self.cfg["httpPort"]
+ try:
+ self.server = _ExclusiveHTTPServer(("127.0.0.1", port), _Handler)
+ except OSError as e:
+ print("serial_tool: HTTP port %d busy (another instance running?): %s"
+ % (port, e), file=sys.stderr)
+ sys.exit(3)
+ self.server.daemon_threads = True
+ _Handler.daemon = self
+ print("serial_tool daemon %s listening on 127.0.0.1:%d (pid %d)"
+ % (VERSION, port, os.getpid()), flush=True)
+ try:
+ self.server.serve_forever()
+ except KeyboardInterrupt:
+ pass
+ finally:
+ self._stop.set()
+ self.server.server_close()
+
+
+class _ExclusiveHTTPServer(ThreadingHTTPServer):
+ """强制独占端口绑定。http.server 默认 SO_REUSEADDR 在 Windows 允许多个活跃
+ socket 绑同一端口(Linux 不会),会致多 daemon 并存、请求随机分发,必须禁用。"""
+
+ allow_reuse_address = False
+
+ def server_bind(self):
+ import socket as _socket
+ if os.name == "nt":
+ # SO_EXCLUSIVEADDRPLACE = 0x0040;部分发行版 socket 模块未导出,用数值兜底
+ excl = getattr(_socket, "SO_EXCLUSIVEADDRPLACE", 0x0040)
+ try:
+ self.socket.setsockopt(_socket.SOL_SOCKET, excl, 1)
+ except (OSError, AttributeError):
+ pass # 退回 allow_reuse_address=False(Windows 同样拒绝二次绑定)
+ super().server_bind()
+
+
+class _Handler(BaseHTTPRequestHandler):
+ daemon = None # 由 Daemon.run 注入
+
+ def log_message(self, fmt, *args):
+ pass # 静默(daemon 输出已重定向到 state/daemon.log)
+
+ def _send(self, code, obj):
+ body = json.dumps(obj, ensure_ascii=False).encode("utf-8")
+ self.send_response(code)
+ self.send_header("Content-Type", "application/json; charset=utf-8")
+ self.send_header("Content-Length", str(len(body)))
+ self.end_headers()
+ self.wfile.write(body)
+
+ def _body(self):
+ n = int(self.headers.get("Content-Length") or 0)
+ if n <= 0:
+ return {}
+ try:
+ raw = json.loads(self.rfile.read(n).decode("utf-8"))
+ return raw if isinstance(raw, dict) else {}
+ except (ValueError, UnicodeDecodeError):
+ return {}
+
+ def _run_cmd(self, cmd, wait_s=20):
+ self.daemon.cmd_q.put(cmd)
+ if not cmd.done.wait(wait_s):
+ self._send(504, {"ok": False, "error": "daemon busy (cmd not finished in %ds)" % wait_s})
+ return None
+ code = 200 if cmd.result.get("ok") else 400
+ self._send(code, cmd.result)
+ return cmd.result
+
+ def do_GET(self):
+ d = self.daemon
+ if self.path == "/ping":
+ self._send(200, {"ok": True, "version": VERSION, "pid": os.getpid()})
+ elif self.path == "/status":
+ self._send(200, d.status())
+ elif self.path == "/ports":
+ ports = [{"name": p.device, "description": p.description, "hwid": p.hwid}
+ for p in serial.tools.list_ports.comports()]
+ self._send(200, {"ok": True, "ports": ports,
+ "configured": d.cfg.get("portName", "")})
+ else:
+ self._send(404, {"ok": False, "error": "no such endpoint: %s" % self.path})
+
+ def do_POST(self):
+ d = self.daemon
+ if self.path == "/open":
+ body = self._body()
+ params = {k: body[k] for k in ("port", "baud", "dtr", "rts") if k in body}
+ self._run_cmd(Cmd("open", **params))
+ elif self.path == "/close":
+ self._run_cmd(Cmd("close"))
+ elif self.path == "/send":
+ body = self._body()
+ params = {k: body[k] for k in ("text", "hex", "nl") if k in body}
+ self._run_cmd(Cmd("send", **params))
+ elif self.path == "/clear":
+ ev = threading.Event()
+ d.emit_clear(ev)
+ if not ev.wait(5):
+ self._send(500, {"ok": False, "error": "clear timeout"})
+ return
+ self._send(200, {"ok": True, "gen": d.gen})
+ elif self.path == "/wait-idle":
+ body = self._body()
+ settle = max(0.5, float(body.get("settle_ms", 8000)) / 1000.0)
+ mx = max(1.0, float(body.get("max_ms", 30000)) / 1000.0)
+ self._send(200, d.wait_idle(settle, mx))
+ elif self.path == "/stop":
+ cmd = Cmd("close")
+ d.cmd_q.put(cmd)
+ cmd.done.wait(5)
+ d._stop.set()
+ if d.server is not None:
+ threading.Timer(0.3, d.server.shutdown).start()
+ self._send(200, {"ok": True, "stopped": True})
+ else:
+ self._send(404, {"ok": False, "error": "no such endpoint: %s" % self.path})
+
+
+# ---------------------------------------------------------------- CLI 侧
+
+class ToolError(Exception):
+ def __init__(self, msg, exit_code):
+ super().__init__(msg)
+ self.exit_code = exit_code
+
+
+def http_port():
+ return load_config().get("httpPort") or DEFAULT_HTTP_PORT
+
+
+def http_request(method, path, body=None, timeout=DEFAULT_TIMEOUT_S):
+ url = "http://127.0.0.1:%d%s" % (http_port(), path)
+ data = json.dumps(body, ensure_ascii=False).encode("utf-8") if body is not None else None
+ req = urllib.request.Request(url, data=data, method=method,
+ headers={"Content-Type": "application/json"})
+ try:
+ with urllib.request.urlopen(req, timeout=timeout) as r:
+ return json.loads(r.read().decode("utf-8"))
+ except urllib.error.HTTPError as e:
+ # 4xx/5xx 也是 daemon 的正常应答(领域错误 JSON),解析返回而非当作不可达
+ try:
+ return json.loads(e.read().decode("utf-8"))
+ except (ValueError, UnicodeDecodeError, OSError):
+ raise ToolError("daemon HTTP %d (non-JSON body)" % e.code, 2)
+
+
+def spawn_daemon():
+ STATE_DIR.mkdir(parents=True, exist_ok=True)
+ log_fh = open(DAEMON_LOG, "ab")
+ kwargs = {}
+ if os.name == "nt":
+ kwargs["creationflags"] = subprocess.CREATE_NO_WINDOW
+ subprocess.Popen([sys.executable, str(SCRIPT), "--serve"],
+ stdout=log_fh, stderr=subprocess.STDOUT,
+ cwd=str(REPO_ROOT), **kwargs)
+ log_fh.close()
+
+
+def wait_ready(timeout=DEFAULT_TIMEOUT_S):
+ t0 = time.time()
+ while time.time() - t0 < timeout:
+ try:
+ http_request("GET", "/ping", timeout=0.5)
+ return True
+ except (urllib.error.URLError, OSError, ValueError):
+ time.sleep(0.1)
+ return False
+
+
+def _try_kill(pid):
+ try:
+ os.kill(int(pid), signal.SIGTERM) # Windows 下 = TerminateProcess
+ return True
+ except (OSError, ValueError):
+ return False
+
+
+def call_daemon(args, method, path, body=None):
+ """调 daemon;连接失败时自动拉起并重试一次。"""
+ timeout = getattr(args, "timeout", DEFAULT_TIMEOUT_S)
+ try:
+ return http_request(method, path, body, timeout)
+ except (urllib.error.URLError, OSError) as e:
+ if args.command == "stop":
+ killed = False
+ pid = load_meta().get("pid")
+ if pid:
+ killed = _try_kill(pid)
+ return {"ok": True, "stopped": True, "note": "daemon not running",
+ "killed_zombie": killed}
+ if args.command == "start":
+ raise ToolError("daemon not running: %s" % e, 2)
+ spawn_daemon()
+ if not wait_ready(timeout):
+ raise ToolError("daemon start timeout (no response on 127.0.0.1:%d within %.0fs)"
+ % (http_port(), timeout), 2)
+ try:
+ return http_request(method, path, body, timeout)
+ except (urllib.error.URLError, OSError) as e2:
+ raise ToolError("daemon unresponsive after auto-start: %s" % e2, 2)
+
+
+# -- 各命令 --
+
+def cmd_ports(args):
+ ports = [{"name": p.device, "description": p.description, "hwid": p.hwid}
+ for p in serial.tools.list_ports.comports()]
+ return {"ok": True, "ports": ports, "configured": load_config().get("portName", "")}
+
+
+def cmd_open(args):
+ body = {}
+ if args.port:
+ body["port"] = args.port
+ if args.baud:
+ body["baud"] = args.baud
+ if args.dtr is not None:
+ body["dtr"] = args.dtr
+ if args.rts is not None:
+ body["rts"] = args.rts
+ return call_daemon(args, "POST", "/open", body)
+
+
+def cmd_close(args):
+ return call_daemon(args, "POST", "/close", {})
+
+
+def cmd_send(args):
+ body = {}
+ if args.hex:
+ body["hex"] = args.hex
+ else:
+ body["text"] = args.text
+ if args.nl:
+ body["nl"] = True
+ return call_daemon(args, "POST", "/send", body)
+
+
+def cmd_status(args):
+ return call_daemon(args, "GET", "/status")
+
+
+def cmd_log(args):
+ gen = int(load_meta().get("gen", 0))
+ if not LOG_FILE.exists():
+ return {"ok": True, "gen": gen, "lines": [], "note": "log file not created yet"}
+ with open(LOG_FILE, "r", encoding="utf-8", errors="replace") as f:
+ lines = f.read().splitlines()
+ if args.tail:
+ lines = lines[-args.tail:]
+ if args.grep:
+ lines = [l for l in lines if args.grep in l]
+ return {"ok": True, "gen": gen, "lines": lines}
+
+
+def cmd_clear(args):
+ return call_daemon(args, "POST", "/clear", {})
+
+
+def cmd_wait_idle(args):
+ return call_daemon(args, "POST", "/wait-idle",
+ {"settle_ms": args.settle_ms, "max_ms": args.max_ms})
+
+
+def cmd_start(args, force=False):
+ """拉起 daemon。force=True(restart 用):先确保旧实例彻底退出再拉新实例,
+ 保证加载的是当前代码(独占绑定保证任何时刻只有一个 daemon)。"""
+ alive = False
+ try:
+ http_request("GET", "/ping", timeout=1)
+ alive = True
+ except (urllib.error.URLError, OSError):
+ alive = False
+ if alive and not force:
+ st = http_request("GET", "/status", timeout=1)
+ return {"ok": True, "already_running": True,
+ "pid": st.get("daemon", {}).get("pid")}
+ if alive:
+ # restart:先优雅停(关端口),再强杀,确认真正退出
+ try:
+ http_request("POST", "/stop", timeout=5)
+ except (urllib.error.URLError, OSError):
+ pass
+ for attempt in (1, 2):
+ deadline = time.time() + 3
+ while time.time() < deadline:
+ try:
+ http_request("GET", "/ping", timeout=0.5)
+ time.sleep(0.2)
+ except (urllib.error.URLError, OSError):
+ break
+ else:
+ if attempt == 1:
+ pid = load_meta().get("pid")
+ if pid:
+ _try_kill(pid)
+ continue
+ raise ToolError("旧 daemon 仍在响应(stop/kill 均无效),放弃启动", 2)
+ break
+ spawn_daemon()
+ if not wait_ready(args.timeout):
+ raise ToolError("daemon start timeout (no response within %.0fs)" % args.timeout, 2)
+ try:
+ st = http_request("GET", "/status", timeout=1)
+ return {"ok": True, "already_running": False,
+ "pid": st.get("daemon", {}).get("pid")}
+ except (urllib.error.URLError, OSError):
+ return {"ok": True, "started": True}
+
+
+def cmd_stop(args):
+ try:
+ http_request("POST", "/stop", timeout=args.timeout)
+ except (urllib.error.URLError, OSError):
+ killed = False
+ pid = load_meta().get("pid")
+ if pid:
+ killed = _try_kill(pid)
+ return {"ok": True, "stopped": True, "note": "daemon not responding",
+ "killed_zombie": killed}
+ deadline = time.time() + 5
+ while time.time() < deadline:
+ try:
+ http_request("GET", "/ping", timeout=0.5)
+ time.sleep(0.2)
+ except (urllib.error.URLError, OSError):
+ return {"ok": True, "stopped": True}
+ pid = load_meta().get("pid")
+ if pid:
+ _try_kill(pid)
+ return {"ok": True, "stopped": True, "note": "forced kill (pid %s)" % pid}
+
+
+# ---------------------------------------------------------------- 无头自检
+
+def run_selftest():
+ results = []
+
+ def check(name, cond, detail=""):
+ results.append((name, bool(cond), detail))
+
+ # T1 行拆分:CRLF / 多行 / 残行冲刷
+ sp = LineSplitter()
+ got = [l for _, l in sp.feed(b"abc\r\n", 1000.0)]
+ check("T1a CRLF 行", got == [b"abc"], repr(got))
+ got = [l for _, l in sp.feed(b"de\nf\n", 1001.0)]
+ check("T1b 多行", got == [b"de", b"f"], repr(got))
+ check("T1c 残行不入文件", sp.feed(b"ghi", 1002.0) == [] and sp.buf == b"ghi")
+ got = [l for _, l in sp.flush_partial()]
+ check("T1d 残行冲刷", got == [b"ghi"], repr(got))
+ got = sp.feed(b"\n\n", 1003.0)
+ check("T1e 空行忽略", all(not raw for _, raw in got) and sp.buf == b"", repr(got))
+
+ # T2 解码回退链
+ check("T2a UTF-8", decode_line("中文ok".encode("utf-8")) == "中文ok")
+ check("T2b GBK", decode_line("中文".encode("gbk")) == "中文")
+ check("T2c HEX 兜底", decode_line(b"\xff\xfe\x01") == "HEX:fffe01")
+
+ # T3 时间戳格式
+ s = line_at(time.time(), "RX", "x")
+ check("T3 行格式", s.startswith("[RX] ") and "T" in s.split(" ")[1], s)
+
+ # T4 截头算法(保留尾部 + 截后继续追加)
+ pat = b"0123456789abcdef"
+ size = 1_100_000
+ keep = 512 * 1024
+ fd, path = tempfile.mkstemp(prefix="serial_tool_selftest_")
+ os.close(fd)
+ try:
+ with open(path, "wb") as f:
+ f.write((pat * (size // len(pat) + 1))[:size])
+ with open(path, "r+b") as f:
+ cap_truncate(f, size, keep=keep, cap=keep // 2)
+ f.write(b"TAIL")
+ f.seek(0)
+ data = f.read()
+ expect = (pat * (size // len(pat) + 1))[:size][-keep:] + b"TAIL"
+ check("T4a 截头保尾", len(data) == keep + 4 and data == expect,
+ "len=%d expect=%d" % (len(data), len(expect)))
+ finally:
+ try:
+ os.remove(path)
+ except OSError:
+ pass
+
+ width = 46
+ all_ok = True
+ for name, ok, detail in results:
+ all_ok &= ok
+ print("[%s] %s %s" % ("PASS" if ok else "FAIL", name, detail))
+ print("selftest: %d/%d passed" % (sum(1 for _, ok, _ in results if ok), len(results)))
+ print("SELFTEST " + ("PASS" if all_ok else "FAIL"))
+ return 0 if all_ok else 1
+
+
+# ---------------------------------------------------------------- 入口
+
+def build_parser():
+ common = argparse.ArgumentParser(add_help=False)
+ common.add_argument("--timeout", type=float, default=DEFAULT_TIMEOUT_S,
+ help="CLI->daemon 硬超时秒数(默认 %.0f)" % DEFAULT_TIMEOUT_S)
+
+ parser = argparse.ArgumentParser(
+ prog="serial_tool",
+ description="Python 串口助手:常驻 daemon + 薄 CLI + 日志文件即接口")
+ parser.add_argument("--serve", action="store_true", help=argparse.SUPPRESS)
+ sub = parser.add_subparsers(dest="command")
+
+ sub.add_parser("ports", parents=[common], help="枚举 COM 口(本地执行,不依赖 daemon)")
+
+ p = sub.add_parser("open", parents=[common], help="开串口(幂等;daemon 未运行会自动拉起)")
+ p.add_argument("--port", help="COM 口(默认取 .opencode/serial_config.json)")
+ p.add_argument("--baud", type=int, help="波特率(默认取配置)")
+ p.add_argument("--dtr", type=int, choices=[0, 1], help="显式置 DTR(默认不翻转)")
+ p.add_argument("--rts", type=int, choices=[0, 1], help="显式置 RTS(默认不翻转)")
+
+ sub.add_parser("close", parents=[common], help="关串口")
+
+ p = sub.add_parser("send", parents=[common], help="发送 TX 字节(进日志)")
+ p.add_argument("text", nargs="?", help="文本(UTF-8 编码发送)")
+ p.add_argument("--hex", help="改发 hex 字节,如 0D0A(与 text 二选一)")
+ p.add_argument("--nl", action="store_true", help="末尾追加 CRLF")
+
+ sub.add_parser("status", parents=[common], help="全状态 JSON")
+
+ p = sub.add_parser("log", parents=[common], help="读日志(直接读文件,daemon 死也能用)")
+ p.add_argument("--tail", type=int, default=50, help="取末 N 行(默认 50;0 = 全部)")
+ p.add_argument("--grep", help="仅含子串的行")
+
+ sub.add_parser("clear", parents=[common], help="清空日志(代际 +1)")
+
+ p = sub.add_parser("wait-idle", parents=[common], help="等 RX 静默(@test 用)")
+ p.add_argument("--settle-ms", type=int, default=8000,
+ help="静默判据毫秒(默认 8000:boot 有 3~4s 自然静默间隙)")
+ p.add_argument("--max-ms", type=int, default=30000, help="最长等待毫秒(默认 30000)")
+
+ sub.add_parser("start", parents=[common], help="拉起 daemon(已在跑则直接返回)")
+ sub.add_parser("stop", parents=[common], help="停止 daemon(关端口后退出)")
+ sub.add_parser("restart", parents=[common], help="stop + start")
+ sub.add_parser("selftest", parents=[common], help="无头自检(无需硬件)")
+
+ return parser
+
+
+def main(argv=None):
+ if hasattr(sys.stdout, "reconfigure"):
+ sys.stdout.reconfigure(encoding="utf-8", errors="replace")
+ sys.stderr.reconfigure(encoding="utf-8", errors="replace")
+
+ parser = build_parser()
+ args = parser.parse_args(argv)
+
+ if args.serve:
+ Daemon().run()
+ return 0
+ if not args.command:
+ parser.print_help()
+ return 3
+
+ try:
+ if args.command == "selftest":
+ return run_selftest()
+ handlers = {
+ "ports": cmd_ports, "open": cmd_open, "close": cmd_close,
+ "send": cmd_send, "status": cmd_status, "log": cmd_log,
+ "clear": cmd_clear, "wait-idle": cmd_wait_idle,
+ "start": cmd_start, "stop": cmd_stop,
+ }
+ if args.command == "send" and not args.text and not args.hex:
+ raise ToolError("send 需要 text 或 --hex", 3)
+ if args.command == "restart":
+ result = cmd_start(args, force=True)
+ else:
+ result = handlers[args.command](args)
+ print(json.dumps(result, ensure_ascii=False, indent=2))
+ return 0 if result.get("ok", False) else 1
+ except ToolError as e:
+ print(json.dumps({"ok": False, "error": str(e)}, ensure_ascii=False, indent=2))
+ return e.exit_code
+ except urllib.error.URLError as e:
+ print(json.dumps({"ok": False, "error": "daemon unreachable: %s" % e.reason},
+ ensure_ascii=False, indent=2))
+ return 2
+ except KeyboardInterrupt:
+ return 130
+
+
+if __name__ == "__main__":
+ sys.exit(main())