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: