diff --git a/.opencode/skills/draw-diagram/SKILL.md b/.dsh/skills/draw-diagram/SKILL.md
similarity index 94%
rename from .opencode/skills/draw-diagram/SKILL.md
rename to .dsh/skills/draw-diagram/SKILL.md
index ef72da0f..b193ab02 100644
--- a/.opencode/skills/draw-diagram/SKILL.md
+++ b/.dsh/skills/draw-diagram/SKILL.md
@@ -1,6 +1,7 @@
---
name: draw-diagram
-description: docs/ 文档技术配图绘制流程(精确布局图用 SVG 生成器 / 拓扑图用 Mermaid):挑脚本 → 改顶部数据 → 重跑生成 → 目检 → md 相对路径引用 → 脚本+产物一并提交,产物统一放 docs/images/。Use when 需要新增/修改 docs/ 文档的技术配图(协议帧、分层架构、依赖/就绪屏障、流程图、时序、状态机),或用户要求"画个图 / 生成图 / 更新架构图 / 帧格式图"。
+description: docs/ 文档技术配图绘制流程(精确布局图用 SVG 生成器 / 拓扑图用 Mermaid):挑脚本 → 改顶部数据 → 重跑生成 → 目检 → md 相对路径引用 → 脚本+产物一并提交,产物统一放 docs/images/。
+whenToUse: 需要新增/修改 docs/ 文档的技术配图(协议帧、分层架构、依赖/就绪屏障、流程图、时序、状态机),或用户要求"画个图 / 生成图 / 更新架构图 / 帧格式图"时。
---
# 文档配图绘制(SVG / Mermaid)
diff --git a/.opencode/skills/font-regen/SKILL.md b/.dsh/skills/font-regen/SKILL.md
similarity index 93%
rename from .opencode/skills/font-regen/SKILL.md
rename to .dsh/skills/font-regen/SKILL.md
index 9f921978..27585a51 100644
--- a/.opencode/skills/font-regen/SKILL.md
+++ b/.dsh/skills/font-regen/SKILL.md
@@ -1,6 +1,7 @@
---
name: font-regen
-description: 重生成中文子集字体 ui_font_cn_12(extract_cn 提取 → make_font 生成 → diff 检查,固定 4 步,.venv 解释器)。Use when UI 代码新增中文文案、用户报告屏幕缺字/空白块、或提交任何含新汉字的 UI 字符串之前。
+description: 重生成中文子集字体 ui_font_cn_12(extract_cn 提取 → make_font 生成 → diff 检查,固定 4 步,.venv 解释器)。
+whenToUse: UI 代码新增中文文案、用户报告屏幕缺字/空白块、或提交任何含新汉字的 UI 字符串之前。
---
# 中文字体重生成
diff --git a/.dsh/skills/keil-build/.gitignore b/.dsh/skills/keil-build/.gitignore
new file mode 100644
index 00000000..c18dd8d8
--- /dev/null
+++ b/.dsh/skills/keil-build/.gitignore
@@ -0,0 +1 @@
+__pycache__/
diff --git a/.dsh/skills/keil-build/SKILL.md b/.dsh/skills/keil-build/SKILL.md
new file mode 100644
index 00000000..f2669f28
--- /dev/null
+++ b/.dsh/skills/keil-build/SKILL.md
@@ -0,0 +1,49 @@
+---
+name: keil-build
+description: Keil MDK-ARM 命令行编译闭环(自带独立脚本 keil_build.py,纯标准库,拷到任何 Keil 项目即用):UV4 -b 构建 → "0 错误 0 警告"判定 → build_log 定位报错。封装入口约定、退出码判读、UV4 改写工程文件的入库陷阱。
+whenToUse: 需要编译 Keil MDK 工程、验证代码改动是否 0 错误 0 警告、或解读 UV4 构建日志时。
+---
+
+# keil-build — Keil 命令行编译(独立可运行 skill)
+
+**执行体 = 本 skill 自带脚本** `.dsh/skills/keil-build/scripts/keil_build.py`
+(纯标准库,任意 python3;拷整个 skill 目录到别的 Keil 项目即可用,不依赖仓库脚本)。
+
+```powershell
+# cwd = 项目根目录
+& $PY ".dsh\skills\keil-build\scripts\keil_build.py"
+# 覆盖:--project X.uvprojx --uv4 UV4.exe --target 名字 --log 路径 --timeout 600
+```
+
+解析顺序(命令行 > 项目档案 `.serialtest/config.json` > 环境 > 探测):
+工程 `uvprojx`(档案缺省时自动搜 cwd 下唯一 `*.uvprojx`);UV4 `uv4` >
+`$DSH_KEIL_UV4` > `$DTU_KEIL_UV4` > PATH(**显式设了但路径无效 = 立即报错,不静默回退**)。
+退出码:`0`=PASS(0错0警) / `1`=构建失败(打印报错行)/ `2`=UV4 未找到/无效 / `3`=用法。
+
+## 本仓库
+
+仓库原生入口 `& "MDK-ARM\build.bat"`(`@build` 约定)与本 skill 脚本判定口径一致
+(先删旧日志 → `-b -j0 -o` → 无 `error:` 且含 `0 Error(s), 0 Warning(s)`),
+两者任选;`@build` 文档口径不变。环境变量 `DTU_KEIL_UV4`(用户级,指向 UV4.exe)。
+
+## 铁律与陷阱(实测教训)
+
+- **任何代码改动后必须先编译验证(0 错误 0 警告)再谈烧录/提交**——编译不过就进
+ 硬件闭环只会把编译错误伪装成运行问题。
+- **警告也算失败**:判 PASS 只认退出码 0,别只看"有没有 error:"。
+- **`MDK-ARM/*.uvprojx` 每次构建后被 UV4 自动改写,改动一律不入库**:UV4 会把工程
+ XML 归一化成本机风格(`` → `` 等)并把 `PackID` 降到本机 DFP 版本。
+ 判断:`git diff` 里只见 XML 格式/`PackID` 差异而**无源文件挂载增删** →
+ `git checkout -- ` 丢弃;只有真的增删了源文件(grep 文件名计数变化)才提交。
+- 报错定位读日志别只看终端回显:本仓库 = `MDK-ARM\build_log.txt`。
+- **exit 2 先分清"真没设"还是"会话没继承"**(2026-10-10 实测):agent 的 shell 由
+ 常驻服务派生,服务启动**之后**设置的用户级环境变量不会自动进来。查
+ `[Environment]::GetEnvironmentVariable('DTU_KEIL_UV4','User')`,有值就
+ `$env:DTU_KEIL_UV4 = <该值>` 注入本会话再跑,别急着让用户去设。
+- 编码类报错(`#870-D: invalid multibyte character sequence` 等)先怀疑源文件
+ BOM/编码被改写,而不是代码本身(见仓库 AGENTS.md 编码铁律)。
+
+## 与其它 skill 的衔接
+
+- 烧录/回读 = `stlink-flash` skill;串口闭环 = `serial-boot-test` skill
+ (其档案 `flash_cmd`/`readback_cmd` 即 stlink-flash 的入口命令)。
diff --git a/.dsh/skills/keil-build/scripts/keil_build.py b/.dsh/skills/keil-build/scripts/keil_build.py
new file mode 100644
index 00000000..c4045cf3
--- /dev/null
+++ b/.dsh/skills/keil-build/scripts/keil_build.py
@@ -0,0 +1,188 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""keil_build.py - Keil MDK-ARM 命令行构建 + 判定(纯标准库,可移植 skill 版)。
+
+用法(**项目根目录**下运行,任意 python3):
+ python /scripts/keil_build.py
+ [--project X.uvprojx] [--uv4 UV4.exe] [--target 名字]
+ [--log build_log.txt] [--timeout 600]
+
+解析顺序(命令行 > 项目档案 > 环境 > 探测):
+ 工程 --project > 档案 "uvprojx" > 自动搜 cwd 下唯一 *.uvprojx
+ UV4 --uv4 > 档案 "uv4" > $DSH_KEIL_UV4 > $DTU_KEIL_UV4 > PATH
+ (环境变量**显式设了但路径无效 = 立即报错,不静默回退**)
+ 日志 --log > 档案 "build_log" > <工程目录>\\build_log.txt
+
+判定口径(与仓库 build.bat 一致,实测教训沉淀):
+ 1. 先删旧日志——UV4 -o 只在真跑了构建才写日志,残留旧日志会被当成本次结果
+ 2. UV4 单实例:IDE 开着时 -b 可能被转发给 IDE(退出码丢失)→ 只 WARN 不杀进程
+ 3. PASS = 日志存在 + 无 "error:"(大小写不敏感)+ 含 "0 Error(s), 0 Warning(s)"
+ (0E0W 精确匹配同时把"只有警告"的构建判 FAIL)
+
+退出码: 0=PASS(0错0警) 1=构建失败(看打印的报错行/日志) 2=UV4 未找到/无效 3=用法错误
+"""
+
+import argparse
+import json
+import os
+import shutil
+import subprocess
+import sys
+from pathlib import Path
+
+
+def find_config_file():
+ env = os.environ.get("DSH_SERIAL_CONFIG")
+ if env:
+ return Path(env)
+ cwd = Path.cwd()
+ candidates = [cwd / ".serialtest" / "config.json",
+ cwd / ".opencode" / "serial_config.json"]
+ for c in candidates:
+ if c.is_file():
+ return c
+ return candidates[0]
+
+
+def load_config():
+ """档案缺失不是错误;解析失败报错由调用方 exit 3。返回 (cfg, err)。"""
+ f = find_config_file()
+ if not f.is_file():
+ return {}, ""
+ try:
+ raw = json.loads(f.read_text(encoding="utf-8-sig"))
+ except (OSError, ValueError) as e:
+ return {}, "cannot parse {}: {}".format(f, e)
+ if not isinstance(raw, dict):
+ return {}, "config root is not an object: {}".format(f)
+ return raw, ""
+
+
+def find_uv4(cli_arg, cfg):
+ """UV4 解析。显式来源(--uv4/档案/环境变量)设了但无效 = 硬错误,不回退。"""
+ for label, val in (("--uv4", cli_arg),
+ ("config 'uv4'", cfg.get("uv4")),
+ ("$DSH_KEIL_UV4", os.environ.get("DSH_KEIL_UV4")),
+ ("$DTU_KEIL_UV4", os.environ.get("DTU_KEIL_UV4"))):
+ if val:
+ if Path(val).is_file():
+ return Path(val), label
+ raise SystemExit(("BUILD_ENV_ERROR: {} is set but not a file: {} "
+ "(fix the value; no silent fallback)").format(label, val))
+ w = shutil.which("UV4.exe") or shutil.which("UV4")
+ if w:
+ return Path(w), "PATH"
+ raise SystemExit("BUILD_ENV_ERROR: UV4.exe not found "
+ "(--uv4 / config 'uv4' / $DSH_KEIL_UV4 / $DTU_KEIL_UV4 / PATH)")
+
+
+def find_project(cli_arg, cfg):
+ if cli_arg:
+ p = Path(cli_arg)
+ if not p.is_file():
+ raise SystemExit("BUILD_ENV_ERROR: --project not found: {}".format(p))
+ return p
+ v = cfg.get("uvprojx")
+ if v:
+ p = Path(v)
+ if not p.is_file():
+ raise SystemExit("BUILD_ENV_ERROR: config 'uvprojx' not found: {}".format(p))
+ return p
+ hits = sorted(p for p in Path.cwd().rglob("*.uvprojx")
+ if not any(part.startswith(".") for part in p.parts))
+ if len(hits) == 1:
+ print("PROJ: auto-detected {}".format(hits[0]))
+ return hits[0]
+ if not hits:
+ raise SystemExit("BUILD_ENV_ERROR: no *.uvprojx under cwd "
+ "(--project or config 'uvprojx')")
+ raise SystemExit("BUILD_ENV_ERROR: multiple .uvprojx found, pick one "
+ "(--project or config 'uvprojx'):\n " +
+ "\n ".join(str(h) for h in hits))
+
+
+def main(argv=None):
+ for stream in (sys.stdout, sys.stderr):
+ try:
+ stream.reconfigure(encoding="utf-8", errors="replace")
+ except Exception:
+ pass
+
+ ap = argparse.ArgumentParser(description="Keil UV4 CLI build + verdict (pure stdlib)")
+ ap.add_argument("--project", default=None, help="*.uvprojx (default: config/auto-detect)")
+ ap.add_argument("--uv4", default=None, help="UV4.exe path")
+ ap.add_argument("--target", default=None, help="build target name (-t; default: project default)")
+ ap.add_argument("--log", default=None, help="build log path (default: /build_log.txt)")
+ ap.add_argument("--timeout", type=int, default=600, help="UV4 timeout seconds")
+ args = ap.parse_args(argv)
+
+ cfg, err = load_config()
+ if err:
+ print("BUILD_ENV_ERROR: {}".format(err))
+ return 3
+
+ proj = find_project(args.project, cfg)
+ uv4, src = find_uv4(args.uv4, cfg)
+ target = args.target or cfg.get("uv_target") or ""
+ log = Path(args.log or cfg.get("build_log") or (proj.parent / "build_log.txt"))
+
+ print("UV4: {} ({})".format(uv4, src))
+ print("PROJ: {} target={}".format(proj, target or "(default)"))
+ print("LOG: {}".format(log))
+
+ # UV4 单实例陷阱:IDE 开着时 -b 可能被转发(退出码丢失、GUI 弹出)——只 WARN
+ try:
+ tl = subprocess.run(["tasklist", "/fi", "imagename eq UV4.exe", "/nh"],
+ capture_output=True, timeout=10)
+ if b"UV4.exe" in (tl.stdout or b""):
+ print("WARN: UV4 IDE is running - this build may be forwarded to it. "
+ "Close the IDE for a deterministic CLI build.")
+ except (OSError, subprocess.TimeoutExpired):
+ pass
+
+ # 先删旧日志:UV4 -o 只在真跑构建时写日志,残留会被当成本次结果
+ try:
+ log.unlink()
+ except FileNotFoundError:
+ pass
+
+ cmd = [str(uv4), "-b", str(proj), "-j0", "-o", str(log)]
+ if target:
+ cmd.insert(3, "-t" + target)
+ try:
+ proc = subprocess.run(cmd, capture_output=True, timeout=args.timeout)
+ rc = proc.returncode
+ except subprocess.TimeoutExpired:
+ print("BUILD_FAIL: UV4 timed out after {}s (hung or forwarded to IDE?)".format(args.timeout))
+ return 1
+ print("UV4_RC: {}".format(rc))
+
+ if not log.is_file():
+ print("BUILD_FAIL: no build log produced - UV4 did not run or hung")
+ return 1
+ text = log.read_text(encoding="utf-8", errors="replace")
+ lines = text.splitlines()
+
+ errs = [l for l in lines if "error:" in l.lower()]
+ if errs:
+ print("BUILD_FAIL: {} error line(s) in log:".format(len(errs)))
+ for l in errs[:20]:
+ print(" | {}".format(l.strip()))
+ if len(errs) > 20:
+ print(" ... {} more".format(len(errs) - 20))
+ return 1
+ if "0 Error(s), 0 Warning(s)" not in text:
+ print("BUILD_FAIL: no '0 Error(s), 0 Warning(s)' marker in log "
+ "(warnings present or build incomplete). Tail:")
+ for l in lines[-15:]:
+ print(" | {}".format(l.strip()))
+ return 1
+ for l in lines:
+ if l.strip().startswith("Program Size:"):
+ print(l.strip())
+ print("BUILD_OK: 0 errors, 0 warnings")
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/.opencode/skills/new-source-file/SKILL.md b/.dsh/skills/new-source-file/SKILL.md
similarity index 95%
rename from .opencode/skills/new-source-file/SKILL.md
rename to .dsh/skills/new-source-file/SKILL.md
index e529b280..09806a2b 100644
--- a/.opencode/skills/new-source-file/SKILL.md
+++ b/.dsh/skills/new-source-file/SKILL.md
@@ -1,6 +1,7 @@
---
name: new-source-file
-description: 新增 .c 源文件的双端注册:MDK-ARM/STM32F407-Demo.uvprojx 与 simulator/CMakeLists.txt 均为显式文件列表,新文件必须手动加进两处再双端构建验证。Use when 新建 App/、Drivers/BSP/ 下的 .c 文件,或构建/运行报 undefined reference、"改了代码没生效" 而怀疑文件漏注册。
+description: 新增 .c 源文件的双端注册:MDK-ARM/STM32F407-Demo.uvprojx 与 simulator/CMakeLists.txt 均为显式文件列表,新文件必须手动加进两处再双端构建验证。
+whenToUse: 新建 App/、Drivers/BSP/ 下的 .c 文件,或构建/运行报 undefined reference、"改了代码没生效" 而怀疑文件漏注册时。
---
# 新文件双端注册
diff --git a/.dsh/skills/serial-assistant/.gitignore b/.dsh/skills/serial-assistant/.gitignore
new file mode 100644
index 00000000..1197108f
--- /dev/null
+++ b/.dsh/skills/serial-assistant/.gitignore
@@ -0,0 +1,4 @@
+# 运行时产物不入库(daemon 日志/状态、专用 venv、字节码缓存)
+scripts/state/
+venv/
+__pycache__/
diff --git a/.dsh/skills/serial-assistant/SKILL.md b/.dsh/skills/serial-assistant/SKILL.md
new file mode 100644
index 00000000..e537cf9c
--- /dev/null
+++ b/.dsh/skills/serial-assistant/SKILL.md
@@ -0,0 +1,61 @@
+---
+name: serial-assistant
+description: 常驻串口助手(采集层):daemon + 薄 CLI + 日志文件即接口。ports/open/send/log/clear/wait-idle/status 全套命令;RX/TX 全量历史落盘可直接 Read/Grep;一台机单 daemon 跨进程共享,自动拉起。serial-boot-test 等串口工作流的底座。
+whenToUse: 需要打开/关闭串口、发送命令或字节、查询串口历史日志、等 RX 静默(抓开机日志)、排查串口占用或端口参数问题时。
+---
+
+# serial-assistant — 串口助手(项目级 skill,采集层)
+
+单脚本 `.dsh/skills/serial-assistant/scripts/serial_tool.py`(Python + pyserial)。
+架构:CLI(短命进程,硬超时)→ HTTP JSON → daemon(detached 单实例,127.0.0.1:18765);
+daemon = 串口读线程(唯一 I/O 者)+ 日志写线程(唯一文件写者)。
+**历史 = `scripts/state/serial.log`**(纯文本、只含完整行;绝对路径以 `status` 的
+`log.path` 为准,直接 Read/Grep 即是历史,无采集窗口仪式)。
+
+## 前置条件
+
+解释器需装有 pyserial:优先项目自己的 venv;否则一次性建 skill 专用 venv:
+
+```powershell
+python -m venv ".dsh\skills\serial-assistant\venv"
+& ".dsh\skills\serial-assistant\venv\Scripts\python.exe" -m pip install pyserial
+```
+
+下文 `$PY` = 选定解释器,`$TOOL` = `.dsh\skills\serial-assistant\scripts\serial_tool.py`。
+**所有命令在项目根目录下运行**(cwd 决定配置源解析)。
+
+## 命令(全部 JSON 输出;退出码 0=成功 1=领域错误 2=daemon 不可达/超时 3=用法)
+
+```powershell
+& $PY $TOOL ports # 枚举 COM 口 + configured 标注 + 唯一候选 hint
+& $PY $TOOL open # 开串口(幂等;daemon 未跑自动拉起);--port/--baud 可覆盖
+& $PY $TOOL send "help" # 发文本;send --hex 0D0A --nl 发字节并追加 CRLF
+& $PY $TOOL log --tail 40 # 读日志;--grep P [-i] [--regex](先过滤后截尾)
+& $PY $TOOL clear # 清空日志(代际 +1,会话边界)
+& $PY $TOOL wait-idle # 等 RX 静默:--settle-ms 8000 --max-ms 30000 --first-timeout-ms 12000
+& $PY $TOOL status # 全状态 JSON(daemon 版本/端口/计数/rx_age_ms/log.path)
+& $PY $TOOL start|stop|restart # daemon 生命周期
+& $PY $TOOL selftest # 无头自检(行拆分/解码/截头/wait 判据,无需硬件)
+```
+
+**抓开机日志铁律**:`open` → `clear` → 烧录/复位 → `wait-idle` → 读日志校验标记
+(采集必须先于烧录,否则错过开机头几行)。
+
+## 配置源(串口参数单一配置源)
+
+解析顺序:`$DSH_SERIAL_CONFIG` > `/.serialtest/config.json` >
+`/.opencode/serial_config.json`(旧位置)。用到的键:`portName`、`baudRate`、
+`httpPort`(缺省 18765)。端口变化**只改这个文件**,改完必须 `restart`
+(daemon 启动时缓存配置)。
+
+## 陷阱(实测教训)
+
+- **一台机只有一个 daemon**(HTTP 口 `SO_EXCLUSIVEADDRUSE` 独占绑定)。多个项目的
+ serial_tool 副本协议兼容、可互连:`status` 的 `daemon.version` 辨归属
+ (本 skill 版 = `1.1.0-skill`),`log.path` 辨日志落点。
+- **`open` 报"拒绝访问/端口被占用"**:先 `status` 查是不是残留旧 daemon 占着口 →
+ `stop`(或 `taskkill /F /PID`)→ 重新 `open`。
+- **`wait-idle` 判据**:`no_data` = 本次等待期间零字节;首字节到达前不启动静默计时;
+ 返回的 `rx_bytes` 是本次增量。`settle-ms` 必须大于固件的自然静默间隙(常见 3~4s)。
+- **`open` 不带 `--dtr/--rts` = 保持当前线路状态**;显式给才翻转。
+- 日志 20MB 上限自动按行边界截头保尾 10MB;无换行残行 64KB 封顶。
diff --git a/.dsh/skills/serial-assistant/scripts/serial_tool.py b/.dsh/skills/serial-assistant/scripts/serial_tool.py
new file mode 100644
index 00000000..a47d53e5
--- /dev/null
+++ b/.dsh/skills/serial-assistant/scripts/serial_tool.py
@@ -0,0 +1,1420 @@
+#!/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 直接读文件即是历史)
+
+用法(**项目根目录**下运行,python 需装有 pyserial):
+ python /scripts/serial_tool.py <命令> [选项]
+
+命令:
+ ports 枚举 COM 口(CLI 本地执行,不依赖 daemon)
+ open / close 开(幂等)/ 关串口
+ send | --hex 发送 TX(进日志);--nl 追加 CRLF
+ status 全状态(单条 JSON)
+ log --tail N --grep P 读日志(先过滤后截尾;-i 忽略大小写 / --regex 正则)
+ clear 清空日志(原子截断 + 代际递增)
+ wait-idle 等 RX 静默(@test 用;--settle-ms / --max-ms / --first-timeout-ms)
+ start / stop / restart daemon 生命周期(其余命令会自动拉起 daemon)
+ selftest 无头自检(行拆分/解码回退/截头算法,无需硬件)
+
+退出码:0=成功 1=领域错误 2=daemon 不可达/超时 3=用法错误
+"""
+import argparse
+import json
+import os
+import queue
+import re
+import signal
+import socket
+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.1.0-skill"
+
+SCRIPT = Path(__file__).resolve()
+TOOL_DIR = SCRIPT.parent
+STATE_DIR = TOOL_DIR / "state" # 全局单 daemon、单日志(一台机一个串口采集面)
+LOG_FILE = STATE_DIR / "serial.log"
+META_FILE = STATE_DIR / "meta.json"
+DAEMON_LOG = STATE_DIR / "daemon.log"
+
+
+def find_config_file():
+ """串口配置源解析顺序(可移植版;cwd = 项目根目录):
+ 1. $DSH_SERIAL_CONFIG 环境变量(显式文件)
+ 2. /.serialtest/config.json (本 skill 约定)
+ 3. /.opencode/serial_config.json (旧位置,兼容既有仓库如 STM32F4-Base)
+ 都不存在时返回第 1 候选(错误信息照它报)。"""
+ env = os.environ.get("DSH_SERIAL_CONFIG")
+ if env:
+ return Path(env)
+ cwd = Path.cwd()
+ candidates = [cwd / ".serialtest" / "config.json",
+ cwd / ".opencode" / "serial_config.json"]
+ for c in candidates:
+ if c.is_file():
+ return c
+ return candidates[0]
+
+
+CONFIG_FILE = find_config_file()
+
+DEFAULT_HTTP_PORT = 18765
+CAP_BYTES = 20 * 1024 * 1024 # 日志体积上限
+KEEP_BYTES = 10 * 1024 * 1024 # 超限后保留的尾部
+PARTIAL_FLUSH_S = 0.2 # 残行冲刷窗口(秒)
+PARTIAL_MAX_BYTES = 64 * 1024 # 无换行残行的内存封顶(超限即当一行落地,见 LineSplitter.feed)
+REOPEN_SETTLE_S = 1.5 # 重开端口退避(CH340 释放后立即重开的 EOF 陷阱)
+DEFAULT_TIMEOUT_S = 5.0 # CLI→daemon 硬超时(秒;wait-idle 未显式给时按 --max-ms 推导)
+DAEMON_LOG_MAX = 1024 * 1024 # daemon.log 超此体积 → 拉起新实例前只保尾部
+DAEMON_LOG_KEEP = 256 * 1024
+
+
+# ---------------------------------------------------------------- 配置 / 元数据
+
+def load_config():
+ """读串口配置源(find_config_file 解析出的那个文件)。
+
+ 编码用 utf-8-sig(与 run_boot_test.py 同口径):纯 utf-8 遇 BOM 会让 json.load
+ 抛错,旧实现再把它静默吞掉 → 端口/波特率全回退默认 → 报 "no port specified",
+ 把配置问题误导成端口问题(2026-10-09 修)。解析失败不再静默:错误文本放进
+ configError,由 status / open / ports 显式回显。
+ """
+ cfg = {"portName": "", "baudRate": 115200, "httpPort": DEFAULT_HTTP_PORT,
+ "configError": ""}
+ try:
+ with open(CONFIG_FILE, "r", encoding="utf-8-sig") as f:
+ raw = json.load(f)
+ except FileNotFoundError:
+ cfg["configError"] = "config file not found: %s" % CONFIG_FILE
+ return cfg
+ except OSError as e:
+ cfg["configError"] = "cannot read config %s: %s" % (CONFIG_FILE, e)
+ return cfg
+ except ValueError as e:
+ cfg["configError"] = "bad JSON in %s: %s" % (CONFIG_FILE, e)
+ return cfg
+ if not isinstance(raw, dict):
+ cfg["configError"] = "config root is not an object: %s" % CONFIG_FILE
+ return cfg
+ cfg["portName"] = str(raw.get("portName", ""))
+ try:
+ cfg["baudRate"] = int(raw.get("baudRate", 115200))
+ cfg["httpPort"] = int(raw.get("httpPort", DEFAULT_HTTP_PORT))
+ except (TypeError, ValueError) as e:
+ cfg["configError"] = "bad baudRate/httpPort in %s: %s" % (CONFIG_FILE, e)
+ return cfg
+
+
+def port_hint(configured, names):
+ """配置里的端口不在位、而系统只有**一个**候选时,给出可照抄的建议。
+
+ 换 USB 口 / 换机器 COM 号就变(本机 COM7→COM6 实测)。旧实现只报
+ `port not found: COM7 (available: COM6)`,还得人自己判断改哪里;现单一候选
+ 直接给 hint(多候选不猜,避免把蓝牙/虚拟口当目标口)。
+ """
+ cands = [n for n in sorted(set(names)) if n != configured]
+ return cands[0] if len(cands) == 1 else None
+
+
+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))
+ # 残行封顶(2026-10-10):板子跑飞 / 烧录期吐二进制垃圾时,没有 \n 的字节会
+ # 一直留在内存(旧实现无上限,长会话能涨到几十 MB),而且后续每一条正常行
+ # 都被拼在这坨垃圾尾巴上,变成一条读不出来的超长行。超 PARTIAL_MAX_BYTES
+ # 就当一行落地(decode_line 会转成 HEX:),内存封顶 + 自动重新对齐换行。
+ if len(self.buf) >= PARTIAL_MAX_BYTES:
+ out.extend(self.flush_partial())
+ return out
+
+ def reset(self):
+ """丢弃残行:open / close / clear 的会话边界。
+
+ 旧实现残行跨会话留存——上一段会话末尾的半行(复位瞬间被截断的那行)会在
+ 下次 open 后被拼进新会话的第一行,开机日志的头一行因此不可读
+ (2026-10-10 修)。"""
+ self.buf = b""
+ self.buf_ts = 0.0
+
+ 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 只截尾部),
+ 故在同一句柄上:读尾部 → 截空 → 写回。单写者线程内执行,无并发风险。
+
+ 按行边界对齐(2026-10-10):旧实现按字节偏移取尾,截出来的**第一行是半行**
+ (前半截已被丢掉)——"日志文件只含完整行"的契约被破坏,Grep / verify_log
+ 读到的首行是垃圾,人读同样误导。现丢掉首个换行之前的残片(无换行则原样保留)。
+ """
+ if size <= cap:
+ return False
+ f.seek(size - keep)
+ tail = f.read(keep)
+ i = tail.find(b"\n")
+ if 0 <= i < len(tail) - 1:
+ tail = tail[i + 1:]
+ 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.splitter = LineSplitter()
+ # 会话边界计数:open / close / clear 递增,串口线程下一轮循环丢弃残行
+ # (跨线程只碰一个 int,避免直接改 splitter.buf 与 feed() 竞态)
+ self.splitter_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 = self.splitter
+ my_gen = self.splitter_gen
+ while not self._stop.is_set():
+ if self.splitter_gen != my_gen:
+ # open / close / clear:上一段会话的半行不得粘进新会话首行
+ splitter.reset()
+ my_gen = self.splitter_gen
+ 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()
+ self.splitter_gen += 1
+
+ # -- 命令执行(均在串口线程) --
+
+ 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:
+ err = "no port specified (and no portName in config)"
+ if self.cfg.get("configError"):
+ err += " [config: %s]" % self.cfg["configError"]
+ cmd.finish({"ok": False, "error": err})
+ return
+ baud = int(p.get("baud") or self.cfg.get("baudRate") or 115200)
+ # dtr/rts:**未显式给 = 保持现状**。旧实现把"没给"当 False,于是每次不带
+ # --dtr/--rts 的重开都会静默把 DTR/RTS 拉低(对走 DTR/RTS 复位电路的板子
+ # 等于莫名多了一次复位脉冲,2026-10-10 修)
+ dtr = bool(p["dtr"]) if p.get("dtr") is not None else self.dtr
+ rts = bool(p["rts"]) if p.get("rts") is not None else self.rts
+
+ with self.lock:
+ if self.ser is not None:
+ if self.port_name == name and self.port_baud == baud:
+ # 幂等重开。旧实现直接回 "already open",**显式给的 --dtr/--rts
+ # 被静默忽略**(第二次起 `open --dtr 1` 毫无作用,2026-10-10 修):
+ # 现按需现场应用并回显改了什么。
+ note = "already open (idempotent)"
+ applied = []
+ for key, attr in (("dtr", "dtr"), ("rts", "rts")):
+ if p.get(key) is None:
+ continue
+ want = bool(p[key])
+ if want == getattr(self, attr):
+ continue
+ try:
+ setattr(self.ser, attr, want)
+ setattr(self, attr, want)
+ applied.append("%s=%d" % (key, int(want)))
+ except (serial.SerialException, OSError) as e:
+ note += "; %s apply failed: %s" % (key, e)
+ if applied:
+ note += "; applied " + ", ".join(applied)
+ cmd.finish({"ok": True, "port": name, "baud": baud, "gen": self.gen,
+ "dtr": self.dtr, "rts": self.rts,
+ "since": self.port_since, "note": note})
+ 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)")
+ hint = port_hint(name, names)
+ if hint:
+ err += " [hint: only candidate is %s -> set portName in %s]" % (hint, CONFIG_FILE)
+ 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.splitter_gen += 1 # 新会话:丢掉上一段会话遗留的残行
+ 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),
+ "exclusive_bind": getattr(self.server, "exclusive_bound", False)},
+ "port": port, "counters": counters, "last_rx": last,
+ # rx_age_ms:距最后一个 RX 字节的毫秒数(null = 这个 daemon 还没收过
+ # 任何字节)。判"板子是不是根本没在说话"不必再自己减时间戳(2026-10-10)
+ "rx_age_ms": last["age_ms"] if last else None,
+ "log": {"path": str(LOG_FILE), "size": size, "gen": gen},
+ "state": state, "error": err,
+ "config_error": self.cfg.get("configError") or None}
+
+ def wait_idle(self, settle_s, max_s, first_timeout_s=None):
+ """等 RX 静默,返回 reason ∈ {idle, no_data, timeout}。
+
+ 2026-10-09 修三处(旧实现会让 @test 在"板子全程没输出"时误判收尾):
+ 1. no_data 判据:旧实现比的是"进入函数时 daemon 累计 rx_bytes 是否为 0"。
+ daemon 常驻(本机实测 rx_bytes=1.2MB)→ 永远 >0 → 板子一个字节都不发
+ 也返回 idle:true,@test 的 no_data 拦截形同虚设。现按**本次增量**判。
+ 2. 首字节门:旧实现从进入即开始计静默时间,板子第一行比 settle_ms 来得
+ 晚就提前收尾("开机日志缺尾巴"的另一半成因)。现首字节到达前不启动
+ settle 计时,改由 first_timeout_s 兜底(默认 = max_s)。
+ 3. 返回的 rx_bytes 改为**本次等待期间的增量**(旧实现回的是 daemon 累计
+ 值,调用方打出来的 WAIT: rx_bytes=1246761 毫无意义)。
+ """
+ t0 = time.time()
+ initial = self.rx_bytes
+ last = initial
+ last_change = 0.0 # 0.0 = 本次尚未收到任何字节
+ first_deadline = t0 + (first_timeout_s if first_timeout_s is not None else max_s)
+ reason = "timeout"
+ while True:
+ now = time.time()
+ if self.rx_bytes != last:
+ last = self.rx_bytes
+ last_change = now
+ if last_change:
+ if now - last_change >= settle_s:
+ reason = "idle"
+ break
+ elif now >= first_deadline:
+ reason = "no_data"
+ break
+ if now - t0 >= max_s:
+ reason = "timeout"
+ break
+ time.sleep(0.1)
+ return {"ok": True, "rx_bytes": last - initial, "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
+ exclusive_bound = False
+
+ def server_bind(self):
+ if os.name == "nt":
+ # 真名是 SO_EXCLUSIVEADDRUSE(值 -5 = ~SO_REUSEADDR)。旧代码写成
+ # "SO_EXCLUSIVEADDRPLACE" 并用数值 0x0040 兜底:属性不存在 → 取到 0x40
+ # → setsockopt 抛 WinError 10042(无效选项)→ 被 except 静默吞掉
+ # → 注释承诺的"独占绑定"从未真正生效(2026-10-09 实测复现)。
+ excl = getattr(socket, "SO_EXCLUSIVEADDRUSE", -5)
+ try:
+ self.socket.setsockopt(socket.SOL_SOCKET, excl, 1)
+ _ExclusiveHTTPServer.exclusive_bound = True
+ except OSError as e:
+ print("serial_tool: SO_EXCLUSIVEADDRUSE failed (%s); falling back "
+ "to allow_reuse_address=False" % e, file=sys.stderr, flush=True)
+ super().server_bind()
+
+ def handle_error(self, request, client_address):
+ """CLI 硬超时先退出会掐断连接 → socketserver 默认往 daemon.log 打整段
+ traceback(本机实测 16 次 ConnectionAbortedError)。客户端已断开不是
+ daemon 故障,静音处理。"""
+ exc = sys.exc_info()[1]
+ if isinstance(exc, ConnectionError):
+ return
+ super().handle_error(request, client_address)
+
+
+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":
+ d.splitter_gen += 1 # 清日志 = 会话边界:残行一并丢弃
+ 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)
+ ft = body.get("first_timeout_ms")
+ ft = None if ft is None else max(1.0, float(ft) / 1000.0)
+ self._send(200, d.wait_idle(settle, mx, ft))
+ 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 _rotate_daemon_log():
+ """daemon.log 超 DAEMON_LOG_MAX → 原地只保尾部 DAEMON_LOG_KEEP。
+
+ 不 rename(Windows 下老 daemon 可能还持有句柄,rename 会让它继续往旧 inode 写);
+ 只在拉起新实例前做,写入口径不变。旧实现无上限:daemon 反复重启 + 断连
+ traceback 会把它无限撑大。
+ """
+ try:
+ size = DAEMON_LOG.stat().st_size
+ if size <= DAEMON_LOG_MAX:
+ return
+ with open(DAEMON_LOG, "r+b") as f:
+ f.seek(size - DAEMON_LOG_KEEP)
+ tail = f.read()
+ f.seek(0)
+ os.ftruncate(f.fileno(), 0)
+ f.write(b"[daemon.log rotated: kept last %dKB]\n" % (DAEMON_LOG_KEEP // 1024))
+ f.write(tail)
+ except OSError:
+ pass
+
+
+def spawn_daemon():
+ STATE_DIR.mkdir(parents=True, exist_ok=True)
+ _rotate_daemon_log()
+ 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(Path.cwd()), **kwargs) # daemon 继承调用方项目根 → 配置源按项目解析
+ 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 _is_timeout_exc(exc):
+ """超时判定:urlopen 连接阶段超时会被包成 URLError(reason=socket.timeout),
+ 读响应阶段超时则直接抛 socket.timeout(3.10+ 即 TimeoutError)。"""
+ if isinstance(exc, (socket.timeout, TimeoutError)):
+ return True
+ return isinstance(getattr(exc, "reason", None), (socket.timeout, TimeoutError))
+
+
+def call_daemon(args, method, path, body=None):
+ """调 daemon;**连接被拒/不可达**时自动拉起并重试一次。
+
+ 2026-10-10 修:超时不再当"daemon 不可达"。旧实现把超时和拒连塞进同一个
+ except → 任何耗时超过 --timeout 的命令(典型:wait-idle)都会误判 daemon 已死、
+ 额外拉起一个 daemon(新实例绑不上 HTTP 口 → 打 "HTTP port busy" 后退出;
+ daemon.log 里 36 条 listening 大半是这么攒出来的),再把原命令撞一次超时。
+ 现在超时直接快速失败并提示调大 --timeout;只有真·不可达才走自动拉起。
+ """
+ timeout = getattr(args, "timeout", DEFAULT_TIMEOUT_S)
+ try:
+ return http_request(method, path, body, timeout)
+ except (socket.timeout, TimeoutError) as e:
+ raise ToolError("daemon did not answer within %.1fs (busy? raise --timeout): %s"
+ % (timeout, e), 2)
+ except (urllib.error.URLError, OSError) as e:
+ if _is_timeout_exc(e):
+ raise ToolError("daemon did not answer within %.1fs (busy? raise --timeout): %s"
+ % (timeout, getattr(e, "reason", e)), 2)
+ 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()]
+ cfg = load_config()
+ configured = cfg.get("portName", "")
+ names = [p["name"] for p in ports]
+ hint = None
+ if configured and configured not in names:
+ hint = port_hint(configured, names) # 配置里的口不在位:唯一候选直接给
+ elif not configured and len(names) == 1:
+ hint = names[0] # 压根没配端口:唯一候选直接给
+ return {"ok": True, "ports": ports, "configured": configured,
+ "configured_present": configured in names, "hint": hint,
+ "config_error": cfg.get("configError") or None}
+
+
+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 resolve_log_paths():
+ """日志/代际的**权威路径**解析:优先问在跑的 daemon(status.log.path),
+ daemon 不可达或未报告时回退本地路径。
+
+ 一台机单 daemon,但 serial_tool 可以有多份副本(仓库版 / skill 版)——日志由
+ **daemon 那份副本**的 state 目录落盘,CLI 若只读自己目录下的文件会读到空
+ (2026-10-10 实测:skill 版 CLI 对仓库版 daemon,wait-idle 有增量、log 却 0 行)。
+ """
+ try:
+ st = http_request("GET", "/status", timeout=2.0)
+ log = st.get("log") or {}
+ p = str(log.get("path") or "")
+ if p and Path(p).is_file():
+ return Path(p), int(log.get("gen", 0))
+ except (urllib.error.URLError, OSError, ValueError):
+ pass
+ return LOG_FILE, int(load_meta().get("gen", 0))
+
+
+def cmd_log(args):
+ """读日志:**先过滤、后截尾**。
+
+ 旧实现先 tail 后 grep → `log --grep "App init OK"`(--tail 默认 50)只在末 50 行
+ 里找,开机标记必然查无此 marker——这就是 AGENTS.md 里"log --grep 实测不可信"的
+ 根因(2026-10-09 实测复现并修复)。返回带 scanned/matched 计数,便于判定。
+ """
+ log_path, gen = resolve_log_paths()
+ if not log_path.exists():
+ return {"ok": True, "gen": gen, "lines": [],
+ "note": "log file not created yet: %s" % log_path}
+ with open(log_path, "r", encoding="utf-8", errors="replace") as f:
+ lines = f.read().splitlines()
+ scanned = len(lines)
+ matched = scanned
+ if args.grep:
+ if args.regex:
+ try:
+ pat = re.compile(args.grep, re.IGNORECASE if args.ignore_case else 0)
+ except re.error as e:
+ raise ToolError("bad --grep regex %r: %s" % (args.grep, e), 3)
+ lines = [l for l in lines if pat.search(l)]
+ elif args.ignore_case:
+ low = args.grep.lower()
+ lines = [l for l in lines if low in l.lower()]
+ else:
+ lines = [l for l in lines if args.grep in l]
+ matched = len(lines)
+ if args.tail:
+ lines = lines[-args.tail:]
+ result = {"ok": True, "gen": gen, "lines": lines, "path": str(log_path),
+ "scanned": scanned, "matched": matched}
+ if args.grep and args.tail and matched > len(lines):
+ result["note"] = "grep matched %d line(s), showing last %d (tail)" % (matched, len(lines))
+ return result
+
+
+def cmd_clear(args):
+ return call_daemon(args, "POST", "/clear", {})
+
+
+def cmd_wait_idle(args):
+ body = {"settle_ms": args.settle_ms, "max_ms": args.max_ms}
+ if args.first_timeout_ms:
+ body["first_timeout_ms"] = args.first_timeout_ms
+ if not args.timeout_explicit:
+ # wait-idle 是同步阻塞的长操作:CLI 超时按等待上限自动推导。旧实现固定 5s
+ # 硬超时会在 daemon 还在等的时候先掐断 → CLI 报 exit 2、daemon 侧
+ # ConnectionAbortedError traceback(本机 daemon.log 实测 16 次)。
+ # AGENTS.md 抓开机日志铁律②"必须带 --timeout"就是这个坑的手工补丁。
+ args.timeout = args.max_ms / 1000.0 + 5.0
+ return call_daemon(args, "POST", "/wait-idle", body)
+
+
+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
+
+ # T5 wait_idle:no_data 判据 / 首字节门 / 增量字节(2026-10-09 回归锁)
+ class _Cnt:
+ def __init__(self, n):
+ self.rx_bytes = n
+
+ r = Daemon.wait_idle(_Cnt(500000), 0.2, 0.6, 0.3) # 常驻 daemon:累计值非 0
+ check("T5a 零字节判 no_data", r["reason"] == "no_data" and r["rx_bytes"] == 0
+ and not r["idle"], repr(r))
+
+ p5 = _Cnt(100)
+ threading.Thread(target=lambda: (time.sleep(0.35), setattr(p5, "rx_bytes", 200)),
+ daemon=True).start()
+ r = Daemon.wait_idle(p5, 0.2, 2.0, 1.0) # 第一行比 settle 晚到
+ check("T5b 首字节门(晚到的第一行仍收全)",
+ r["reason"] == "idle" and r["rx_bytes"] == 100 and r["waited_ms"] >= 500, repr(r))
+
+ p6 = _Cnt(0)
+ stop6 = threading.Event()
+
+ def _spam():
+ while not stop6.is_set():
+ p6.rx_bytes += 10
+ time.sleep(0.05)
+
+ threading.Thread(target=_spam, daemon=True).start()
+ r = Daemon.wait_idle(p6, 0.3, 0.8)
+ stop6.set()
+ check("T5c 持续吐数判 timeout + 返回增量字节",
+ r["reason"] == "timeout" and r["rx_bytes"] > 0, repr(r))
+
+ # T6 log 命令:先 grep 后 tail(旧顺序即 AGENTS.md"log --grep 不可信"的根因)
+ saved_log, saved_meta = LOG_FILE, load_meta
+ saved_resolve = globals()["resolve_log_paths"]
+ fd2, lpath2 = tempfile.mkstemp(prefix="serial_tool_selftest_log_", suffix=".log")
+ os.close(fd2)
+ try:
+ body = "[RX] 2026-10-09T00:00:00.000+08:00 App init OK\n" + "\n".join(
+ "[RX] 2026-10-09T00:00:01.000+08:00 noise %d" % i for i in range(60))
+ Path(lpath2).write_text(body, encoding="utf-8")
+ globals()["LOG_FILE"] = Path(lpath2)
+ globals()["load_meta"] = lambda: {"gen": 7}
+ # resolve_log_paths 会先问在跑的 daemon——selftest 必须连它一起桩掉,
+ # 否则多 daemon 共存环境下 T6 读到真 daemon 的日志而不是这份临时文件
+ globals()["resolve_log_paths"] = lambda: (Path(lpath2), 7)
+
+ class _G:
+ tail = 50
+ grep = "App init OK"
+ regex = False
+ ignore_case = False
+
+ r = cmd_log(_G())
+ check("T6a grep 先于 tail(末 50 行也查到开机标记)",
+ r["matched"] == 1 and len(r["lines"]) == 1 and "App init OK" in r["lines"][0],
+ "matched=%s lines=%d" % (r.get("matched"), len(r["lines"])))
+
+ class _R:
+ tail = 0
+ grep = r"App init|noise 5[0-9]"
+ regex = True
+ ignore_case = False
+
+ r = cmd_log(_R())
+ check("T6b --regex 交替匹配", r["matched"] == 11, "matched=%s" % r.get("matched"))
+
+ class _I:
+ tail = 5
+ grep = "NOISE"
+ regex = False
+ ignore_case = True
+
+ r = cmd_log(_I())
+ check("T6c -i 忽略大小写 + tail 在过滤之后",
+ r["scanned"] == 61 and r["matched"] == 60 and len(r["lines"]) == 5,
+ "scanned=%s matched=%s lines=%d" % (r.get("scanned"), r.get("matched"),
+ len(r["lines"])))
+ finally:
+ globals()["LOG_FILE"], globals()["load_meta"] = saved_log, saved_meta
+ globals()["resolve_log_paths"] = saved_resolve
+ try:
+ os.remove(lpath2)
+ except OSError:
+ pass
+
+ # T7 CLI 超时/拒连区分(旧实现把超时也当"daemon 不可达"→ 误拉起新实例)
+ check("T7a 超时识别(裸抛 + URLError 包裹)",
+ _is_timeout_exc(socket.timeout("t"))
+ and _is_timeout_exc(urllib.error.URLError(socket.timeout("t"))))
+ check("T7b 拒连不当成超时(仍走自动拉起)",
+ not _is_timeout_exc(urllib.error.URLError(ConnectionRefusedError("refused"))))
+
+ # T8 残行封顶 + 会话边界复位(2026-10-10):二进制垃圾不再无限占内存,
+ # 也不再把自己拼进下一段会话的第一行
+ sp8 = LineSplitter()
+ junk = b"\x00\x01" * 40000 # 80 KB 无换行垃圾
+ got = sp8.feed(junk, 2000.0)
+ check("T8a 无换行垃圾封顶成一行(内存不无限涨)",
+ len(got) == 1 and got[0][1] == junk and sp8.buf == b"",
+ "lines=%d buf=%d" % (len(got), len(sp8.buf)))
+ got2 = sp8.feed(b"App init OK\n", 2001.0)
+ check("T8b 封顶后重新对齐换行", len(got2) == 1 and got2[0][1] == b"App init OK", repr(got2))
+
+ sp9 = LineSplitter()
+ sp9.feed(b"half-a-li", 3000.0) # 复位瞬间被截断的半行
+ sp9.reset()
+ got3 = sp9.feed(b"ne boot: syslog init OK\n", 3001.0)
+ check("T8c 会话边界丢弃残行(新会话首行不被污染)",
+ len(got3) == 1 and got3[0][1] == b"ne boot: syslog init OK", repr(got3))
+
+ # T9 截头按行对齐(旧实现按字节切 → 截出来的首行是半行,Grep/verify_log 读到垃圾)
+ fd9, path9 = tempfile.mkstemp(prefix="serial_tool_selftest_trunc_")
+ os.close(fd9)
+ try:
+ with open(path9, "wb") as f:
+ for i in range(2000):
+ f.write(b"[RX] line %04d %s\n" % (i, b"x" * 40))
+ size9 = os.path.getsize(path9)
+ with open(path9, "r+b") as f:
+ cap_truncate(f, size9, keep=8000, cap=1000)
+ f.seek(0)
+ data9 = f.read()
+ first9 = data9.split(b"\n", 1)[0]
+ check("T9 截头后首行完整(按行边界对齐)",
+ first9.startswith(b"[RX] line ") and data9.endswith(b"\n"),
+ "first=%r" % first9[:24])
+ finally:
+ try:
+ os.remove(path9)
+ except OSError:
+ pass
+
+ # T10 端口建议:换 USB 口后 COM 号变了,唯一候选直接给(多候选不猜)
+ check("T10 port_hint 唯一候选才给建议",
+ port_hint("COM7", ["COM6"]) == "COM6"
+ and port_hint("COM7", ["COM6", "COM9"]) is None
+ and port_hint("COM6", ["COM6"]) is None)
+
+ 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=None,
+ help="CLI->daemon 硬超时秒数(默认 %.0f;wait-idle 未显式给时按 --max-ms 自动推导)"
+ % 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 口(默认取串口配置源,见 find_config_file)")
+ 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="只留匹配行(默认子串匹配;先过滤后 tail)")
+ p.add_argument("-i", "--ignore-case", action="store_true", help="grep 忽略大小写")
+ p.add_argument("--regex", action="store_true", help="grep 按 Python re 正则")
+
+ 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)")
+ p.add_argument("--first-timeout-ms", type=int, default=0,
+ help="首字节超时毫秒(0 = 同 --max-ms):期间零字节即判 no_data")
+
+ 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
+
+ # --timeout 缺省解析:显式与否要留给 cmd_wait_idle(它按 --max-ms 自动推导)
+ args.timeout_explicit = args.timeout is not None
+ if args.timeout is None:
+ args.timeout = DEFAULT_TIMEOUT_S
+
+ 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())
diff --git a/.dsh/skills/serial-boot-test/.gitignore b/.dsh/skills/serial-boot-test/.gitignore
new file mode 100644
index 00000000..40e102d3
--- /dev/null
+++ b/.dsh/skills/serial-boot-test/.gitignore
@@ -0,0 +1,4 @@
+# 运行时产物不入库(daemon 日志/状态、专用 venv、字节码缓存)
+state/
+venv/
+__pycache__/
diff --git a/.dsh/skills/serial-boot-test/SKILL.md b/.dsh/skills/serial-boot-test/SKILL.md
new file mode 100644
index 00000000..463bac5d
--- /dev/null
+++ b/.dsh/skills/serial-boot-test/SKILL.md
@@ -0,0 +1,88 @@
+---
+name: serial-boot-test
+description: 嵌入式目标"烧录/复位 → 抓串口开机日志 → 标记校验"一键闭环。构建在 serial-assistant skill(串口采集层)之上,自带烧录测试编排器与日志标记校验器;项目差异全部收在 <项目根>/.serialtest/config.json,任何带调试串口的项目都能用。
+whenToUse: 需要向目标板烧录或复位后,用串口日志自动判定运行是否正常(开机回归、固件身份校验、HardFault/FAIL 标记扫描、开机耗时测量),或需要交互式串口收发与日志回放时。
+---
+
+# serial-boot-test — 串口自动验证(判定层,构建在 serial-assistant 之上)
+
+分层:**采集层 = 兄弟 skill `serial-assistant`**(常驻 daemon 串口助手),
+本 skill 只负责**闭环编排 + 判定**。换项目时把 `serial-boot-test` 和
+`serial-assistant` 两个文件夹一起拷进目标项目的 `.dsh/skills/`
+(或 `.agents/skills/`),脚本不含任何仓库路径。
+
+| 脚本 | 作用 |
+|---|---|
+| `.dsh/skills/serial-boot-test/scripts/run_boot_test.py` | 一键闭环:open+clear → 烧录 → wait-idle → 提取 boot log → 烧录回读校验 → 标记校验。串口助手按 `$DSH_SERIAL_TOOL` > 同目录 > `../serial-assistant/scripts/serial_tool.py` 解析 |
+| `.dsh/skills/serial-boot-test/scripts/verify_log.py` | 对任意日志文件做标记校验(Required/Forbidden/锚点计数/顺序/耗时),可单独用 |
+
+交互式串口操作(open/send/log/wait-idle/status 等)不属于本 skill——
+**用 `serial-assistant` skill**。
+
+## 前置条件
+
+1. **解释器需装有 pyserial**(闭环经采集层脚本用串口)。优先用项目自己的 venv;
+ 否则按 `serial-assistant` skill 的"前置条件"建专用 venv。
+ 下文 `$PY` = 选定的解释器,`$SKILL` = `.dsh\skills\serial-boot-test`(项目根下相对路径)。
+2. **所有命令都在项目根目录下运行**(cwd 决定配置源解析)。
+
+## 第一步:项目档案(每个项目一次)
+
+配置解析顺序:`$DSH_SERIAL_CONFIG` > `/.serialtest/config.json` > `/.opencode/serial_config.json`(旧位置,仅串口字段)。
+
+若项目没有 `.serialtest/config.json`:**先从项目文档(AGENTS.md/README/测试文档)推断各字段,推断不了的问用户**,然后按 `config.example.json` 写一份。字段含义:
+
+| 键 | 含义 | 缺省行为 |
+|---|---|---|
+| `portName` / `baudRate` | 串口 | 无 portName 直接 exit 3 |
+| `flash_cmd` | 烧录命令(**必须含复位运行**,如 `-Rst -Run`;可带参数) | 空 = 不烧录,脚本提示人工复位板子 |
+| `hex` | 刚构建的镜像(Intel HEX),供身份戳 + 回读比对 | 空 = 两项都跳过(WARN) |
+| `fw_marker` | 固件身份戳前缀(固件里打印 `"<前缀>" __DATE__ " " __TIME__`) | 默认 `fw build: `;hex 里无此串只 WARN 不假判 |
+| `readback_cmd` | 回读校验命令模板,`{hex}` = 镜像路径(引号自动加) | 空 = 跳过 |
+| `readback_ok` | 回读成功判据(按行首匹配,大小写不敏感) | `no difference found` |
+| `required` | 必须出现的标记(`;` 分隔,大小写敏感) | **空 = exit 3**(必须给) |
+| `forbidden` | 禁止出现的模式(`;` 分隔,大小写不敏感) | `FAIL;ERROR;HardFault` |
+| `boot_marker` | 一轮启动的锚点子串(如 `reset cause:`) | 空 = 锚点计数关闭 |
+| `max_boot_count` | 锚点出现 0 次或 >N 次 = FAIL | 1(仅 boot_marker 非空时生效) |
+| `done_marker` + `max_boot_ms` | 开机耗时口径与上限 | 空/0 = 只报告不判 |
+| `settle_ms` / `max_wait_ms` / `first_wait_ms` | RX 静默判据 / 总超时 / 首字节超时 | 8000 / 30000 / 12000 |
+| `require_order` / `expect_reset_cause` / `require_suite_summary` | 顺序校验 / 复位原因白名单 / 套件汇总行 | 空 = 关闭 |
+| `require_selftest_pass` + `device_ip` | 设备侧自检台账联合判定(FTP 抓取) | 关闭 |
+
+**先跑项目的构建**(本仓库 = `keil-build` skill,其它 Keil 项目照该 skill 套;
+非 Keil 项目用项目自己的构建入口),确认 0 错误 0 警告后再进闭环。
+档案里的 `flash_cmd`/`readback_cmd` 就是烧录层的入口命令
+(本仓库 = `stlink-flash` skill 的 `MDK-ARM\reset.bat flash` / `cmp {hex}`)。
+
+## 第二步:跑闭环
+
+```powershell
+# cwd = 项目根目录
+& $PY "$SKILL\scripts\run_boot_test.py"
+# 临时覆盖:--port COM7 --required "..." --forbidden "..." --flash-cmd "..."
+```
+
+退出码:`0=PASS` / `1=FAIL`(标记缺失或命中)/ `2=采集或烧录错误`(含回读不一致,本次运行作废)/ `3=用法/预检错误`。
+
+产物:boot log(每行带 `[+Nms]` 相对时基)写到 `%TEMP%\serial-boot-test\boot_log_<时间戳>.txt`(`--out-dir` 改,保留最近 20 份)。
+
+## 第三步:判读与迭代
+
+- **PASS** → 报告命中的标记与开机耗时。
+- **FAIL** → `[MISS]`/`[HIT]` 行(Forbidden 命中带行号+内容)+ 日志尾部已给出;需要更多上下文直接 Read/Grep boot log 文件或 `status` 里 `log.path` 指向的 serial.log。修代码 → 重新构建 → 重跑闭环。
+- **exit 2 `no_data`** → 板子全程零字节:查端口在位(`ports`)、板电、烧录命令是否真的复位了目标。
+
+## 交互式串口调试(不跑闭环时)
+
+用 **`serial-assistant` skill**(`ports/open/send/log/clear/wait-idle/status` 全套命令、
+抓开机日志铁律、daemon 陷阱都在那边)。本 skill 不重复采集层用法。
+
+## 陷阱(都是实测教训)
+
+- **daemon 一台机只有一个**(HTTP 口 18765 独占绑定,由采集层 serial-assistant 管理)。改配置后必须 `restart`(daemon 启动时缓存配置)。若机器上还有旧 daemon,先 `status` 看 `daemon.version`(serial-assistant 版 = `1.1.0-skill`)确认归属,必要时 `stop` 后重开。
+- **回读校验排在日志采集之后**:部分烧录工具(如 ST-LINK `-CmpFile`)连接时会复位目标,提前跑会截断开机日志。脚本已按此顺序,勿手工调换。
+- **烧录命令必须让目标复位运行**(裸 `-P` 类写入在目标核死循环时不落地);写完镜像后 `fw build` 身份戳校验会揭穿"芯片还是旧镜像"。
+- **`settle_ms` 必须大于固件的自然静默间隙**(常见 3~4s),默认 8000;日志被截断先调大它。
+- **Required 标记宁少勿多**:先用启动锚点 + 少数里程碑标记;按用例临时覆盖 `--forbidden`(预期出现的 `[ERR]` 负路径用例)。
+- **烧录与串口采集不冲突**:SWD 下载走探针、调试日志走 USB-serial(CH340 类),互不占用;烧录期间串口侧静默几秒属正常,不算 `no_data`(首字节门会兜住)。
+- 中文/编码:脚本全程字节级处理,不受控制台代码页影响;配置文件存 UTF-8(BOM 容忍)。
diff --git a/.dsh/skills/serial-boot-test/config.example.json b/.dsh/skills/serial-boot-test/config.example.json
new file mode 100644
index 00000000..8d7e1bd4
--- /dev/null
+++ b/.dsh/skills/serial-boot-test/config.example.json
@@ -0,0 +1,29 @@
+{
+ "portName": "COM6",
+ "baudRate": 115200,
+
+ "flash_cmd": "MDK-ARM\\reset.bat flash",
+ "flash_timeout": 180,
+ "hex": "MDK-ARM\\STM32F407-Demo\\STM32F407-Demo.hex",
+ "fw_marker": "fw build: ",
+ "readback_cmd": "MDK-ARM\\reset.bat cmp {hex}",
+ "readback_ok": "no difference found",
+
+ "required": "App init OK;Network Init;net init: OK",
+ "forbidden": "FAIL;ERROR;HardFault;[ERR]",
+ "boot_marker": "reset cause:",
+ "max_boot_count": 1,
+ "done_marker": "net init: OK",
+ "max_boot_ms": 0,
+ "require_order": "",
+ "expect_reset_cause": "",
+
+ "settle_ms": 8000,
+ "max_wait_ms": 30000,
+ "first_wait_ms": 12000,
+
+ "require_selftest_pass": false,
+ "device_ip": "",
+
+ "httpPort": 18765
+}
diff --git a/.dsh/skills/serial-boot-test/scripts/run_boot_test.py b/.dsh/skills/serial-boot-test/scripts/run_boot_test.py
new file mode 100644
index 00000000..f99f0333
--- /dev/null
+++ b/.dsh/skills/serial-boot-test/scripts/run_boot_test.py
@@ -0,0 +1,618 @@
+#!/usr/bin/env python3
+"""run_boot_test.py - 一键开机日志抓取 + 校验(纯标准库;可移植 skill 版)。
+
+调用串口助手(采集层 = 兄弟 skill serial-assistant 的 serial_tool.py,常驻 daemon,
+子进程 CLI + JSON;解析顺序见 find_serial_tool):
+ 1. open 串口 + clear 日志(本次运行独立代际)
+ 2. 烧录(config "flash_cmd";未配置 = 跳过烧录,等人工复位板子)
+ 2.5 固件身份戳:从待烧 hex(config "hex")里抠出固件戳(config "fw_marker",
+ 默认 `fw build: