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())