移除 OpenCode 配置与命令,技能与串口单一配置源迁移至 .dsh/skills 与 .serialtest,同步更新全部文档引用

This commit is contained in:
2026-10-10 14:05:36 +08:00
parent e89fe54a97
commit aac5f99fe9
44 changed files with 3868 additions and 180 deletions
@@ -1,6 +1,7 @@
--- ---
name: draw-diagram name: draw-diagram
description: docs/ 文档技术配图绘制流程(精确布局图用 SVG 生成器 / 拓扑图用 Mermaid):挑脚本 → 改顶部数据 → 重跑生成 → 目检 → md 相对路径引用 → 脚本+产物一并提交,产物统一放 docs/images/。Use when 需要新增/修改 docs/ 文档的技术配图(协议帧、分层架构、依赖/就绪屏障、流程图、时序、状态机),或用户要求"画个图 / 生成图 / 更新架构图 / 帧格式图"。 description: docs/ 文档技术配图绘制流程(精确布局图用 SVG 生成器 / 拓扑图用 Mermaid):挑脚本 → 改顶部数据 → 重跑生成 → 目检 → md 相对路径引用 → 脚本+产物一并提交,产物统一放 docs/images/。
whenToUse: 需要新增/修改 docs/ 文档的技术配图(协议帧、分层架构、依赖/就绪屏障、流程图、时序、状态机),或用户要求"画个图 / 生成图 / 更新架构图 / 帧格式图"时。
--- ---
# 文档配图绘制(SVG / Mermaid) # 文档配图绘制(SVG / Mermaid)
@@ -1,6 +1,7 @@
--- ---
name: font-regen 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 字符串之前。
--- ---
# 中文字体重生成 # 中文字体重生成
+1
View File
@@ -0,0 +1 @@
__pycache__/
+49
View File
@@ -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 归一化成本机风格(`<Tag />` → `<Tag></Tag>` 等)并把 `PackID` 降到本机 DFP 版本。
判断:`git diff` 里只见 XML 格式/`PackID` 差异而**无源文件挂载增删** →
`git checkout -- <uvprojx>` 丢弃;只有真的增删了源文件(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 的入口命令)。
@@ -0,0 +1,188 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""keil_build.py - Keil MDK-ARM 命令行构建 + 判定(纯标准库,可移植 skill 版)。
用法(**项目根目录**下运行,任意 python3):
python <skill>/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: <proj dir>/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())
@@ -1,6 +1,7 @@
--- ---
name: new-source-file 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、"改了代码没生效" 而怀疑文件漏注册时。
--- ---
# 新文件双端注册 # 新文件双端注册
+4
View File
@@ -0,0 +1,4 @@
# 运行时产物不入库(daemon 日志/状态、专用 venv、字节码缓存)
scripts/state/
venv/
__pycache__/
+61
View File
@@ -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` > `<cwd>/.serialtest/config.json` >
`<cwd>/.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 封顶。
File diff suppressed because it is too large Load Diff
+4
View File
@@ -0,0 +1,4 @@
# 运行时产物不入库(daemon 日志/状态、专用 venv、字节码缓存)
state/
venv/
__pycache__/
+88
View File
@@ -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` > `<cwd>/.serialtest/config.json` > `<cwd>/.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 容忍)。
@@ -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
}
@@ -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: <DATE> <TIME>`),追加成 Required 标记——
芯片里跑的必须就是这份构建(--no-fw-check 关闭)
3. wait-idle:RX 静默 >= settle-ms 收尾;首字节超过 --first-wait-ms 未到 → no_data
(快速失败);整体 max-wait-ms 超时
4. 从日志提取 [RX] 载荷行(剥掉 "[RX] <ISO时间戳> " 前缀),并给每行加 `[+Nms]` 相对
偏移(daemon 毫秒时基,本次第一行 = +0ms;--raw-log 出纯载荷)。固件侧时间戳往往
只有秒级、开机头几行还没时钟,量不了耗时——这个前缀就是耗时口径
4.5 烧录回读校验:config "readback_cmd" 模板({hex} = 镜像路径),成功判据 =
某行以 "readback_ok"(默认 No difference found)开头;未配置 = 跳过。
**必须排在采集之后**:ST-LINK -CmpFile 实测连接时会复位目标
5. verify_log.py 标记校验(含启动锚点计数 config "boot_marker" + --max-boot-count;
--expect-reset-cause 原因白名单;--require-order 标记顺序;
--done-marker + --max-boot-ms 开机耗时)
6. (仅当 --require-selftest-pass)自检记录联合判定(S7):
--selftest-log 提供本地副本则直接用它,否则从设备 FTP 抓取 /log/selftest.log
(需 config "device_ip");判定:文件缺失 / 0 字节 = 无故障 → PASS
采集在烧录**之前**武装(open + clear 先于烧录),daemon 常驻全量日志兜底,
无采集窗口/首字节等待仪式。
项目差异全部收在 <cwd>/.serialtest/config.json(见 config.example.json):
portName/baudRate/flash_cmd/hex/fw_marker/readback_cmd/readback_ok/
required/forbidden/boot_marker/done_marker/settle_ms/...
解析顺序:$DSH_SERIAL_CONFIG > <cwd>/.serialtest/config.json >
<cwd>/.opencode/serial_config.json(旧位置,仅串口字段)。
优先级:命令行 > 档案 > 内置通用默认。
加固口径(Windows 实测教训):
- 子进程输出按**字节**读取、显式 UTF-8 解码 —— 不受控制台代码页(GBK)影响
- 配置 JSON 按 UTF-8(容忍 BOM)解析
- 不经 PowerShell —— 无执行策略拦截
- 烧录/回读带超时 —— 坏连接不会永久挂起
用法(**项目根目录**下运行,python 需装有 pyserial):
python <skill>/scripts/run_boot_test.py
[--port COM7] [--baud-rate 115200] [--settle-ms 8000] [--max-wait-ms 30000]
[--first-wait-ms 12000]
[--flash-cmd "<烧录命令>"] [--flash-timeout 180]
[--required "m1;m2"] [--forbidden "FAIL;ERROR;HardFault"]
[--require-suite-summary "Storage Tests"]
[--require-selftest-pass] [--selftest-log <本地副本>]
[--device-ip 192.168.1.100] [--out-dir <目录>] [--show-tail-lines 15]
[--fw-hex <构建产物 hex>] [--no-fw-check]
[--no-verify-flash] [--readback-timeout 120]
[--max-boot-count 1] [--expect-reset-cause 'software;power-on']
[--require-order 'a>b>c'] [--done-marker 'ok'] [--max-boot-ms 0]
[--raw-log] [--keep-out 20]
未显式给的参数取 <cwd>/.serialtest/config.json(见 config.example.json)
退出码(与旧版一致):
0 = PASS(采集成功 + Required 全中 + Forbidden 零命中)
1 = FAIL(标记缺失/命中)
2 = 采集或烧录错误(含烧录回读不一致 = 芯片内容与刚编的镜像不同,本次运行作废)
3 = 用法/预检错误
"""
import argparse
import ftplib
import json
import os
import shlex
import subprocess
import sys
import tempfile
import time
from datetime import datetime
from pathlib import Path
SCRIPT_DIR = Path(__file__).resolve().parent
VERIFY_LOG = SCRIPT_DIR / "verify_log.py"
def find_serial_tool():
"""串口助手脚本解析顺序(分层 skill:采集层 = 兄弟 skill serial-assistant):
1. $DSH_SERIAL_TOOL 环境变量(显式指定)
2. 与本脚本同目录(兼容单独整包拷贝的场景)
3. 兄弟 skill:<skills 根>/serial-assistant/scripts/serial_tool.py
都不存在时返回第 3 候选(错误信息照它报)。"""
env = os.environ.get("DSH_SERIAL_TOOL")
if env:
return Path(env)
candidates = [SCRIPT_DIR / "serial_tool.py",
SCRIPT_DIR.parent.parent / "serial-assistant" / "scripts" / "serial_tool.py"]
for c in candidates:
if c.is_file():
return c
return candidates[1]
SERIAL_TOOL = find_serial_tool()
def find_config_file():
"""项目测试档案解析顺序(cwd = 项目根目录):
1. $DSH_SERIAL_CONFIG 环境变量(显式文件)
2. <cwd>/.serialtest/config.json (本 skill 约定)
3. <cwd>/.opencode/serial_config.json (旧位置,仅串口字段)
都不存在时返回第 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]
CFG_FILE = find_config_file()
# 通用默认(与具体项目无关;项目差异全部进 .serialtest/config.json)
FORBIDDEN_DEFAULT = "FAIL;ERROR;HardFault"
# 固件身份戳:固件里打印 `"<fw_marker>" __DATE__ " " __TIME__`(编译期展开成连续
# ASCII),脚本从待烧 hex 里抠出该串追加成 Required 标记——芯片里跑的镜像必须
# 就是刚编出来的这份。旧镜像/错镜像在纯固定串标记下不可见。
# 固件没有这类戳 → config 里 "fw_marker": "" 关闭(只 WARN 跳过,不假判)。
FW_MARKER_DEFAULT = "fw build: "
# 烧录回读校验的成功判据(按**行首**匹配,大小写不敏感):ST-LINK_CLI -CmpFile
# 输出 `No difference found`。其他烧录工具按其成功行改 config 里的 "readback_ok"。
READBACK_OK_DEFAULT = "no difference found"
def _split_cmd(cmd_str):
r"""把 `--flash-cmd` 这类命令串拆成 [exe, arg, ...](2026-10-10 实测坑)。
直接交给 subprocess、**不经 `cmd /c`**:CreateProcess 能直接跑 .bat,且 Python
会给含空格的路径正确加引号。旧写法 `['cmd','/c', 串]` 在本仓库(目录名
`DTU Code` 含空格)会被 cmd 的 /c 引号规则玩坏——实测报
`'D:\Code\DTU' is not recognized`,回读校验被假判成"芯片内容不一致"。
路径本身含空格时把该段用引号包住(`"D:\some dir\reset.bat" flash`)。
posix=False 是必须的:posix=True 会吃掉 Windows 路径里的反斜杠。
"""
return [p.strip('"') for p in shlex.split(cmd_str, posix=False)]
def _run_cmd(argv, timeout):
"""跑一条外部命令,按字节取回输出并显式 UTF-8 解码(不受控制台代码页影响)。"""
try:
proc = subprocess.run(argv, capture_output=True, timeout=timeout)
rc = proc.returncode
raw = (proc.stdout or b"") + (proc.stderr or b"")
except subprocess.TimeoutExpired as e:
rc = -1
raw = (e.stdout or b"") + (e.stderr or b"")
return rc, raw.decode("utf-8", errors="replace")
def extract_fw_stamp(hex_path, marker):
"""从 Intel HEX 里抠固件身份戳(只扫数据记录,不建整片镜像,内存 O(镜像体积))。
返回戳串(如 'Oct 10 2026 02:38:12');找不到(旧固件/未重编译)返回 None。
"""
data = bytearray()
for line in Path(hex_path).read_text(errors="replace").splitlines():
line = line.strip()
if not line.startswith(":"):
continue
try:
rec = bytes.fromhex(line[1:])
except ValueError:
continue
if len(rec) > 4 and rec[3] == 0:
data += rec[4:4 + rec[0]]
i = data.find(marker.encode("ascii"))
if i < 0:
return None
j = i + len(marker)
end = j
while end < len(data) and 0x20 <= data[end] < 0x7F:
end += 1
return data[j:end].decode("ascii", errors="replace")
def _cli(cmd_args, timeout):
"""调用串口助手 CLI,返回 (rc, stdout_text, parsed_json_or_None)。
全程字节级处理(capture_output 取 bytes 后显式 UTF-8 解码),
任何 Windows 控制台代码页都不参与数据路径。
"""
try:
proc = subprocess.run(cmd_args, capture_output=True, timeout=timeout)
except subprocess.TimeoutExpired:
return 2, "client timeout ({}s)".format(timeout), None
text = proc.stdout.decode("utf-8", errors="replace")
try:
return proc.returncode, text, json.loads(text)
except ValueError:
return proc.returncode, text, None
def _tool_cmd(py, cmd, *rest, timeout=15):
rc, out, data = _cli([py, str(SERIAL_TOOL), cmd, *rest], timeout)
if rc != 0 or data is None:
return None, out
return data, out
def _ftp_quit(ftp):
"""尽力关闭 FTP 控制连接(QUIT 失败不影响判定)。"""
try:
ftp.quit()
except (OSError, ftplib.Error):
pass
def _prune_out_dir(out_dir, keep):
"""只保留最近 N 份产物(`boot_log_*.txt` / `selftest_log_*.log`)。
这些文件按 `%Y%m%d_%H%M%S` 命名、每次 @test 各留一份,旧实现从不清理——
本机实测 out 目录已堆到 31 份 boot_log + 7 份 selftest_log(2026-10-10)。
文件名含时间戳 → 按名字排序即按时间排序。
"""
if keep <= 0:
return 0
removed = 0
for pat in ("boot_log_*.txt", "selftest_log_*.log"):
for p in sorted(out_dir.glob(pat), key=lambda q: q.name)[:-keep]:
try:
p.unlink()
removed += 1
except OSError:
pass
return removed
def _fetch_selftest_log(device_ip, out_dir, stamp):
"""从设备 FTP 抓取 /log/selftest.log(lftpd :21 匿名,根目录 = 卷根)。
成功返回本地路径字符串;失败(网络不可达 / FTP 错误 / 设备侧文件缺失)返回 ""
(按 S7 语义视为无故障记录;此处打 WARN,调用方不判整体 FAIL)。
2026-10-02 台架修复:原实现只 `except OSError`,而 ftplib 的 error_temp /
error_perm 继承自 Exception 而非 OSError —— 台账"仅故障才写",干净设备上
/log/selftest.log 根本不存在,RETR 返回 450 直接抛出未捕获异常,令整个
`@test --require-selftest-pass` 在最后一步崩掉(而它本该是 PASS 路径)。
现把"设备侧无此文件"单列为正常路径(INFO),其余 FTP 错误保留 WARN。
"""
local = Path(out_dir) / "selftest_log_{}.log".format(stamp)
ftp = ftplib.FTP()
try:
ftp.connect(device_ip, 21, timeout=10)
ftp.login() # 匿名(lftpd 接受 USER anonymous / 空 PASS)
except (OSError, ftplib.Error) as e:
print("RUN_TEST_WARN: selftest fetch failed: {} "
"(按 S7 语义视为无记录;设备网络/FTP 需另行排查)".format(e))
return ""
buf = bytearray()
try:
ftp.retrbinary("RETR /log/selftest.log", buf.extend)
except ftplib.all_errors as e:
# 450/550 = 设备侧无此文件:干净台账,S7 判 PASS(正常路径,非故障)
print("SELFTEST_FETCH: {} -> (ledger absent: {}) 按 S7 视为无故障记录"
.format(device_ip, str(e).strip()))
_ftp_quit(ftp)
return ""
_ftp_quit(ftp)
local.write_bytes(bytes(buf))
print("SELFTEST_FETCH: {} -> {} ({} bytes)".format(device_ip, local, len(buf)))
return str(local)
def main(argv=None):
# 控制台输出固定 UTF-8:Windows 代码页只影响显示,不会让脚本崩溃
for stream in (sys.stdout, sys.stderr):
try:
stream.reconfigure(encoding="utf-8", errors="replace")
except Exception:
pass
ap = argparse.ArgumentParser(description="boot log capture + verify (pure stdlib, no PowerShell)")
ap.add_argument("--port", default=None,
help="serial port (default: portName from project config)")
ap.add_argument("--baud-rate", type=int, default=None)
ap.add_argument("--settle-ms", type=int, default=None,
help="RX silence that ends the capture")
ap.add_argument("--max-wait-ms", type=int, default=None,
help="overall wait-idle timeout")
ap.add_argument("--first-wait-ms", type=int, default=None,
help="give up as no_data if the board sent ZERO bytes this long "
"after flash started the wait (0 = same as --max-wait-ms)")
ap.add_argument("--flash-cmd", default=None,
help="flash command (from config 'flash_cmd'; empty = skip flashing, "
"reset the board manually right after the run starts)")
ap.add_argument("--flash-timeout", type=int, default=None,
help="flash subprocess timeout in seconds")
ap.add_argument("--required", default=None,
help="required markers, ';' separated (config 'required')")
ap.add_argument("--forbidden", default=None,
help="forbidden patterns, ';' separated (config 'forbidden')")
ap.add_argument("--require-suite-summary", default=None,
help="suite names that MUST appear as '=== <name>: N/M PASSED (failed=0) ===' "
"with N>0, ';' separated (T-2). Empty = off")
ap.add_argument("--require-selftest-pass", action="store_true", default=None,
help="jointly judge /log/selftest.log (S7): missing/empty = PASS, "
"any 64B record = FAIL")
ap.add_argument("--selftest-log", default=None,
help="local copy of /log/selftest.log (skips device-side fetch when given)")
ap.add_argument("--device-ip", default=None,
help="device IP for fetching /log/selftest.log via FTP (anonymous)")
ap.add_argument("--out-dir", default=None)
ap.add_argument("--show-tail-lines", type=int, default=None)
ap.add_argument("--fw-hex", default=None,
help="built image for the firmware-identity stamp and the readback "
"compare (config 'hex')")
ap.add_argument("--no-fw-check", action="store_true", default=None,
help="do not require the firmware build stamp in the boot log")
ap.add_argument("--no-verify-flash", action="store_true", default=None,
help="skip the readback compare (config 'readback_cmd')")
ap.add_argument("--readback-timeout", type=int, default=None,
help="timeout for the readback compare, seconds")
ap.add_argument("--max-boot-count", type=int, default=None,
help="fail if the boot banner (config 'boot_marker') appears 0 times or "
"more than N times (0 = check off)")
ap.add_argument("--expect-reset-cause", default=None,
help="';'-separated substrings the FIRST reset cause must match "
"(case-insensitive); empty = report only, no verdict")
ap.add_argument("--require-order", default=None,
help="'>'-separated markers that must appear in that line order "
"(catches out-of-order boot steps); empty = off")
ap.add_argument("--done-marker", default=None,
help="marker whose line marks boot completion (for --max-boot-ms); "
"empty = don't measure")
ap.add_argument("--max-boot-ms", type=int, default=None,
help="fail if time from first line to --done-marker exceeds N ms "
"(0 = measure and report only)")
ap.add_argument("--raw-log", action="store_true", default=None,
help="write payload-only boot log (no '[+Nms]' relative-time prefix)")
ap.add_argument("--keep-out", type=int, default=None,
help="keep the newest N boot_log_*/selftest_log_* files in --out-dir "
"(0 = keep everything)")
args = ap.parse_args(argv)
# 串口助手 CLI 用当前解释器(需装有 pyserial)
py = sys.executable
# 项目测试档案(.serialtest/config.json,UTF-8 容忍 BOM):
# 优先级 = 命令行 > 档案 > 内置通用默认。档案缺失不是错误(纯命令行也能跑)。
cfg = {}
if CFG_FILE.is_file():
try:
raw = json.loads(CFG_FILE.read_text(encoding="utf-8-sig"))
except (OSError, ValueError) as e:
print("RUN_TEST_ERROR: cannot parse {}: {}".format(CFG_FILE, e))
return 3
if not isinstance(raw, dict):
print("RUN_TEST_ERROR: config root is not an object: {}".format(CFG_FILE))
return 3
cfg = raw
print("CONFIG: {}".format(CFG_FILE if cfg else "(none, CLI-only)"))
def _pick(cli, key, default=None):
if cli is not None:
return cli
v = cfg.get(key)
return default if v is None else v
port = str(_pick(args.port, "portName", default="") or "")
args.baud_rate = int(_pick(args.baud_rate, "baudRate", default=115200))
args.settle_ms = int(_pick(args.settle_ms, "settle_ms", default=8000))
args.max_wait_ms = int(_pick(args.max_wait_ms, "max_wait_ms", default=30000))
args.first_wait_ms = int(_pick(args.first_wait_ms, "first_wait_ms", default=12000))
args.flash_cmd = str(_pick(args.flash_cmd, "flash_cmd", default="") or "")
args.flash_timeout = int(_pick(args.flash_timeout, "flash_timeout", default=180))
args.required = str(_pick(args.required, "required", default="") or "")
args.forbidden = str(_pick(args.forbidden, "forbidden", default=FORBIDDEN_DEFAULT))
args.require_suite_summary = str(_pick(args.require_suite_summary,
"require_suite_summary", default="") or "")
args.require_selftest_pass = bool(_pick(args.require_selftest_pass,
"require_selftest_pass", default=False))
args.selftest_log = str(_pick(args.selftest_log, "selftest_log", default="") or "")
args.device_ip = str(_pick(args.device_ip, "device_ip", default="") or "")
args.out_dir = str(_pick(args.out_dir, "out_dir",
default=str(Path(tempfile.gettempdir()) / "serial-boot-test")))
args.show_tail_lines = int(_pick(args.show_tail_lines, "show_tail_lines", default=15))
args.fw_hex = str(_pick(args.fw_hex, "hex", default="") or "")
args.no_fw_check = bool(_pick(args.no_fw_check, "no_fw_check", default=False))
args.fw_marker = str(cfg.get("fw_marker", FW_MARKER_DEFAULT) or "")
args.no_verify_flash = bool(_pick(args.no_verify_flash, "no_verify_flash", default=False))
args.readback_cmd = str(cfg.get("readback_cmd", "") or "")
args.readback_ok = str(cfg.get("readback_ok", READBACK_OK_DEFAULT) or READBACK_OK_DEFAULT)
args.readback_timeout = int(_pick(args.readback_timeout, "readback_timeout", default=120))
args.max_boot_count = int(_pick(args.max_boot_count, "max_boot_count", default=1))
args.boot_marker = str(cfg.get("boot_marker", "") or "")
args.expect_reset_cause = str(_pick(args.expect_reset_cause,
"expect_reset_cause", default="") or "")
args.require_order = str(_pick(args.require_order, "require_order", default="") or "")
args.done_marker = str(_pick(args.done_marker, "done_marker", default="") or "")
args.max_boot_ms = int(_pick(args.max_boot_ms, "max_boot_ms", default=0))
args.raw_log = bool(_pick(args.raw_log, "raw_log", default=False))
args.keep_out = int(_pick(args.keep_out, "keep_out", default=20))
if not port:
print("RUN_TEST_ERROR: no --port given and no portName in {} "
"(create .serialtest/config.json, see config.example.json)".format(CFG_FILE))
return 3
if not args.required:
print("RUN_TEST_ERROR: no required markers (--required or 'required' in {})".format(CFG_FILE))
return 3
# --flash-cmd 允许带参数(如 "MDK-ARM\reset.bat flash"):拆成 argv,
# 存在性只查 exe 段(整串当文件名必然 missing,2026-10-10 实测)
flash_argv = _split_cmd(args.flash_cmd) if args.flash_cmd else None
# 预检(对应旧版 missing-file 检查)
preflight = [SERIAL_TOOL, VERIFY_LOG]
if flash_argv is not None:
preflight.append(Path(flash_argv[0]))
for p in preflight:
if not p.is_file():
print("RUN_TEST_ERROR: missing {}".format(p))
return 3
out_dir = Path(args.out_dir)
out_dir.mkdir(parents=True, exist_ok=True)
pruned = _prune_out_dir(out_dir, args.keep_out)
if pruned:
print("OUT: pruned {} old artifact file(s) in {} (keep={})".format(
pruned, out_dir, args.keep_out))
stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
boot_txt = out_dir / "boot_log_{}.txt".format(stamp)
def tool(cmd, *rest, timeout=15):
return _tool_cmd(py, cmd, *rest, timeout=timeout)
print("=== Boot test {} ===".format(stamp))
print("Port: {} @ {} settle={}ms max={}ms".format(
port, args.baud_rate, args.settle_ms, args.max_wait_ms))
# 1. open(幂等;未运行则自动拉起常驻 daemon)
data, out = tool("open", "--port", port, "--baud", str(args.baud_rate), "--timeout", "15")
if data is None:
print("RUN_TEST_ERROR: open failed: {}".format(out))
return 2
# 2. clear(新代际;此后数据才属于本次运行)
data, out = tool("clear", "--timeout", "15")
if data is None:
print("RUN_TEST_ERROR: clear failed: {}".format(out))
return 2
# 3. flash(下载 + Reset & Run)。烧录失败不中断:板子可能已复位,
# 由采集 + 标记判定结果(与旧版一致)。flash_cmd 为空 = 不烧录,
# 采集已武装,等人工复位/上电目标板(纯串口验证项目常用)。
if flash_argv is None:
print("FLASH: skipped (no flash_cmd) -- reset/power-cycle the board NOW")
else:
print("FLASH_START: {}".format(" ".join(flash_argv)))
t0 = time.monotonic()
try:
proc = subprocess.run(flash_argv,
capture_output=True, timeout=args.flash_timeout)
flash_rc = proc.returncode
flash_bytes = (proc.stdout or b"") + (proc.stderr or b"")
except subprocess.TimeoutExpired as e:
flash_rc = -1
flash_bytes = (e.stdout or b"") + (e.stderr or b"")
elapsed_ms = int((time.monotonic() - t0) * 1000)
for line in flash_bytes.decode("utf-8", errors="replace").strip().splitlines()[-12:]:
print(line)
print("FLASH_RC: {} elapsed_ms={}".format(flash_rc, elapsed_ms))
if flash_rc != 0:
print("RUN_TEST_WARN: flash rc={} (continuing: board may still have reset)".format(flash_rc))
# 3.5 固件身份戳:从**刚烧进去的那份 hex** 里抠出固件戳(config "fw_marker",
# 默认 'fw build: '),追加成 Required 标记 → 芯片里跑的必须就是这份构建。
# hex 里没有该串(旧构建/无此机制)时只 WARN 跳过,不假判 FAIL。
required = args.required
fw_hex = Path(args.fw_hex) if args.fw_hex else None
if args.no_fw_check:
print("FW ID: skipped (--no-fw-check)")
elif not args.fw_marker:
print("FW ID: skipped (fw_marker empty)")
elif fw_hex is None:
print("RUN_TEST_WARN: no 'hex' configured -> identity + readback skipped")
elif not fw_hex.is_file():
print("RUN_TEST_WARN: fw hex not found {} -> identity + readback skipped".format(fw_hex))
else:
fw_stamp = extract_fw_stamp(fw_hex, args.fw_marker)
if fw_stamp is None:
print("RUN_TEST_WARN: no '{}' stamp in {} -> identity check skipped "
"(stale image: rebuild first)".format(args.fw_marker, fw_hex.name))
else:
marker = args.fw_marker + fw_stamp
required = (args.required + ";" + marker) if args.required else marker
print("FW ID: expected stamp '{}' (from {})".format(marker, fw_hex.name))
# 4. wait-idle(RX 静默收尾或整体超时)
# --first-timeout-ms:首字节门——烧录后板子全程不吐字节时按 no_data 快速失败
# (旧版 daemon 常驻累计 rx_bytes>0,no_data 永不触发,只能靠后面"日志里没有
# [RX] 行"兜底,报错信息误导成 device silent?)
wait_timeout_s = args.max_wait_ms // 1000 + 20
wait_args = ["--settle-ms", str(args.settle_ms),
"--max-ms", str(args.max_wait_ms),
"--timeout", str(wait_timeout_s)]
if args.first_wait_ms:
wait_args += ["--first-timeout-ms", str(args.first_wait_ms)]
data, out = tool("wait-idle", *wait_args, timeout=wait_timeout_s + 15)
if data is None:
print("RUN_TEST_ERROR: wait-idle failed: {}".format(out))
return 2
print("WAIT: reason={} rx_bytes={} waited_ms={}".format(
data.get("reason"), data.get("rx_bytes"), data.get("waited_ms")))
if data.get("reason") == "no_data":
print("RUN_TEST_ERROR: no RX data at all (check port / board power / flash)")
return 2
# 5. 提取 [RX] 载荷行(剥掉 "[RX] <ISO时间戳> " 前缀)
# 默认给每行加 `[+Nms]` 相对偏移(daemon 毫秒时基,本次采集第一行 = +0ms)。
# 为什么加:MCU 侧时间戳只有**秒级**,且开机头几行时钟还没起(`----/--/--`),
# 根本量不了耗时;有了它 verify_log 才能查"能开机但慢了几秒"(--max-boot-ms)。
# --raw-log 恢复旧格式(纯载荷)。
data, out = tool("log", "--tail", "0", "--timeout", "15")
if data is None:
print("RUN_TEST_ERROR: log read failed: {}".format(out))
return 2
rx = []
t0 = None
for l in data.get("lines", []):
if not (isinstance(l, str) and l.startswith("[RX] ")):
continue
rest = l[5:]
sp = rest.find(" ")
ts_txt = rest[:sp] if sp >= 0 else ""
payload = rest[sp + 1:] if sp >= 0 else rest
if args.raw_log:
rx.append(payload)
continue
ts = None
if ts_txt:
try:
ts = datetime.fromisoformat(ts_txt)
except ValueError:
ts = None
if ts is None:
rx.append(payload) # 时间戳读不出来就不加前缀(不猜)
continue
if t0 is None:
t0 = ts
rx.append("[+{}ms] {}".format(int((ts - t0).total_seconds() * 1000), payload))
if not rx:
print("RUN_TEST_ERROR: no [RX] lines in log (device silent?)")
return 2
boot_txt.write_bytes(("\r\n".join(rx) + "\r\n").encode("utf-8"))
print("Boot log: {} (lines={} gen={} ts={})".format(
boot_txt, len(rx), data.get("gen"), "raw" if args.raw_log else "+ms"))
# 5.6 烧录回读校验(config "readback_cmd" 模板,{hex} = 待比对镜像路径;
# 成功判据 = 某行以 "readback_ok"(默认 No difference found)开头)。
# 必须排在**采集之后**:实测 ST-LINK -CmpFile 连接时会复位目标(板子当场
# 重打一遍开机日志),排在采集前会截断开机日志并让启动锚点计数误判。
if args.no_verify_flash:
print("READBACK: skipped (--no-verify-flash)")
elif not args.readback_cmd:
print("READBACK: skipped (no 'readback_cmd' in config)")
elif fw_hex is None or not fw_hex.is_file():
print("READBACK: skipped (no fw hex: {})".format(args.fw_hex or "(unset)"))
else:
rb_cmd = args.readback_cmd.replace("{hex}", '"{}"'.format(fw_hex))
rb_rc, rb_text = _run_cmd(_split_cmd(rb_cmd), args.readback_timeout)
# 判定按**行首**匹配:进度条和帮助文本里也可能含成功字样,
# 只有工具自己那行才独占行首(实测 `...100%\r\nNo difference found.`)
rb_ok = any(ln.strip().lower().startswith(args.readback_ok.lower())
for ln in rb_text.splitlines())
if rb_ok:
print("READBACK: OK ({}) rc={}".format(args.readback_ok, rb_rc))
else:
print("READBACK: FAIL -- device flash does NOT match {}".format(fw_hex.name))
for line in rb_text.strip().splitlines()[-10:]:
print(" | {}".format(line))
print("RUN_TEST_ERROR: flash readback mismatch (chip content != built image)")
return 2
# 5.5 自检记录抓取(S7):启用且未提供本地副本时才走 FTP(需 config "device_ip")
selftest_local = args.selftest_log
if args.require_selftest_pass and not selftest_local and not args.device_ip:
print("RUN_TEST_WARN: require_selftest_pass but no device_ip/selftest_log "
"-> treated as no record (S7 PASS path)")
if args.require_selftest_pass and not selftest_local and args.device_ip:
selftest_local = _fetch_selftest_log(args.device_ip, out_dir, stamp)
# 6. 标记校验(含固件身份戳、启动锚点计数、可选的自检记录联合判定)
# 启动锚点(config "boot_marker",如本仓库的 'reset cause:')未配置 =
# 锚点计数关闭——不同固件的"一轮启动"锚点各不相同,宁关勿假判。
from verify_log import verify
boot_count = args.max_boot_count if args.boot_marker else 0
if not args.boot_marker:
print("BOOT ANCHOR: off (set 'boot_marker' in config to catch "
"mid-capture resets / stale captures)")
rc = verify(str(boot_txt), required, args.forbidden, args.show_tail_lines,
suite_summary=args.require_suite_summary,
selftest_pass=args.require_selftest_pass,
selftest_log=selftest_local,
boot_marker=args.boot_marker or "reset cause:",
max_boot_count=boot_count,
expect_reset_cause=args.expect_reset_cause,
require_order=args.require_order,
done_marker=args.done_marker,
max_boot_ms=args.max_boot_ms)
print("RUN_TEST_RC={}".format(rc))
return rc
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,448 @@
#!/usr/bin/env python3
"""verify_log.py - 对抓取的 MCU 调试日志做标记校验(纯标准库,2026-09-20 替代 verify_log.ps1)。
用于 @test 闭环:烧录后从串口助手日志提取的开机日志交给本脚本 grep 标记,
输出结构化判定(格式与旧 PowerShell 版逐字一致,下游文档/习惯不受影响)。
用法:
& '.venv\\Scripts\\python.exe' test/host/verify_log.py --log-path <file>
[--required 'marker1;marker2']
[--forbidden 'FAIL;ERROR;HardFault;assert;[ERR]']
[--require-suite-summary 'Storage Tests;GD5F2GQ5UE Tests']
[--require-selftest-pass] [--selftest-log <selftest.log 本地副本>]
[--show-tail-lines 40]
[--max-boot-count 1] [--boot-marker 'reset cause:']
[--expect-reset-cause 'software;power-on']
[--require-order 'App init OK>Network Init>net init: OK']
[--done-marker 'net init: OK'] [--max-boot-ms 8000] [--json]
退出码(与旧版一致):
0 = PASS(所有 Required 标记找到,Forbidden 零命中,汇总行要求满足)
1 = FAIL(缺 Required / 命中 Forbidden / 汇总行要求不满足)
2 = ERROR(日志文件不存在/不可读)
默认 Required 标记对应 STM32F4-Base 启动序列(见 App/app/app_main.c + App/task/net_task.c),
按测试用例覆盖,例如:
--required 'App init OK;net init: OK;PHY: 100M FULL'
--forbidden 'FAIL;ERROR;HardFault;assert;[ERR]'
语义约定(与旧 PowerShell 版对齐):
- Required:大小写敏感子串匹配(全文)
- Forbidden:大小写**不敏感**子串匹配、逐行计数(旧版 -like 语义;
如 Forbidden 'FAIL' 会命中 "write /log/run.log fail (7)" 这类行)
- 固件错误级日志前缀是 `[ERR]`(`DBG_ERROR`,形如 `[时间][ERR][TAG] 消息`),
与 `ERROR` 子串**不匹配**——默认 Forbidden 因此显式包含 `[ERR]`,驱动/模块
错误行不再漏检(2026-09-21 修复)。测试套件中**预期**出现 `[ERR]` 的用例
(如 GD5F DMA 守卫负路径、FatFs 非法名校验)须用 --forbidden 覆盖默认值。
- 读取编码:先按 UTF-8,解码失败回退系统 locale 编码(旧版 Get-Content 行为)
- 控制台输出重配置为 UTF-8(errors=replace):任何 Windows 控制台代码页
都只影响终端显示,不会导致本脚本崩溃
- 汇总行(2026-10-01 加,T-2):纯 Required 子串匹配有个盲区——套件启动横幅
命中即判 PASS,**套件是否跑到汇总行、断言数是否为 0 完全不可见**(套件中途
挂死/复位、或 Phase 宏全关时会被误判为通过)。`--require-suite-summary`
要求日志中出现 `=== <名称>: N/M PASSED (failed=0) ===` 且 **N > 0**。
- 自检记录联合判定(S7,2026-10 实机窗口):`--require-selftest-pass` 对 /log/selftest.log
的本地副本(定长 64B 记录,格式见 App/log/sys_log.h)判定。自检仅故障写盘(v1 不记
通过项):**文件缺失 / 0 字节 = 无故障 → PASS**;存在任何一条记录 = 有故障 → FAIL
(退出码 1),并逐条打印时间戳 + 描述。抓取失败(设备不可达 / FTP 错误等)由调用方
(run_boot_test.py)先打 WARN,本地文件按缺失处理 → 按上述规则不判整体 FAIL。
- 启动锚点计数(2026-10-10):`--max-boot-count N`(run_boot_test.py 默认传 1)统计
`reset cause:` 锚点出现次数——**0 次** = 采集没从复位开始(标记全中也可能只是上一轮
的残留日志);**>N 次** = 板子在采集窗口内自己复位过(IWDG 咬狗 / Fault 后
`NVIC_SystemReset` / 掉电重启),此前这类运行只要不命中 Forbidden 就判 PASS。
`--expect-reset-cause` 对第一个锚点做原因白名单(大小写不敏感子串),空 = 只报告不判。
- Forbidden 命中行(2026-10-10):命中时逐行打印**行号 + 内容**(最多 6 行,超出报剩余数)。
旧实现只说 `forbidden pattern: [ERR] (3 line(s))`,人还得自己再 grep 一遍才知道是哪几行
——而"是哪几行"恰恰是定位要的东西。
- 标记顺序(`--require-order 'a>b>c'`):按**首次出现的行序**校验启动步骤先后。标记全中
但顺序颠倒 = 某步在另一步之后补做(屏障失序、重试后补跑),旧实现完全看不见。
- 开机耗时(`--done-marker` + `--max-boot-ms`):依赖行首 `[+Nms]` 前缀(run_boot_test.py
2026-10-10 起按 daemon 毫秒时基给每行加,MCU 侧时间戳只有秒级且开机头几行根本没有)。
量"第一行 → done-marker"的真实耗时,抓"能开机但慢了几秒"(mount 重试 / PHY 重协商)。
日志里没有该前缀 → 只 WARN 跳过,不假判 FAIL。
- `--json`:同一套判定以 JSON 输出(供 CI / 上层脚本消费;退出码语义不变)。
"""
import argparse
import json
import locale
import re
import sys
from pathlib import Path
REQUIRED_DEFAULT = "App init OK;Network Init;net init: OK"
FORBIDDEN_DEFAULT = "FAIL;ERROR;HardFault;assert;[ERR]"
# Forbidden 命中时最多打印几条命中行(够定位又不刷屏;剩余条数会报出来)
HIT_LINES_MAX = 6
# 行首 `[+1234ms] `:run_boot_test.py 按 daemon 毫秒时基给每行加的相对偏移
# (MCU 侧时间戳只有秒级,且开机头几行时钟还没起 → 量不了耗时)
_TS_OFF_RE = re.compile(r"^\[\+(\d+)ms\]\s")
# 每轮启动的锚点行(App/app/app_main.c:`reset cause: <原因> (CSR=0x…)`)。
# 一次采集里出现 >1 次 = 板子在采集窗口内自己复位过(IWDG 咬狗 / Fault 后
# NVIC_SystemReset / 掉电重启),此前只有 Forbidden 命中时才被发现,
# "跑完 PASS 但中途重启过"完全不可见(2026-10-10 加)。
BOOT_MARKER_DEFAULT = "reset cause:"
# 套件汇总行:`=== Storage Tests: 3/3 PASSED (failed=0) ===`
# 名称可含空格;N/M 为十进制;failed 允许前导 0。宽松容忍前后空白与
# 日志时间戳前缀(`[2026-10-01 12:00:00.000]`)——故用 search 而非 match。
_SUMMARY_RE = re.compile(
r"===\s*(?P<name>[^:=]+?)\s*:\s*(?P<passed>\d+)\s*/\s*(?P<total>\d+)\s+"
r"PASSED\s*\(\s*failed\s*=\s*(?P<failed>\d+)\s*\)\s*==="
)
def _split_list(spec):
"""';' 分隔列表 → 去空白、去空项。"""
return [p.strip() for p in spec.split(";") if p.strip()]
def _read_lines(path):
raw = Path(path).read_bytes()
for enc in ("utf-8", locale.getpreferredencoding(False)):
try:
return raw.decode(enc).splitlines()
except UnicodeDecodeError:
continue
return raw.decode("utf-8", errors="replace").splitlines()
def _reset_cause_of(line, marker):
"""从 `reset cause: software (NVIC_SystemReset) (CSR=0x14000000)` 取原因文本。"""
i = line.find(marker)
if i < 0:
return ""
tail = line[i + len(marker):].strip()
j = tail.find(" (CSR=")
return tail[:j] if j > 0 else tail
def verify(log_path, required=REQUIRED_DEFAULT, forbidden=FORBIDDEN_DEFAULT,
show_tail=40, out=print, suite_summary="",
selftest_pass=False, selftest_log="",
boot_marker=BOOT_MARKER_DEFAULT, max_boot_count=0,
expect_reset_cause="", require_order="", done_marker="",
max_boot_ms=0, as_json=False):
"""标记校验核心(可被 run_boot_test.py 复用)。返回退出码 0/1/2。
suite_summary: ';' 分隔的套件名列表。非空时,要求日志中每个套件都出现
`=== <名称>: N/M PASSED (failed=0) ===` 且 **N > 0**。
selftest_pass: True 时对 selftest_log(/log/selftest.log 本地副本)联合判定:
文件缺失 / 0 字节 = 无故障(PASS);存在任何 64B 记录 = FAIL(S7)。
selftest_log: 自检记录文件本地路径(空串 = 按文件缺失处理)。
boot_marker / max_boot_count(2026-10-10):统计启动锚点出现次数。
max_boot_count>0 时启用:0 次 = 采集没从复位开始;>N 次 = 板子
在采集窗口内自己复位过(IWDG / Fault 后 NVIC_SystemReset)。
expect_reset_cause: ';' 分隔的原因子串白名单,只对**第一个**锚点生效(大小写不敏感)。
空 = 只报告不判定。
require_order: '>' 分隔的标记序列(2026-10-10),按**首次出现的行序**校验先后;
顺序颠倒 = 某步在另一步之后补做(屏障失序 / 重试补跑)。空 = 不查。
done_marker / max_boot_ms(2026-10-10):量"第一行 → done_marker"的毫秒耗时
(靠行首 `[+Nms]` 前缀);超过 max_boot_ms 判 FAIL。
日志无该前缀 → WARN 跳过,不假判。
as_json: True = 不打印文本判定,最后输出一个 JSON 对象(退出码不变)。
"""
res = {"log_path": str(log_path), "lines": 0, "bytes": 0, "required_missing": [],
"forbidden_hits": [], "boot": None, "suites_bad": [], "selftest": None,
"order_bad": [], "timing": None, "tail": [], "verdict": None, "exit": None}
emit = out
def finish(code):
res["exit"] = code
res["verdict"] = {0: "PASS", 1: "FAIL", 2: "ERROR"}[code]
if as_json:
emit(json.dumps(res, ensure_ascii=False, indent=2))
return code
if as_json:
out = lambda *a, **k: None # 文本判定静默,结果走 JSON
p = Path(log_path)
if not p.is_file():
out("VERIFY_ERROR: log file not found: {}".format(log_path))
return finish(2)
try:
lines = _read_lines(log_path)
except OSError:
out("VERIFY_ERROR: cannot read log file: {}".format(log_path))
return finish(2)
full = "\n".join(lines)
req_list = _split_list(required)
forb_list = _split_list(forbidden)
summary_list = _split_list(suite_summary)
out("=== Log verdict: {} ===".format(log_path))
out("Lines: {}, bytes: {}".format(len(lines), p.stat().st_size))
res["lines"] = len(lines)
res["bytes"] = p.stat().st_size
missing = []
for m in req_list:
if m in full:
out("[OK] required marker found: {}".format(m))
else:
out("[MISS] required marker NOT found: {}".format(m))
missing.append(m)
res["required_missing"] = list(missing)
hits = []
for f in forb_list:
fl = f.lower()
matched = [(i + 1, ln) for i, ln in enumerate(lines) if fl in ln.lower()]
if matched:
out("[HIT] forbidden pattern: {} ({} line(s))".format(f, len(matched)))
# 命中行必须打出来(2026-10-10):旧实现只报 "(3 line(s))",人还得自己
# 再 grep 一遍才知道是哪几行——而"哪几行"正是定位所需的那一步
for ln_no, ln in matched[:HIT_LINES_MAX]:
out(" L{}: {}".format(ln_no, ln.strip()[:200]))
if len(matched) > HIT_LINES_MAX:
out(" ... {} more".format(len(matched) - HIT_LINES_MAX))
hits.append(f)
res["forbidden_hits"].append({
"pattern": f, "count": len(matched),
"lines": [{"no": n, "text": t.strip()[:200]}
for n, t in matched[:HIT_LINES_MAX]]})
else:
out("[OK] forbidden pattern absent: {}".format(f))
# 启动锚点计数(2026-10-10):抓"跑完但中途复位过"和"采集没从复位开始"
boot_issue = False
if max_boot_count > 0:
banner_lines = [ln for ln in lines if boot_marker in ln]
n_boot = len(banner_lines)
causes = [_reset_cause_of(ln, boot_marker) for ln in banner_lines]
if n_boot == 0:
out("[FAIL] no boot banner (marker '{}'): capture did not start at a reset".format(
boot_marker))
boot_issue = True
elif n_boot > max_boot_count:
out("[FAIL] boot banner x{} > allowed {} -> board reset itself during capture".format(
n_boot, max_boot_count))
for ln in banner_lines[:6]:
out(" {}".format(ln.strip()))
boot_issue = True
else:
out("[OK] boot banner x{} (marker '{}')".format(n_boot, boot_marker))
if causes:
out("[INFO] reset cause(s): {}".format("; ".join(causes)))
exp = _split_list(expect_reset_cause)
if exp:
got = causes[0].lower()
if any(e.lower() in got for e in exp):
out("[OK] reset cause '{}' matches expected {}".format(
causes[0], expect_reset_cause))
else:
out("[FAIL] reset cause '{}' not in expected: {}".format(
causes[0], expect_reset_cause))
boot_issue = True
res["boot"] = {"marker": boot_marker, "count": n_boot, "allowed": max_boot_count,
"causes": causes, "issue": boot_issue}
# 标记顺序(2026-10-10):标记全中但**顺序颠倒** = 某步在另一步之后补做
# (屏障失序 / 失败重试补跑)。旧实现只看子串存在性,这类失序完全看不见。
order_issue = False
if require_order:
seq = [s.strip() for s in require_order.split(">") if s.strip()]
pos = []
for m in seq:
idx = next((i for i, ln in enumerate(lines) if m in ln), -1)
pos.append((m, idx))
absent = [m for m, i in pos if i < 0]
if absent:
out("[FAIL] require-order: marker(s) not found: {}".format("; ".join(absent)))
order_issue = True
else:
bad = [(pos[k][0], pos[k + 1][0]) for k in range(len(pos) - 1)
if pos[k][1] > pos[k + 1][1]]
if bad:
out("[FAIL] marker order violated (expected {})".format(require_order))
for a, b in bad:
out(" '{}' first appeared AFTER '{}'".format(a, b))
for m, i in pos:
out(" L{}: {}".format(i + 1, m))
order_issue = True
else:
out("[OK] marker order ok: {}".format(require_order))
res["order_bad"] = [m for m, i in pos if i < 0] + [
"{}>{}".format(a, b) for a, b in
[(pos[k][0], pos[k + 1][0]) for k in range(len(pos) - 1) if pos[k][1] > pos[k + 1][1]]]
# 开机耗时(2026-10-10):行首 [+Nms] = daemon 毫秒时基的相对偏移
# (MCU 侧时间戳只有秒级,且开机头几行时钟还没起,量不出耗时)。
timing = None
timing_issue = False
offs = []
for i, ln in enumerate(lines):
m = _TS_OFF_RE.match(ln)
if m:
offs.append((i, int(m.group(1))))
if offs:
timing = {"ts_lines": len(offs), "first_ms": offs[0][1], "last_ms": offs[-1][1],
"span_ms": offs[-1][1] - offs[0][1]}
out("[INFO] capture span: +{}ms .. +{}ms ({} timestamped line(s))".format(
timing["first_ms"], timing["last_ms"], timing["ts_lines"]))
if done_marker:
di = next((i for i, ln in enumerate(lines) if done_marker in ln), -1)
if di < 0:
out("[WARN] done marker '{}' not found -> boot time not measured".format(
done_marker))
else:
doff = next((off for i, off in offs if i >= di), None)
if doff is None:
out("[WARN] done marker line has no [+Nms] prefix -> not measured")
else:
boot_ms = doff - offs[0][1]
timing["boot_ms"] = boot_ms
timing["done_marker"] = done_marker
out("[INFO] boot time to '{}': {} ms".format(done_marker, boot_ms))
if max_boot_ms > 0 and boot_ms > max_boot_ms:
out("[FAIL] boot time {} ms > limit {} ms".format(boot_ms, max_boot_ms))
timing_issue = True
elif done_marker or max_boot_ms > 0:
out("[WARN] no [+Nms] line prefix in this log -> timing checks skipped "
"(run_boot_test.py adds it; --raw-log turns it off)")
res["timing"] = timing
# 套件汇总行解析(T-2):抓"没跑到汇总行 / 断言数为 0"的盲区
bad_summary = []
if summary_list:
parsed = {}
for ln in lines:
m = _SUMMARY_RE.search(ln)
if m:
parsed.setdefault(m.group("name").strip(), []).append(m)
for name in summary_list:
cands = parsed.get(name)
if not cands:
out("[MISS] suite summary NOT found: '=== {}: N/M PASSED (failed=0) ==='".format(name))
bad_summary.append(name)
continue
ok = False
for m in cands:
passed = int(m.group("passed"))
total = int(m.group("total"))
failed = int(m.group("failed"))
if failed != 0:
out("[FAIL] suite summary reports failures: {} -> {}/{} PASSED (failed={})".format(
name, passed, total, failed))
continue
if total == 0:
out("[FAIL] suite summary total is 0 (no assertion ran): '{}'".format(name))
continue
if passed != total:
out("[FAIL] suite summary inconsistent: {} -> {}/{} PASSED".format(
name, passed, total))
continue
out("[OK] suite summary ok: {} -> {}/{} PASSED".format(name, passed, total))
ok = True
if not ok:
bad_summary.append(name)
res["suites_bad"] = list(bad_summary)
# 自检记录联合判定(S7):/log/selftest.log 定长 64B 记录(格式见 App/log/sys_log.h),
# 仅故障写盘:文件缺失 / 0 字节 = 无故障 → PASS;存在任何一条记录 → FAIL
selftest_fail = False
if selftest_pass:
sp = Path(selftest_log) if selftest_log else None
if sp is None or not sp.is_file() or sp.stat().st_size == 0:
out("[OK] selftest: no fault record (file missing or empty)")
res["selftest"] = {"path": str(sp) if sp else None, "faults": 0, "records": []}
else:
raw = sp.read_bytes()
n = len(raw) // 64
if len(raw) % 64:
out("[WARN] selftest log has {} trailing incomplete bytes (ignored)".format(len(raw) % 64))
recs = []
for i in range(n):
rec = raw[i * 64:(i + 1) * 64]
ts = rec[0:19].decode("ascii", errors="replace")
desc = rec[31:59].split(b"\0", 1)[0].decode("utf-8", errors="replace")
recs.append({"no": i, "ts": ts, "desc": desc})
res["selftest"] = {"path": str(sp), "faults": n, "records": recs}
if n > 0:
selftest_fail = True
out("[FAIL] selftest: {} fault record(s) in {}".format(n, selftest_log))
for r in recs:
out(" #{}: {} {}".format(r["no"], r["ts"], r["desc"]))
else:
out("[OK] selftest: no fault record (0 complete records)")
if show_tail > 0 and lines:
res["tail"] = lines[-show_tail:]
out("")
out("=== Last {} line(s) of log ===".format(show_tail))
for ln in lines[-show_tail:]:
out(ln)
out("")
if (missing or hits or bad_summary or selftest_fail or boot_issue
or order_issue or timing_issue):
out("VERDICT: FAIL")
return finish(1)
out("VERDICT: PASS")
return finish(0)
def main(argv=None):
# 控制台输出固定 UTF-8:Windows 代码页(GBK/UTF-8 终端)均不会让本脚本崩溃
for stream in (sys.stdout, sys.stderr):
try:
stream.reconfigure(encoding="utf-8", errors="replace")
except Exception:
pass
ap = argparse.ArgumentParser(description="MCU debug log marker check (pure stdlib)")
ap.add_argument("--log-path", required=True, help="boot log file to check")
ap.add_argument("--required", default=REQUIRED_DEFAULT,
help="required markers, ';' separated (case-sensitive)")
ap.add_argument("--forbidden", default=FORBIDDEN_DEFAULT,
help="forbidden patterns, ';' separated (case-insensitive)")
ap.add_argument("--require-suite-summary", default="",
help="suite names that MUST appear as '=== <name>: N/M PASSED (failed=0) ===' "
"with N>0, ';' separated (T-2). Empty = off")
ap.add_argument("--show-tail-lines", type=int, default=40,
help="show last N log lines (0 = off)")
ap.add_argument("--require-selftest-pass", action="store_true",
help="judge /log/selftest.log local copy (S7): "
"missing/empty = PASS, any 64B record = FAIL")
ap.add_argument("--selftest-log", default="",
help="local copy path of /log/selftest.log (with --require-selftest-pass)")
ap.add_argument("--boot-marker", default=BOOT_MARKER_DEFAULT,
help="per-boot banner substring (default '{}')".format(BOOT_MARKER_DEFAULT))
ap.add_argument("--max-boot-count", type=int, default=0,
help="fail if the boot banner appears 0 times or more than N times "
"(0 = check off). run_boot_test.py passes 1 by default")
ap.add_argument("--expect-reset-cause", default="",
help="';'-separated substrings the FIRST reset cause must match "
"(case-insensitive). Empty = report only, no verdict")
ap.add_argument("--require-order", default="",
help="'>'-separated markers that must appear in that line order "
"(e.g. 'App init OK>Network Init>net init: OK'). Empty = off")
ap.add_argument("--done-marker", default="",
help="marker whose line marks boot completion, for --max-boot-ms")
ap.add_argument("--max-boot-ms", type=int, default=0,
help="fail if time from first line to --done-marker exceeds N ms "
"(needs '[+Nms]' line prefix; 0 = off)")
ap.add_argument("--json", action="store_true",
help="emit one JSON object instead of the text verdict (exit code unchanged)")
args = ap.parse_args(argv)
return verify(args.log_path, args.required, args.forbidden,
args.show_tail_lines, suite_summary=args.require_suite_summary,
selftest_pass=args.require_selftest_pass,
selftest_log=args.selftest_log,
boot_marker=args.boot_marker,
max_boot_count=args.max_boot_count,
expect_reset_cause=args.expect_reset_cause,
require_order=args.require_order,
done_marker=args.done_marker,
max_boot_ms=args.max_boot_ms,
as_json=args.json)
if __name__ == "__main__":
sys.exit(main())
+1
View File
@@ -0,0 +1 @@
__pycache__/
+52
View File
@@ -0,0 +1,52 @@
---
name: stlink-flash
description: ST-LINK 命令行烧录/复位/回读校验(自带独立脚本 stlink_flash.py,纯标准库,拷到任何 ST-Link+STM32 项目即用):-P 烧录+复位运行 / -CmpFile 全片回读比对 / 只复位。封装"烧后必须 -Rst -Run + 回读校验"的铁律与 -CmpFile 复位目标等实测陷阱。
whenToUse: 需要向 STM32 目标烧录 .hex/.bin、只复位重启目标、校验芯片内容与刚构建镜像是否一致、或排查"反复重下载芯片仍是旧镜像"类烧录假成功时。
---
# stlink-flash — ST-LINK 烧录/复位/回读(独立可运行 skill)
**执行体 = 本 skill 自带脚本** `.dsh/skills/stlink-flash/scripts/stlink_flash.py`
(纯标准库;拷整个 skill 目录到别的 ST-Link 项目即可用,不依赖仓库脚本)。
```powershell
# cwd = 项目根目录
& $PY ".dsh\skills\stlink-flash\scripts\stlink_flash.py" list
& $PY ".dsh\skills\stlink-flash\scripts\stlink_flash.py" flash [hex] # 烧录 + -Rst -Run
& $PY ".dsh\skills\stlink-flash\scripts\stlink_flash.py" cmp [hex] # 回读比对(会复位目标)
& $PY ".dsh\skills\stlink-flash\scripts\stlink_flash.py" reset # 只复位运行
```
镜像缺省取项目档案 `hex`;ST-LINK_CLI 解析:`--cli` > 档案 `stlink_cli` >
`$DSH_STLINK_CLI` > `$DTU_STLINK_CLI` > PATH(**显式设了但无效 = 立即报错,不回退**)。
退出码:`0`=OK / `1`=探针或目标错误 / `2`=CLI 未找到 / `3`=用法。
成功判据内置:flash = rc0 + `Programming Complete`;cmp = 行首 `No difference found`
(输出原样透传,serial-boot-test 的 `readback_ok` 默认值直接可用)。
## 本仓库
仓库原生入口 `MDK-ARM\reset.bat`(flash/cmp/halt/run/list 子命令)与本 skill 脚本
语义一致,两者任选。环境变量 `DTU_STLINK_CLI`(未设回退 PATH;设了但无效 = 报错 exit 2)。
## 铁律与陷阱(实测教训)
- **烧录必须带 `-Rst -Run`,不可省略**:裸 `-P` 会报 `Programming Complete`,但目标核
处于死循环/异常状态时写入并不落地(芯片内容不变)——"反复重下载、芯片一直是旧镜像、
二分法失效"的根因。上层若做自动闭环,固件里加编译期身份戳(`fw build: __DATE__
__TIME__`)才能把这种假成功揪出来(serial-boot-test 的 `fw_marker` 机制)。
- **烧录后必须回读校验**:`-CmpFile` 全片逐字节比对,`No difference found` 才算真写入;
不一致 = 本次运行作废。
- **`-CmpFile` 连接时会复位目标**:板子当场重打一遍开机日志——在自动闭环里必须排在
**串口日志采集之后**,否则开机日志被截断、启动锚点计数误判成"中途复位"。
- **只重启不烧录**用无参 `-Rst -Run`;nRST 未接线的台架(3 线 SWD)这是唯一复位手段,
`-HardRst`/`RM=` 在部分 CLI 版本(v3.5.0 实测)不可用。
- 探针在位先 `list`;`Programming Complete` 但行为像旧固件 → 先怀疑缺 `-Rst -Run`,
再怀疑 hex 没重编(对比 hex 时间戳)。
- 完整复位语义/故障排查(本仓库):`docs/测试说明/ST-LINK_CLI_复位使用说明.md`。
## 与其它 skill 的衔接
- 编译 = `keil-build` skill(先 0 错 0 警再烧)。
- `serial-boot-test` 档案里 `flash_cmd` = 本 skill 入口(如
`"MDK-ARM\\reset.bat flash"`)、`readback_cmd` = `"MDK-ARM\\reset.bat cmp {hex}"`、
`readback_ok` = `no difference found`——闭环脚本已保证"回读排在采集之后"。
@@ -0,0 +1,184 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""stlink_flash.py - ST-LINK 命令行烧录/复位/回读校验(纯标准库,可移植 skill 版)。
用法(**项目根目录**下运行,任意 python3):
python <skill>/scripts/stlink_flash.py list
python <skill>/scripts/stlink_flash.py flash [镜像.hex|.bin] # 烧录 + -Rst -Run
python <skill>/scripts/stlink_flash.py cmp [镜像.hex|.bin] [--base 0x08000000]
python <skill>/scripts/stlink_flash.py reset # 只复位运行
python <skill>/scripts/stlink_flash.py halt | run
镜像缺省取项目档案 "hex"(解析顺序同下)。
ST-LINK_CLI 解析(命令行 > 档案 > 环境 > PATH):
--cli > 档案 "stlink_cli" > $DSH_STLINK_CLI > $DTU_STLINK_CLI > PATH
(环境变量**显式设了但路径无效 = 立即报错,不静默回退**)
铁律(实测教训,脚本已内置):
- 烧录必带 -Rst -Run:裸 -P 在目标核死循环时报 Complete 但内容不落地
- flash 成功判据 = rc==0 且输出含 "Programming Complete"
- cmp 成功判据 = 某行以 "No difference found" 开头(原样透传 CLI 输出,
上层 run_boot_test 的 readback_ok 默认值直接可用)
- cmp 连接时会**复位目标**(板子重打开机日志)→ 自动闭环里必须排在日志采集之后
退出码: 0=OK 1=探针/目标错误 2=CLI 未找到/无效 3=用法错误
"""
import argparse
import json
import os
import shutil
import subprocess
import sys
import time
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():
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_cli(cli_arg, cfg):
for label, val in (("--cli", cli_arg),
("config 'stlink_cli'", cfg.get("stlink_cli")),
("$DSH_STLINK_CLI", os.environ.get("DSH_STLINK_CLI")),
("$DTU_STLINK_CLI", os.environ.get("DTU_STLINK_CLI"))):
if val:
if Path(val).is_file():
return Path(val), label
raise SystemExit("ENV_ERROR: {} is set but not a file: {} "
"(fix the value; no silent fallback)".format(label, val))
w = shutil.which("ST-LINK_CLI.exe") or shutil.which("ST-LINK_CLI")
if w:
return Path(w), "PATH"
raise SystemExit("ENV_ERROR: ST-LINK_CLI.exe not found "
"(--cli / config 'stlink_cli' / $DSH_STLINK_CLI / $DTU_STLINK_CLI / PATH)")
def run_cli(cli, args, timeout):
t0 = time.monotonic()
try:
proc = subprocess.run([str(cli)] + args, capture_output=True, timeout=timeout)
rc = proc.returncode
raw = (proc.stdout or b"") + (proc.stderr or b"")
except subprocess.TimeoutExpired as e:
rc = -1
raw = (e.stdout or b"") + (e.stderr or b"")
text = raw.decode("utf-8", errors="replace")
for line in text.strip().splitlines():
print(line)
print("RC: {} elapsed_ms={}".format(rc, int((time.monotonic() - t0) * 1000)))
return rc, text
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="ST-LINK CLI flash/reset/readback (pure stdlib)")
ap.add_argument("command", choices=["list", "flash", "cmp", "reset", "halt", "run"])
ap.add_argument("image", nargs="?", default=None,
help="image file (.hex/.bin; default: config 'hex')")
ap.add_argument("--cli", default=None, help="ST-LINK_CLI.exe path")
ap.add_argument("--base", default="0x08000000", help="cmp base address")
ap.add_argument("--timeout", type=int, default=None,
help="subprocess timeout (default: flash 300 / cmp 180 / other 60)")
args = ap.parse_args(argv)
cfg, err = load_config()
if err:
print("ENV_ERROR: {}".format(err))
return 3
cli, src = find_cli(args.cli, cfg)
print("CLI: {} ({})".format(cli, src))
if args.command in ("list", "reset", "halt", "run"):
if args.image:
print("NOTE: image argument ignored for '{}'".format(args.command))
timeout = args.timeout or 60
if args.command == "list":
rc, _ = run_cli(cli, ["-List"], timeout)
print("STLINK_RC={}".format(rc))
return 0 if rc == 0 else 1
if args.command == "reset":
rc, _ = run_cli(cli, ["-c", "SWD", "-Rst", "-Run", "-Q"], timeout)
print("STLINK_RC={}".format(rc))
return 0 if rc == 0 else 1
if args.command == "halt":
rc, _ = run_cli(cli, ["-c", "SWD", "-Rst", "-Halt"], timeout)
print("STLINK_RC={}".format(rc))
return 0 if rc == 0 else 1
rc, _ = run_cli(cli, ["-c", "SWD", "-Run"], timeout)
print("STLINK_RC={}".format(rc))
return 0 if rc == 0 else 1
# flash / cmp 需要镜像
img = args.image or cfg.get("hex") or ""
if not img:
print("STLINK_ERROR: no image given and no config 'hex'")
return 3
img = Path(img)
if not img.is_file():
print("STLINK_ERROR: image not found: {}".format(img))
return 3
if args.command == "flash":
print("FLASH: {} then reset and run".format(img))
rc, text = run_cli(cli, ["-c", "SWD", "-P", str(img), "-Rst", "-Run", "-Q"],
args.timeout or 300)
if rc == 0 and "programming complete" in text.lower():
print("FLASH: OK")
print("STLINK_RC=0")
return 0
if rc == 0:
print("STLINK_ERROR: rc=0 but no 'Programming Complete' in output "
"(treat as failure - possible forwarded/hung run)")
print("STLINK_RC=1")
return 1
print("STLINK_RC=1")
return 1
# cmp:原样透传输出("No difference found" 独占行首 = 成功判据)
print("CMP: {} vs device flash @ {} (NOTE: connecting resets the target)".format(
img, args.base))
rc, text = run_cli(cli, ["-c", "SWD", "-CmpFile", str(img), args.base],
args.timeout or 180)
ok = any(ln.strip().lower().startswith("no difference found")
for ln in text.splitlines())
if ok:
print("CMP: OK (No difference found)")
print("STLINK_RC=0")
return 0
print("STLINK_ERROR: readback mismatch or compare failed")
print("STLINK_RC=1")
return 1
if __name__ == "__main__":
sys.exit(main())
@@ -1,6 +1,7 @@
--- ---
name: ui-verify name: ui-verify
description: UI 改动后的回归验证:5 步门禁(模拟器构建、菜单像素回归、内容页黄金参考、Keil 构建、代码规范)。Use whenever code under Drivers/BSP/UI, 字体 ui_font_cn_12.c / ui_cn_chars.txt、菜单/内容页被修改,或用户要求"验证 UI / 跑 UI 回归 / UI 改动检查"。 description: UI 改动后的回归验证:5 步门禁(模拟器构建、菜单像素回归、内容页黄金参考、Keil 构建、代码规范)。
whenToUse: Drivers/BSP/UI、字体 ui_font_cn_12.c / ui_cn_chars.txt、菜单/内容页被修改之后,或用户要求"验证 UI / 跑 UI 回归 / UI 改动检查"时。
--- ---
# UI 回归验证 # UI 回归验证
-19
View File
@@ -1,19 +0,0 @@
---
description: Build the STM32F4-Base Keil MDK project. Use ONLY when the user asks to build, compile, or verify the project.
agent: build
---
Run the Keil MDK-ARM build by executing the build script with the bash tool. The repo root is the working directory; use the repo-relative path (the old absolute path `D:\Code\DTU 程序\...` is stale):
```powershell
& "MDK-ARM\build.bat"
```
Then read the tail of `MDK-ARM/build_log.txt` and report the result.
Exit code interpretation (per AGENTS.md / `MDK-ARM/build.bat`):
- 0 = success (0 errors, 0 warnings) — PASS
- 1 = build failure (compile errors, or warnings-only, or no build log produced) — FAIL, must fix
- 2 = environment error (`DTU_KEIL_UV4` unset or invalid path) — fix the tool path, then rerun
If the build fails, read `MDK-ARM/build_log.txt`, fix the reported errors/warnings, and rerun `@build` until it returns 0.
-20
View File
@@ -1,20 +0,0 @@
---
description: Download (flash) the program to the STM32 via Keil MDK. Use ONLY when the user asks to flash, download, program, or burn the firmware to the MCU.
agent: build
---
Run the Keil MDK flash download by executing the download script with the bash tool. The repo root is the working directory; use the repo-relative path (the old absolute path `D:\Code\DTU 程序\...` is stale):
```powershell
& "MDK-ARM\download.bat"
```
Then read the tail of `MDK-ARM/download_log.txt` and report the result.
This compiles the project then flashes the target via the configured debugger (ST-Link).
Exit code interpretation:
- 0 = download successful — PASS
- nonzero = download/build failed — see `MDK-ARM/download_log.txt` for details
Note: Since flashing overwrites the running firmware, confirm the debug probe is connected and the user is ready to flash before running this. If the user only wants to verify compilation (not flash), use `@build` instead.
-74
View File
@@ -1,74 +0,0 @@
---
description: Build → flash (download) → capture USART1 debug log → verify boot/test markers → iterate. The full automated test loop for the STM32F4-Base target.
agent: build
---
Run the full automated test loop: **build → download → capture serial debug → verify markers → report/iterate**.
Prerequisites (checked in step 0):
- Repo `.venv` exists with `pyserial` installed (rebuild per AGENTS.md "Python 环境" if missing).
- ST-Link probe connected; target powered. The USB-serial (CH340) may stay idle while flashing — ST-Link download does not conflict with the serial port.
## Procedure
### Step 0 — Pre-flight: serial assistant + port
Verify the serial assistant works and the target port exists:
```powershell
& '.venv\Scripts\python.exe' 'tools/serial_tool/serial_tool.py' selftest # SELFTEST PASS
& '.venv\Scripts\python.exe' 'tools/serial_tool/serial_tool.py' ports # JSON: 端口列表 + configured 标注
```
Read `.opencode/serial_config.json` for the authoritative `portName` (COM6) and confirm it appears in the `ports` output. If the port is missing, the CH340 driver or the cable is the problem — do not continue.
### Step 1 — Build verify
Run the build script (repo-relative; the repo root is the working directory):
```powershell
& "MDK-ARM\build.bat"
```
Interpretation (per AGENTS.md): exit code 0 = PASS (0 errors, 0 warnings). 1/2+ = FAIL. If it fails, read `MDK-ARM/build_log.txt`, fix, and rerun `@build` until exit code 0 **before** touching the hardware.
### Step 2 — Flash + capture boot log (one orchestrated call)
Use `test/host/run_boot_test.py` (pure-stdlib Python, 2026-09-20 起替代 PowerShell 版) which opens the port + clears the log (arming),
flashes, waits for RX silence, extracts the boot log and verifies — all in one
call. The serial daemon buffers the full stream in `tools/serial_tool/state/serial.log`,
so nothing can be lost between steps:
```powershell
& '.venv\Scripts\python.exe' test/host/run_boot_test.py `
--settle-ms 8000 --max-wait-ms 30000 `
--flash-cmd "MDK-ARM\download.bat" `
--required "App init OK;Network Init;net init: OK" `
--forbidden "FAIL;ERROR;HardFault"
```
`--port` defaults to `.opencode/serial_config.json`; override only if needed.
Exit codes: **0 = PASS**, 1 = FAIL (marker miss / forbidden hit), 2 = capture/flash error, 3 = preflight/usage error.
### Step 3 — Iterate
- **PASS** → report the verdict and which markers matched; done.
- **FAIL** → the `[MISS]`/`[HIT]` lines + the log tail show what went wrong. Fix the code, then loop again from **Step 1**. Do not skip the build step.
## Alternative: interactive serial debug
For interactive command/response (not boot-log capture), drive the serial assistant directly:
1. `ports` → confirm the target port.
2. `open --port COM6 --baud 115200` (idempotent; auto-starts the daemon).
3. `send "text"` / `send --hex 0D0A` to transmit; `log --tail 20` (or Read the log file directly) to see RX history.
4. `close` when done (the daemon itself keeps running; `stop` only if the port must be freed for another tool).
## Notes
- **串口端口**:以 `.opencode/serial_config.json` 为准(本机为 COM6)。每次连接前先读它。
- **全量历史 = 日志文件**:daemon 把 RX/TX/SYS 持续写入 `tools/serial_tool/state/serial.log`,直接 Read/Grep 即可,无需采集窗口仪式。
- **⚠️ 静默判据**:boot 任务间有 ~3-4s 自然静默间隙,`--settle-ms` 必须 ≥ 8000(默认已如此),否则日志截断。
- **daemon 生命周期**:任何命令自动拉起 daemon(无需显式 start);`restart` 保证加载最新代码;强杀 daemon 后下条命令自动恢复(串口需重新 `open`)。
- UI (LVGL) task `ui_task` 已有 `UI port init: OK` / `UI menu init: OK` 打印,可用 `--required "UI port init: OK;UI menu init: OK"` 自动回归 UI 初始化。
- Full command contract / daemon behavior / known traps: `docs/测试说明/串口助手设计说明.md` and `docs/测试说明/自动化测试流程.md`.
-6
View File
@@ -1,6 +0,0 @@
{
"portName": "COM6",
"portHint": "USB 串行设备",
"baudRate": 115200,
"note": "MCU 调试串口(USART1 → CH340 → 本机枚举为 COM6(2026-09-21 台架重插后由 COM7 变更))。Python 串口助手(tools/serial_tool)与 @test 均以此文件为端口/波特率默认值(单一配置源);执行前先读取。如本机端口变化,请人工修改此文件。"
}
+19
View File
@@ -0,0 +1,19 @@
{
"portName": "COM6",
"baudRate": 115200,
"flash_cmd": ".venv\\Scripts\\python.exe .dsh\\skills\\stlink-flash\\scripts\\stlink_flash.py flash",
"hex": "MDK-ARM\\STM32F407-Demo\\STM32F407-Demo.hex",
"fw_marker": "fw build: ",
"readback_cmd": ".venv\\Scripts\\python.exe .dsh\\skills\\stlink-flash\\scripts\\stlink_flash.py cmp {hex}",
"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",
"settle_ms": 8000,
"max_wait_ms": 30000,
"first_wait_ms": 12000
}
+1 -1
View File
@@ -113,7 +113,7 @@ STM32F407ZGTx @168MHz(HSE 8MHz → PLL M=4/N=168/P=2);**仅 Keil MDK-ARM v
- 入口:`& '.venv\Scripts\python.exe' tools/serial_tool/serial_tool.py <命令>`(daemon 自动拉起;全部命令 5s 超时、JSON 输出、exit 0/1/2/3) - 入口:`& '.venv\Scripts\python.exe' tools/serial_tool/serial_tool.py <命令>`(daemon 自动拉起;全部命令 5s 超时、JSON 输出、exit 0/1/2/3)
- 全量历史 = 日志文件 `tools/serial_tool/state/serial.log`(RX/TX/SYS 时间戳行);agent 直接 Read/Grep 文件,**无采集窗口仪式**;20MB 上限自动截头保尾 10MB - 全量历史 = 日志文件 `tools/serial_tool/state/serial.log`(RX/TX/SYS 时间戳行);agent 直接 Read/Grep 文件,**无采集窗口仪式**;20MB 上限自动截头保尾 10MB
- 常用:`open`(幂等)/ `send <文本>`(或 `--hex 0D0A`)/ `log --tail N --grep P [-i] [--regex]`(**先过滤后截尾**)/ `clear`(代际+1)/ `wait-idle --settle-ms 8000 --max-ms 30000 [--first-timeout-ms 12000]` / `status` / `close` - 常用:`open`(幂等)/ `send <文本>`(或 `--hex 0D0A`)/ `log --tail N --grep P [-i] [--regex]`(**先过滤后截尾**)/ `clear`(代际+1)/ `wait-idle --settle-ms 8000 --max-ms 30000 [--first-timeout-ms 12000]` / `status` / `close`
- 串口端口单一配置源:`.opencode/serial_config.json`(当前 COM6,2026-09-21 台架重插后由 COM7 变更);端口变化**只改这个文件** - 串口端口单一配置源:`.serialtest/config.json`(当前 COM6,2026-09-21 台架重插后由 COM7 变更;2026-10-10 由 `.opencode/serial_config.json` 迁入,仓库脚本与 `.dsh/skills/` 串口 skill 共用此文件);端口变化**只改这个文件**
- 命令/工作流/故障排查 `docs/测试说明/串口助手使用指南.md`;契约/daemon 行为/陷阱 `docs/测试说明/串口助手设计说明.md` - 命令/工作流/故障排查 `docs/测试说明/串口助手使用指南.md`;契约/daemon 行为/陷阱 `docs/测试说明/串口助手设计说明.md`
- **抓开机日志铁律(2026-09-20 实测;②③ 已于 2026-10-10 修复)**:① 顺序 `open` → `clear` → 烧录/复位 → `wait-idle` → 读日志校验标记(**采集必须先于烧录**,否则错过开机头几行);② `wait-idle` 的 CLI 超时现按 `--max-ms` **自动推导**,不再需要手工带 `--timeout`(旧版固定 5s 会在 daemon 还在等时掐断连接、daemon.log 刷 traceback);③ `log --grep` 已改为**先过滤后截尾**——旧实现先 `tail`(默认 50)后 grep,开机 marker 必然查不到,这才是当年"`log --grep` 实测不可信"的真根因,现可直接作判定依据(Read/Grep 文件仍等效) - **抓开机日志铁律(2026-09-20 实测;②③ 已于 2026-10-10 修复)**:① 顺序 `open` → `clear` → 烧录/复位 → `wait-idle` → 读日志校验标记(**采集必须先于烧录**,否则错过开机头几行);② `wait-idle` 的 CLI 超时现按 `--max-ms` **自动推导**,不再需要手工带 `--timeout`(旧版固定 5s 会在 daemon 还在等时掐断连接、daemon.log 刷 traceback);③ `log --grep` 已改为**先过滤后截尾**——旧实现先 `tail`(默认 50)后 grep,开机 marker 必然查不到,这才是当年"`log --grep` 实测不可信"的真根因,现可直接作判定依据(Read/Grep 文件仍等效)
- **`wait-idle` 判据(2026-10-10 修)**:`no_data` = **本次等待期间零字节**(旧实现比的是 daemon 累计 `rx_bytes` 是否为 0,常驻 daemon 下永不触发 → 板子全程静默也误报 `idle:true`);**首字节到达前不启动静默计时**(`--first-timeout-ms` 兜底,`run_boot_test.py --first-wait-ms` 默认 12000);返回的 `rx_bytes` 是本次增量而非累计 - **`wait-idle` 判据(2026-10-10 修)**:`no_data` = **本次等待期间零字节**(旧实现比的是 daemon 累计 `rx_bytes` 是否为 0,常驻 daemon 下永不触发 → 板子全程静默也误报 `idle:true`);**首字节到达前不启动静默计时**(`--first-timeout-ms` 兜底,`run_boot_test.py --first-wait-ms` 默认 12000);返回的 `rx_bytes` 是本次增量而非累计
+3 -3
View File
@@ -199,8 +199,8 @@ STM32F4-Base/
├── STM32F407-Demo.ioc # CubeMX 工程文件 ├── STM32F407-Demo.ioc # CubeMX 工程文件
├── .pre-commit-config.yaml # pre-commit 钩子配置 ├── .pre-commit-config.yaml # pre-commit 钩子配置
├── .gitlint # commit message 检查规则 ├── .gitlint # commit message 检查规则
├── opencode.json # opencode 配置 ├── .dsh/skills/ # DSH 项目级 skill(编译/烧录/串口采集/闭环 + 流程类)
├── .opencode/ # opencode 本地配置(串口等) ├── .serialtest/ # 串口测试档案(串口端口单一配置源 + 烧录/标记参数)
└── AGENTS.md # AI 开发规范 └── AGENTS.md # AI 开发规范
``` ```
@@ -314,7 +314,7 @@ UI 改动回归门禁(菜单像素回归 + 内容页黄金参考 + 池水位
通过仓库内置 Python 串口助手(`tools/serial_tool/`)自主读取 USART1 串口打印: 通过仓库内置 Python 串口助手(`tools/serial_tool/`)自主读取 USART1 串口打印:
- 端口配置:`.opencode/serial_config.json`(单一配置源) - 端口配置:`.serialtest/config.json`(单一配置源)
- 用法详见 `docs/测试说明/串口助手使用指南.md`,设计见 `docs/测试说明/串口助手设计说明.md` - 用法详见 `docs/测试说明/串口助手使用指南.md`,设计见 `docs/测试说明/串口助手设计说明.md`
## 开发规范 ## 开发规范
+14
View File
@@ -15,6 +15,7 @@ docs/
├── 产品需求/ 产品需求设计文档(面向产品的需求基线) ├── 产品需求/ 产品需求设计文档(面向产品的需求基线)
├── 架构设计/ 全局架构与设计方法论 ├── 架构设计/ 全局架构与设计方法论
├── 子系统设计/ 各功能子系统的设计说明 ├── 子系统设计/ 各功能子系统的设计说明
├── 算法/ 傅氏算法与继电保护算法专题
├── 网络/ BSD Socket API 使用与内部实现 ├── 网络/ BSD Socket API 使用与内部实现
├── 测试说明/ 测试流程、模块测试指南、工具说明 ├── 测试说明/ 测试流程、模块测试指南、工具说明
├── 代码规范/ 编码规范与静态检查门禁 ├── 代码规范/ 编码规范与静态检查门禁
@@ -78,6 +79,8 @@ docs/
## 5. 子系统设计 ## 5. 子系统设计
### 5.1 功能子系统
| 文档 | 内容 | | 文档 | 内容 |
|---|---| |---|---|
| [`配置文件设计说明.md`](子系统设计/配置文件设计说明.md) | `/config.ini` 配置引擎:分层、解析、原子持久化(旧 NCFG 档已废弃) | | [`配置文件设计说明.md`](子系统设计/配置文件设计说明.md) | `/config.ini` 配置引擎:分层、解析、原子持久化(旧 NCFG 档已废弃) |
@@ -89,6 +92,16 @@ docs/
| [`网络/BSD_Socket_API_使用指南.md`](网络/BSD_Socket_API_使用指南.md) | **上层视图**:BSD Socket API 公开语义、示例、常见问题 | | [`网络/BSD_Socket_API_使用指南.md`](网络/BSD_Socket_API_使用指南.md) | **上层视图**:BSD Socket API 公开语义、示例、常见问题 |
| [`网络/BSD_Socket_API_函数调用关系.md`](网络/BSD_Socket_API_函数调用关系.md) | **内部视图**:netTask / 消息队列 / L3 挂起 / 重试对账的调用关系与设计意图 | | [`网络/BSD_Socket_API_函数调用关系.md`](网络/BSD_Socket_API_函数调用关系.md) | **内部视图**:netTask / 消息队列 / L3 挂起 / 重试对账的调用关系与设计意图 |
| [`网络/Net层阻塞模型架构设计.md`](网络/Net层阻塞模型架构设计.md) | **等待模型**:pending 请求架构、五路完成语义、设计边界与性能特征 | | [`网络/Net层阻塞模型架构设计.md`](网络/Net层阻塞模型架构设计.md) | **等待模型**:pending 请求架构、五路完成语义、设计边界与性能特征 |
---
### 5.2 算法专题
| 文档 | 内容 |
|---|---|
| [`傅氏算法详解.md`](算法/傅氏算法详解.md) | 全周波傅氏(单频 DFT)原理与工程实现:正交性、梳状带通、滑动窗、系数表折算有效值;保护/遥测双轨有效值的算法基础 |
| [`继电保护功能复现规划.md`](算法/继电保护功能复现规划.md) | **保护功能复现规划**:把参考系统(CLS100)保护链路映射到本项目(STM32F4)的分层功能/性能/参数清单、平台适配(N-18 等)与分阶段路线图 |
--- ---
## 6. 测试说明 ## 6. 测试说明
@@ -248,6 +261,7 @@ docs/
| 日期 | 变更 | 说明 | | 日期 | 变更 | 说明 |
|---|---|---| |---|---|---|
| 2026-10-10 | 新增算法专题 | 新建 `docs/算法/继电保护功能复现规划.md`(保护链路复现总规划);本索引 §1 目录树补 `算法/`、§5 拆为 5.1 功能子系统 / 5.2 算法专题并登记 `算法/` 两篇文档 |
| 2026-10-05 | 新增产品需求文档 | 新建 `docs/产品需求/产品需求设计文档.md`(面向产品的需求基线);本索引新增 §3 产品需求、§2 推荐阅读顺序置顶、§1 目录树补 `产品需求/`;后续章节号顺延(架构设计 §4 … 变更记录 §12) | | 2026-10-05 | 新增产品需求文档 | 新建 `docs/产品需求/产品需求设计文档.md`(面向产品的需求基线);本索引新增 §3 产品需求、§2 推荐阅读顺序置顶、§1 目录树补 `产品需求/`;后续章节号顺延(架构设计 §4 … 变更记录 §12) |
| 2026-09-25 | 目录重组 + 索引建立 | 顶层 16 篇散落 md 归入 5 个新目录(`架构设计/` `子系统设计/` `网络/` `审查记录/` `环境与部署/`);全仓库 90 处引用同步;新建本索引(D-5) | | 2026-09-25 | 目录重组 + 索引建立 | 顶层 16 篇散落 md 归入 5 个新目录(`架构设计/` `子系统设计/` `网络/` `审查记录/` `环境与部署/`);全仓库 90 处引用同步;新建本索引(D-5) |
| 2026-09-25 | D-6 目录去空格 | `docs/UI 设计/` → `docs/UI设计/`;全仓库 67 处引用同步(含 URL 编码形态 `UI%20设计`);命名约定写入 §10 | | 2026-09-25 | D-6 目录去空格 | `docs/UI 设计/` → `docs/UI设计/`;全仓库 67 处引用同步(含 URL 编码形态 `UI%20设计`);命名约定写入 §10 |
+12 -12
View File
@@ -445,17 +445,17 @@ sequenceDiagram
### 5.9 ADC 采集(FR-ADC)⬜ 待实现 ### 5.9 ADC 采集(FR-ADC)⬜ 待实现
- **需求(待产品定义确认)**:周期采集 TPAFE5160 多通道模拟量并转发。 - **需求(B0 已确认,2026-10-10)**:**持续采集** 8 路电气量(Ua/Ub/Uc/U0、Ia/Ib/Ic/I0,**1000 Hz / 20 点/周波**),经保护/遥测算法后**上送 IEC 60870-5-104**(以太网 TCP 2404;遥测上送短浮点、采集上传系数 = 1)。
- **现状**:`tpafe5160_init()` 与 `tpafe5160_start_conv_irq()` 在启动序列调用一次;**全仓库无周期性 CONVST 重触发**;`tpafe5160_snapshot()` 无消费者(`adc_task.c` 为空桩——2026-10-10 起已挂进 `uvprojx`,仍未在 `freertos.c` 创建);STM32 侧 ADC1 已生成但**全仓库无 `HAL_ADC_Start`**。 - **现状**:`tpafe5160_init()` 与 `tpafe5160_start_conv_irq()` 在启动序列调用一次;**全仓库无周期性 CONVST 重触发**;`tpafe5160_snapshot()` 无消费者(`adc_task.c` 为空桩——2026-10-10 起已挂进 `uvprojx`,仍未在 `freertos.c` 创建);STM32 侧 ADC1 已生成但**全仓库无 `HAL_ADC_Start`**。
- **后果**:上电后仅采集 1 个样本,此后数据永久陈旧。 - **后果**:上电后仅采集 1 个样本,此后数据永久陈旧。
- **待确认(阻塞项 B0)**:① 采集是否持续 ② 采样率/通道 ③ 转发目标 ④ `APP_ADC_ENABLE` 语义。 - **B0 结论**(2026-10-10,详见 `docs/算法/继电保护功能复现规划.md` §1.5):① 持续采集 ② 1000 Hz(20 点/周波),通道 = 4U+4I(点表 `4011~4018`)③ 上行 **IEC 60870-5-104**(2404,非 ModBus RTU 上行)④ `APP_ADC_ENABLE` 语义 = 门控 ADC 采集链路(ADC 为遥测/保护必需来源;是否接线或删除占位宏待定)。
- **实现约束**(见 §9):周期 CONVST 重触发 + `adc_task` 消费 `tpafe5160_snapshot()`;**原子快照 API 已就位**(2026-10-10 修 E-44 / E-45 / E-58,旧的 `get_buf` / `data_ready` / `clear_ready` 已删 → 取数不再撕裂、重触发不再丢帧、芯片不驱动总线会被 `init()` 首帧自检抓到);栈预算核算;V0.1 通道映射已固化 → `docs/硬件平台/ADC通道映射说明.md`(13/17 路置换;语义 UA/UB/UC/UN、IA/IB/IC/IN、V_ZL2=温度,其余备用;网名 ADC17 = PC4 用 `ADC_CHANNEL_14`,**不是** `ADC_CHANNEL_12`)。⚠ **8 路电气量分属 TPAFE / STM32 两片转换器属 V0.1 硬件缺陷**(跨转换器无同一时刻性、精度与量程不一致,登记簿 N-18,V0.2 改版要求全部走 TPAFE)→ 在 V0.1 上主板侧只做**逐通道独立监视**,功率 / 功率因数 / 相角 / 矢量类量一律取子板计量模块结果,不得由主板混算。 - **实现约束**(见 §9):周期 CONVST 重触发 + `adc_task` 消费 `tpafe5160_snapshot()`;**原子快照 API 已就位**(2026-10-10 修 E-44 / E-45 / E-58,旧的 `get_buf` / `data_ready` / `clear_ready` 已删 → 取数不再撕裂、重触发不再丢帧、芯片不驱动总线会被 `init()` 首帧自检抓到);栈预算核算;V0.1 通道映射已固化 → `docs/硬件平台/ADC通道映射说明.md`(13/17 路置换;语义 UA/UB/UC/UN、IA/IB/IC/IN、V_ZL2=温度,其余备用;网名 ADC17 = PC4 用 `ADC_CHANNEL_14`,**不是** `ADC_CHANNEL_12`)。⚠ **8 路电气量分属 TPAFE / STM32 两片转换器属 V0.1 硬件缺陷**(跨转换器无同一时刻性、精度与量程不一致,登记簿 N-18,V0.2 改版要求全部走 TPAFE)→ 在 V0.1 上主板侧只做**逐通道独立监视**,功率 / 功率因数 / 相角 / 矢量类量一律取子板计量模块结果,不得由主板混算。
### 5.10 RS-485 采集(FR-RS485)⬜ 待实现 ### 5.10 RS-485 采集(FR-RS485)⬜ 待实现
- **需求(待产品定义确认)**:半双工 RS-485 收发、协议帧解析、转发。 - **需求(B0 已确认,2026-10-10)**:半双工 RS-485 **对下 ModBus RTU 采集**(下行采集口,非上行口;上行走以太网 IEC 60870-5-104)。
- **现状**:`rs485_init()` 已调用;`rs485_task.c` 为空桩,未在 `freertos.c` 创建;`App/protocol/` 预留(仅 README + `.gitkeep`)。 - **现状**:`rs485_init()` 已调用;`rs485_task.c` 为空桩,未在 `freertos.c` 创建;`App/protocol/` 预留(仅 README + `.gitkeep`)。
- **实现约束**:双缓冲 + 序号(防包撕裂)+ DE 收尾(超时路径等 TC)+ 协议状态机;解析必须与硬件 I/O 分离(`App/protocol/` 正为此预留)。 - **实现约束**:双缓冲 + 序号(防包撕裂)+ DE 收尾(超时路径等 TC)+ 协议状态机;解析必须与硬件 I/O 分离(`App/protocol/` 正为此预留)。B0 结论详见 `docs/算法/继电保护功能复现规划.md` §1.5。
--- ---
@@ -549,7 +549,7 @@ mac=00:00:00:00:00:00
|---|---| |---|---|
| 端口 | USART1(PA9 TX / PA10 RX),115200 8N1 | | 端口 | USART1(PA9 TX / PA10 RX),115200 8N1 |
| 用途 | printf 日志 + `@test` 采集通道 | | 用途 | printf 日志 + `@test` 采集通道 |
| 配置源 | `.opencode/serial_config.json`(单一配置源) | | 配置源 | `.serialtest/config.json`(单一配置源) |
| 工具 | 仓库内置 Python 串口助手(`tools/serial_tool/`,常驻 daemon + CLI) | | 工具 | 仓库内置 Python 串口助手(`tools/serial_tool/`,常驻 daemon + CLI) |
**启动标记(`@test` 判据)**: **启动标记(`@test` 判据)**:
@@ -771,16 +771,16 @@ cfg-file-unit-test、test-suite-switch、gitlint。
| 缺口 | 现状 | 影响 | | 缺口 | 现状 | 影响 |
|---|---|---| |---|---|---|
| **ADC 采集链路** | `adc_task.c` 空桩(已挂进 `uvprojx`、未建任务);无周期 CONVST 重触发;`tpafe5160_snapshot()` 无消费者;STM32 侧无 `HAL_ADC_Start` | 上电后仅采 1 个样本(驱动取数契约已于 2026-10-10 修好,接消费者不再有撕裂/丢样风险) | | **ADC 采集链路** | `adc_task.c` 空桩(已挂进 `uvprojx`、未建任务);无周期 CONVST 重触发;`tpafe5160_snapshot()` 无消费者;STM32 侧无 `HAL_ADC_Start` | 上电后仅采 1 个样本(驱动取数契约已于 2026-10-10 修好,接消费者不再有撕裂/丢样风险) |
| **RS-485 采集链路** | `rs485_task.c` 空桩;`App/protocol/` 仅 README | 无 RS-485 数据收发 | | **RS-485 采集链路** | `rs485_task.c` 空桩;`App/protocol/` 仅 README | 无 RS-485 数据收发(B0 定:**对下 ModBus RTU 采集**) |
| **DTU 核心转发职责** | 未定义 | 采集到数据后无处转发 | | **DTU 核心转发职责** | **B0 已定(2026-10-10)**:上行 **IEC 60870-5-104**(以太网 TCP 2404,遥测短浮点系数 1);RS-485 对下 ModBus RTU 采集 | 转发目标已明确,待实现 |
> **根因不是"缺陷难修",而是"功能没接"**——B1/B2 的规模完全取决于 B0 产品定义。 > **根因不是"缺陷难修",而是"功能没接"**——B1/B2 的规模取决于 B0 产品定义,**B0 已于 2026-10-10 确认**(见 `docs/算法/继电保护功能复现规划.md` §1.5)。
### 9.2 阶段路线图 ### 9.2 阶段路线图
```text ```text
【阶段 B 功能】 B0 产品定义确认 ─┬─ B3 E-59 门禁固化(并行) 【阶段 B 功能】 B0 产品定义确认 ✅ ─┬─ B3 E-59 门禁固化(并行)
(当前最高优先) └─ E-58 原子快照 → B1 ADC → B2 RS485 (当前最高优先) └─ E-58 原子快照 → B1 ADC → B2 RS485
↓ ↓
【阶段 C 结构】 C1 pending 二期 ─ C3 日志管线 ─ C4 栈(S-1) ─ C5 收口 ─ C6 USART1 TX-only 【阶段 C 结构】 C1 pending 二期 ─ C3 日志管线 ─ C4 栈(S-1) ─ C5 收口 ─ C6 USART1 TX-only
↓ ↓
@@ -789,7 +789,7 @@ cfg-file-unit-test、test-suite-switch、gitlint。
| 阶段 | 关键任务 | 前置 | | 阶段 | 关键任务 | 前置 |
|---|---|---| |---|---|---|
| **B0** | 产品定义确认(阻塞项):采集是否持续 / 采样率通道 / 转发目标 / `APP_*_ENABLE` 语义 | — | | **B0** | 产品定义确认 ✅(2026-10-10):持续采集 / 1000 Hz·20 点周波 / 上行 IEC 60870-5-104(2404)/ RS-485 对下 ModBus RTU;详见 `docs/算法/继电保护功能复现规划.md` §1.5 | — |
| **B1** | ADC 采集链路实现(周期 CONVST + `adc_task` 消费;**先修原子快照 API**) | B0 + E-59 门禁 | | **B1** | ADC 采集链路实现(周期 CONVST + `adc_task` 消费;**先修原子快照 API**) | B0 + E-59 门禁 |
| **B2** | RS-485 采集链路实现(双缓冲 + 序号 + DE 收尾 + 协议状态机) | B0 + E-59 门禁 | | **B2** | RS-485 采集链路实现(双缓冲 + 序号 + DE 收尾 + 协议状态机) | B0 + E-59 门禁 |
| **B3** | E-59 门禁固化入 `AGENTS.md`(接消费者前先修缺陷 + 栈预算) | — | | **B3** | E-59 门禁固化入 `AGENTS.md`(接消费者前先修缺陷 + 栈预算) | — |
@@ -801,7 +801,7 @@ cfg-file-unit-test、test-suite-switch、gitlint。
| 开关 | 位置 | 现状 | | 开关 | 位置 | 现状 |
|---|---|---| |---|---|---|
| `APP_NET_ENABLE` / `APP_ADC_ENABLE` / `APP_RS485_ENABLE` | `App/app/app_cfg.h` | **未接线占位,不门控任何代码**——三宏全库零消费点,各模块一律编译进产品;是否/如何接线取决于 B0 产品定义。**不要把它们当开关用** | | `APP_NET_ENABLE` / `APP_ADC_ENABLE` / `APP_RS485_ENABLE` | `App/app/app_cfg.h` | **未接线占位,不门控任何代码**——三宏全库零消费点,各模块一律编译进产品;B0 已确认 ADC / RS-485 均为必需链路(ADC=遥测/保护来源,RS-485=对下 ModBus RTU 采集)→ 建议在 B1/B2 落地时接线为真实门控(或删除占位宏)。**在接线前不要把它们当开关用** |
| `TEST_SUITE_*` | `test/board/test_config.h` | 测试套件**互斥**,一次构建只开一个;全注释 = 正常产品固件 | | `TEST_SUITE_*` | `test/board/test_config.h` | 测试套件**互斥**,一次构建只开一个;全注释 = 正常产品固件 |
--- ---
+9 -7
View File
@@ -63,18 +63,20 @@
> 排序原则:**先降风险(产品级故障)→ 再补功能(核心链路)→ 再清结构债 → 最后卫生**。 > 排序原则:**先降风险(产品级故障)→ 再补功能(核心链路)→ 再清结构债 → 最后卫生**。
> 本节只给「做什么 / 为什么现在 / 入口在哪 / 完成判据」,明细指向 §3 对应条目。 > 本节只给「做什么 / 为什么现在 / 入口在哪 / 完成判据」,明细指向 §3 对应条目。
### 2.1 阶段 B:核心功能补齐(产品价值最高,需产品定义确认) ### 2.1 阶段 B:核心功能补齐(产品价值最高,产品定义已确认)
> 目标:让 DTU 真正采到数——这是当前**最大的功能空洞**,**当前最高优先级**。 > 目标:让 DTU 真正采到数——这是当前**最大的功能空洞**,**当前最高优先级**。
> **B0 产品定义已于 2026-10-10 确认并销项**:上行 **IEC 60870-5-104**(以太网 TCP 2404)/
> 持续采集 / **1000 Hz(20 点/周波)** / RS-485 **对下 ModBus RTU 采集**;结论详见
> `docs/算法/继电保护功能复现规划.md` §1.5,并已同步 `docs/产品需求/产品需求设计文档.md` §5.9/§5.10/§9。
> **B3(纪律门禁)已于 2026-10-10 固化进 `AGENTS.md` 并销项**;同日 TPAFE 侧前置缺陷 > **B3(纪律门禁)已于 2026-10-10 固化进 `AGENTS.md` 并销项**;同日 TPAFE 侧前置缺陷
> **E-44 / E-45 / E-58 全部修复** → **B1 现在只欠 B0 的产品定义 + 栈预算**; > **E-44 / E-45 / E-58 全部修复** → **B1 现在只欠栈预算 + 建任务**;
> B2 仍欠 **E-50 / E-49**(RS485 驱动契约未修,门禁仍挡着)。 > B2 仍欠 **E-50 / E-49**(RS485 驱动契约未修,门禁仍挡着)。
| 序 | 任务 | 前置 | 说明 | | 序 | 任务 | 前置 | 说明 |
|---|---|---|---| |---|---|---|---|
| **B0** | **产品定义确认**(阻塞项) | — | 需甲方明确:① 采集是否持续 ② 采样率(**通道语义已于 2026-10-09 确认并固化 → `docs/硬件平台/ADC通道映射说明.md`**:UA/UB/UC/UN=`AIN1~4`、IA/IB/IC/IN=`AIN8~11`、V_ZL2=温度、其余 8 路备用;主板只做监视/辅助测量,计量在子板。**注意:8 路电气量分属 TPAFE/STM32 两片转换器属 V0.1 硬件缺陷 → N-18**,主板侧不得做跨转换器的相位/功率类运算) ③ 转发目标(MODBUS RTU?上行 TCP?)④ `APP_ADC_ENABLE`/`APP_RS485_ENABLE` 语义。**B1/B2 的规模完全取决于此** | | **B1** | **ADC 采集链路实现**(消化 P1-1;E-58 已闭环) | B0 ✅ + **E-59 门禁**(TPAFE 侧已满足) | 周期 CONVST 重触发 + `adc_task` 消费 **`tpafe5160_snapshot()`**(**2026-10-10 驱动契约已修**:E-44 FRSTDATA 有效性校验 + `init()` 首帧自检返 `-3`、E-45 帧序号取代就绪电平标志、E-58 临界区原子快照;旧 `get_buf`/`data_ready`/`clear_ready` **已删**,删除前零调用者);`adc_task.c` **已挂进 `uvprojx`**(此前 Keil/模拟器两边都不注册 = 永不编译;simulator 无任务层,只挂 Keil);**仍欠**:栈预算核算(两任务未入 `freertos.c` 栈表,建任务前后各跑 `stack_report.py`;原 S-1 栈风险已于 2026-10-10 **削链销项**,defaultTask 现静态 2776/4096 余 32%、实测 2152 B)+ 消费者本体 + 建任务走 **L1(`.ioc` 的 `FREERTOS.Tasks01`)**;**取数必须按 `docs/硬件平台/ADC通道映射说明.md`**(TPAFE `buf[2]=IA`/`buf[5]=UN`/`buf[6]=UA`;STM32 侧 ADC1 **已于 2026-10-09 生成**:`hadc1` + `MX_ADC1_Init()`、9 通道 Rank1~9、144 cycles、ADCCLK=21 MHz、软件触发,网名 ADC17=PC4 用 `ADC_CHANNEL_14`;⚠ 全仓库至今**无 `HAL_ADC_Start`** → STM32 侧一路未跑) |
| **B1** | **ADC 采集链路实现**(消化 P1-1;E-58 已闭环) | B0 + **E-59 门禁**(TPAFE 侧已满足) | 周期 CONVST 重触发 + `adc_task` 消费 **`tpafe5160_snapshot()`**(**2026-10-10 驱动契约已修**:E-44 FRSTDATA 有效性校验 + `init()` 首帧自检返 `-3`、E-45 帧序号取代就绪电平标志、E-58 临界区原子快照;旧 `get_buf`/`data_ready`/`clear_ready` **已删**,删除前零调用者);`adc_task.c` **已挂进 `uvprojx`**(此前 Keil/模拟器两边都不注册 = 永不编译;simulator 无任务层,只挂 Keil);**仍欠**:栈预算核算(两任务未入 `freertos.c` 栈表,建任务前后各跑 `stack_report.py`;原 S-1 栈风险已于 2026-10-10 **削链销项**,defaultTask 现静态 2776/4096 余 32%、实测 2152 B)+ 消费者本体 + 建任务走 **L1(`.ioc` 的 `FREERTOS.Tasks01`)**;**取数必须按 `docs/硬件平台/ADC通道映射说明.md`**(TPAFE `buf[2]=IA`/`buf[5]=UN`/`buf[6]=UA`;STM32 侧 ADC1 **已于 2026-10-09 生成**:`hadc1` + `MX_ADC1_Init()`、9 通道 Rank1~9、144 cycles、ADCCLK=21 MHz、软件触发,网名 ADC17=PC4 用 `ADC_CHANNEL_14`;⚠ 全仓库至今**无 `HAL_ADC_Start`** → STM32 侧一路未跑) | | **B2** | **RS-485 采集链路实现**(消化 E-50、E-49) | B0 ✅ + **E-59 门禁**(**尚未满足**:E-50 / E-49 未修) | 双缓冲 + 序号(防包撕裂)+ DE 收尾(超时路径等 TC)+ 协议状态机(`App/protocol/` 正是为此预留);**B0 定用途 = 对下 ModBus RTU 采集** |
| **B2** | **RS-485 采集链路实现**(消化 E-50、E-49) | B0 + **E-59 门禁**(**尚未满足**:E-50 / E-49 未修) | 双缓冲 + 序号(防包撕裂)+ DE 收尾(超时路径等 TC)+ 协议状态机(`App/protocol/` 正是为此预留) |
**建议**:驱动契约与编译接线已在 2026-10-10 铺完 → B1 的下一步是**栈预算 + 建任务(L1)**, **建议**:驱动契约与编译接线已在 2026-10-10 铺完 → B1 的下一步是**栈预算 + 建任务(L1)**,
再谈消费者与节拍;B2 之前必须先修 E-50 / E-49。 再谈消费者与节拍;B2 之前必须先修 E-50 / E-49。
@@ -99,8 +101,8 @@
### 2.4 建议执行顺序(一图) ### 2.4 建议执行顺序(一图)
``` ```
【阶段 B 功能】 B0 产品定义确认 ─┬─ B3 E-59 门禁固化(并行) 【阶段 B 功能】 B0 产品定义确认 ✅ ─┬─ B3 E-59 门禁固化(并行)
(当前最高优先) └─ E-58 原子快照 → B1 ADC → B2 RS485 (当前最高优先) └─ E-58 原子快照 → B1 ADC → B2 RS485
↓ ↓
【阶段 C 结构】 C1 pending 二期 ─ C3 日志管线(N-2/S-3) 【阶段 C 结构】 C1 pending 二期 ─ C3 日志管线(N-2/S-3)
─ C5 收口 ─ C6 USART1 TX-only ─ C5 收口 ─ C6 USART1 TX-only
+1 -1
View File
@@ -14,7 +14,7 @@
|---|---| |---|---|
| **目录名一律不含空格** | 空格迫使脚本/链接/CI 处处加引号,且 URL 编码形态(`UI%20设计`)极易被漏改(D-6,2026-09-25 起强制;全库已无含空格目录) | | **目录名一律不含空格** | 空格迫使脚本/链接/CI 处处加引号,且 URL 编码形态(`UI%20设计`)极易被漏改(D-6,2026-09-25 起强制;全库已无含空格目录) |
| **按领域归入子目录** | 顶层不放散落 `.md`;新领域先建无空格子目录,再在 [`README.md`](README.md) 登记 | | **按领域归入子目录** | 顶层不放散落 `.md`;新领域先建无空格子目录,再在 [`README.md`](README.md) 登记 |
| 现有目录 | `架构设计/` `子系统设计/` `网络/` `测试说明/` `代码规范/` `内存管理/` `问题记录/` `环境与部署/` `UI设计/` `硬件平台/` `芯片手册/` `images/` | | 现有目录 | `架构设计/` `子系统设计/` `算法/` `网络/` `测试说明/` `代码规范/` `内存管理/` `问题记录/` `环境与部署/` `UI设计/` `硬件平台/` `芯片手册/` `images/` |
### 1.1b 代码目录的 README.md ### 1.1b 代码目录的 README.md
+1 -1
View File
@@ -2,7 +2,7 @@
> **范围**:本仓库文档(`docs/`)里的技术配图——协议帧 / 字节布局、分层架构、依赖 / 就绪屏障、流程 / 时序 / 状态机等。 > **范围**:本仓库文档(`docs/`)里的技术配图——协议帧 / 字节布局、分层架构、依赖 / 就绪屏障、流程 / 时序 / 状态机等。
> **目标**:统一绘图方法与工具链,保证图形**精确、可复现、可 diff、可过 pre-commit**,并与既有 `tools/arch_diagram/` 生成器一致。 > **目标**:统一绘图方法与工具链,保证图形**精确、可复现、可 diff、可过 pre-commit**,并与既有 `tools/arch_diagram/` 生成器一致。
> **沉淀**:本流程已沉淀为 `draw-diagram` skill(`.opencode/skills/draw-diagram/SKILL.md`)——画图时直接加载该 skill 即可。 > **沉淀**:本流程已沉淀为 `draw-diagram` skill(`.dsh/skills/draw-diagram/SKILL.md`)——画图时直接加载该 skill 即可。
> **产物目录**:生成的 `.svg` 统一放 **`docs/images/`**(专门配图目录,产物入库)。 > **产物目录**:生成的 `.svg` 统一放 **`docs/images/`**(专门配图目录,产物入库)。
--- ---
@@ -223,7 +223,7 @@ ST-LINK_CLI -c SWD -P "MDK-ARM\STM32F407-Demo\STM32F407-Demo.hex" -Rst -Run
| 复位后卡死(串口日志中断在半路) | ⓞ 2026-09-19 实测案例:`-c SWD HOTPLUG -SCore -CoreReg` 读 PC/寄存器定位卡点(案例:V2 引脚固件卡在 0x08000FA0 启动循环,见 §9)——**先定位再下结论**,勿直接怀疑探针 | | 复位后卡死(串口日志中断在半路) | ⓞ 2026-09-19 实测案例:`-c SWD HOTPLUG -SCore -CoreReg` 读 PC/寄存器定位卡点(案例:V2 引脚固件卡在 0x08000FA0 启动循环,见 §9)——**先定位再下结论**,勿直接怀疑探针 |
| 烧录报成功但芯片行为不变 | ⓞ **先怀疑静默写入失败**(2026-09-20 实测):命令缺 `-Rst -Run`(裸 `-P` 在目标核死循环/异常时不落地)或选项字节异常(RDP 字节非法,见 §10.4)。处置:`-CmpFile` 全片比对/回读向量表首字 → 用 `reset.bat flash` 重烧 → 再 `-CmpFile` 复核;勿直接怀疑探针/接线 | | 烧录报成功但芯片行为不变 | ⓞ **先怀疑静默写入失败**(2026-09-20 实测):命令缺 `-Rst -Run`(裸 `-P` 在目标核死循环/异常时不落地)或选项字节异常(RDP 字节非法,见 §10.4)。处置:`-CmpFile` 全片比对/回读向量表首字 → 用 `reset.bat flash` 重烧 → 再 `-CmpFile` 复核;勿直接怀疑探针/接线 |
| `daemon unresponsive after auto-start`(serial_tool) | ⓞ 复位后几秒内 daemon 忙于排空日志突发;稍等后直接读 `state/serial.log` 文件验证,勿反复重试 | | `daemon unresponsive after auto-start`(serial_tool) | ⓞ 复位后几秒内 daemon 忙于排空日志突发;稍等后直接读 `state/serial.log` 文件验证,勿反复重试 |
| `port not found: COMx (available: COMy)`(serial_tool) | ⓞ 端口重新枚举(板子重插常见):改 `.opencode/serial_config.json` → **kill 旧 daemon 进程** → 重新 `open`(daemon 启动时缓存配置,不重启不生效) | | `port not found: COMx (available: COMy)`(serial_tool) | ⓞ 端口重新枚举(板子重插常见):改 `.serialtest/config.json` → **kill 旧 daemon 进程** → 重新 `open`(daemon 启动时缓存配置,不重启不生效) |
| `log --grep` 查不到明明存在的标记 | ⓞ 该子命令实测不可靠(0 命中);改用直接读/`Select-String` 查 `state/serial.log` | | `log --grep` 查不到明明存在的标记 | ⓞ 该子命令实测不可靠(0 命中);改用直接读/`Select-String` 查 `state/serial.log` |
| SWD 通信不稳 | 不传 `SWCLK` 用默认时钟(本机实测 4MHz);检查长线/夹子接触 | | SWD 通信不稳 | 不传 `SWCLK` 用默认时钟(本机实测 4MHz);检查长线/夹子接触 |
| 误改 option bytes | `-rOB` 读回现状;`-OB RDP=0` 等回写后需再复位生效;**RDP=2 会锁死调试+全片擦除,操作前务必确认** | | 误改 option bytes | `-rOB` 读回现状;`-OB RDP=0` 等回写后需再复位生效;**RDP=2 会锁死调试+全片擦除,操作前务必确认** |
@@ -30,7 +30,7 @@
# ① 看本机有哪些 COM 口(不依赖 daemon;configured = 配置目标口) # ① 看本机有哪些 COM 口(不依赖 daemon;configured = 配置目标口)
& '.venv\Scripts\python.exe' 'tools/serial_tool/serial_tool.py' ports & '.venv\Scripts\python.exe' 'tools/serial_tool/serial_tool.py' ports
# ② 开串口(自动拉起 daemon;端口取 .opencode/serial_config.json,也可显式指定) # ② 开串口(自动拉起 daemon;端口取 .serialtest/config.json,也可显式指定)
& '.venv\Scripts\python.exe' 'tools/serial_tool/serial_tool.py' open & '.venv\Scripts\python.exe' 'tools/serial_tool/serial_tool.py' open
# ③ 发一条数据(文本;--hex 发字节;--nl 末尾加 CRLF) # ③ 发一条数据(文本;--hex 发字节;--nl 末尾加 CRLF)
@@ -72,7 +72,7 @@
"configured": "COM6"} "configured": "COM6"}
``` ```
- `configured` 来自 `.opencode/serial_config.json` 的 `portName`;找不到它 = 板子没接/驱动没装/口号变了。 - `configured` 来自 `.serialtest/config.json` 的 `portName`;找不到它 = 板子没接/驱动没装/口号变了。
- CH340 的 `description` 通常含"USB 串行设备"字样。 - CH340 的 `description` 通常含"USB 串行设备"字样。
### 3.2 `open` — 开串口 ### 3.2 `open` — 开串口
@@ -304,7 +304,7 @@ Get-Content 'tools\serial_tool\state\daemon.log' -Tail 30 # ② daemon 自身
## 6. 配置 ## 6. 配置
唯一配置源:`.opencode/serial_config.json` 唯一配置源:`.serialtest/config.json`
```json ```json
{ {
@@ -328,7 +328,7 @@ Get-Content 'tools\serial_tool\state\daemon.log' -Tail 30 # ② daemon 自身
| 现象 | 原因 | 处理 | | 现象 | 原因 | 处理 |
|---|---|---| |---|---|---|
| `port not found: COMx (available: …)` | 板子没接 / CH340 驱动没装 / 口号变了 | 对照 `available` 列表;装 CH340 驱动;改 `.opencode/serial_config.json` 的 `portName` | | `port not found: COMx (available: …)` | 板子没接 / CH340 驱动没装 / 口号变了 | 对照 `available` 列表;装 CH340 驱动;改 `.serialtest/config.json` 的 `portName` |
| `port busy: COMx (held by another process)` | 别的程序占着口 | `Get-Process` 找占用者关掉;或 `… serial_tool.py close` 释放本工具占的口 | | `port busy: COMx (held by another process)` | 别的程序占着口 | `Get-Process` 找占用者关掉;或 `… serial_tool.py close` 释放本工具占的口 |
| `daemon start timeout` / `daemon unreachable` | daemon 起不来(端口冲突/解释器问题) | 看 `state/daemon.log`;`… serial_tool.py restart`;确认 18765 未被占(改 `httpPort`) | | `daemon start timeout` / `daemon unreachable` | daemon 起不来(端口冲突/解释器问题) | 看 `state/daemon.log`;`… serial_tool.py restart`;确认 18765 未被占(改 `httpPort`) |
| `daemon did not answer within Ns (busy? raise --timeout)` | 命令耗时超过 `--timeout`——daemon 活着、只是还在等(典型:显式给 `wait-idle` 带了小 `--timeout`) | 调大 `--timeout`,或不显式给(`wait-idle` 按 `--max-ms` 自动推导)。2026-10-10 起这种情况**不再**顺手拉起第二个 daemon | | `daemon did not answer within Ns (busy? raise --timeout)` | 命令耗时超过 `--timeout`——daemon 活着、只是还在等(典型:显式给 `wait-idle` 带了小 `--timeout`) | 调大 `--timeout`,或不显式给(`wait-idle` 按 `--max-ms` 自动推导)。2026-10-10 起这种情况**不再**顺手拉起第二个 daemon |
@@ -3,7 +3,7 @@
> **版本**:v1.1(需求 + 设计 + 实施记录) > **版本**:v1.1(需求 + 设计 + 实施记录)
> **日期**:2026-09-15 > **日期**:2026-09-15
> **状态**:**已实施**(S1–S6 完成;本机验收 A1–A5/A8 通过,A6/A7 正路径待 CH340 硬件接入后补验) > **状态**:**已实施**(S1–S6 完成;本机验收 A1–A5/A8 通过,A6/A7 正路径待 CH340 硬件接入后补验)
> **关联文档**:`串口助手使用指南.md`(命令用法/工作流/排障) · `自动化测试流程.md` · `AGENTS.md`(串口调试/自动化测试节) · `.opencode/serial_config.json`(端口单一配置源,沿用) > **关联文档**:`串口助手使用指南.md`(命令用法/工作流/排障) · `自动化测试流程.md` · `AGENTS.md`(串口调试/自动化测试节) · `.serialtest/config.json`(端口单一配置源,2026-10-10 由 `.opencode/serial_config.json` 迁入)
> **取代**:`serial-mcp-server_串口调试.md`(实施时删除,工具历史并入本文 §12) > **取代**:`serial-mcp-server_串口调试.md`(实施时删除,工具历史并入本文 §12)
## 1. 背景与目标 ## 1. 背景与目标
@@ -66,7 +66,7 @@ flowchart LR
- **单文件实现**:`tools/serial_tool/serial_tool.py`(stdlib + pyserial,约 1000 行);解释器统一 `.venv\Scripts\python.exe` - **单文件实现**:`tools/serial_tool/serial_tool.py`(stdlib + pyserial,约 1000 行);解释器统一 `.venv\Scripts\python.exe`
- **Agent 调用式**:`& '.venv\Scripts\python.exe' 'tools/serial_tool/serial_tool.py' <命令> [参数]`(任意 cwd;state 目录按脚本位置解析,与 cwd 无关) - **Agent 调用式**:`& '.venv\Scripts\python.exe' 'tools/serial_tool/serial_tool.py' <命令> [参数]`(任意 cwd;state 目录按脚本位置解析,与 cwd 无关)
- **历史 = 日志文件**:Agent 用 Read/Grep 直接看历史,无需专门 API(`log` 命令只是便捷封装) - **历史 = 日志文件**:Agent 用 Read/Grep 直接看历史,无需专门 API(`log` 命令只是便捷封装)
- **配置沿用** `.opencode/serial_config.json`(`portName`/`baudRate` 为默认值;新增可选字段 `httpPort`,默认 18765) - **配置沿用** `.serialtest/config.json`(`portName`/`baudRate` 为默认值;新增可选字段 `httpPort`,默认 18765)
### 3.2 "不卡死"的归属(对用户 #3 的重新表述) ### 3.2 "不卡死"的归属(对用户 #3 的重新表述)
@@ -48,7 +48,7 @@
| A4 | `.map` 非测试态 | 看 `MDK-ARM/STM32F407-Demo/STM32F407-Demo.map` 的 `ER_IROM1` | 产品态基线随已接受的修复增长:262,100(初版)→ 262,836 B(10-02 round 1~6)→ 264,284 B(round 7~9)→ **264,936 B(当前,含 A2/A3/A4 补码)**;明显阶跃 ≈3.7KB 及以上(10-02 实测产品 262,836 → 套件 266,540)= 停在套件态 → 重跑 A2 | | A4 | `.map` 非测试态 | 看 `MDK-ARM/STM32F407-Demo/STM32F407-Demo.map` 的 `ER_IROM1` | 产品态基线随已接受的修复增长:262,100(初版)→ 262,836 B(10-02 round 1~6)→ 264,284 B(round 7~9)→ **264,936 B(当前,含 A2/A3/A4 补码)**;明显阶跃 ≈3.7KB 及以上(10-02 实测产品 262,836 → 套件 266,540)= 停在套件态 → 重跑 A2 |
| A5 | 全量静态门禁 | `pre-commit run --all-files` | **17/17 Passed**(2026-10-03 实测;含新增 S5 `test-cppcheck`) | | A5 | 全量静态门禁 | `pre-commit run --all-files` | **17/17 Passed**(2026-10-03 实测;含新增 S5 `test-cppcheck`) |
| A6 | ST-LINK 探针可见 | `& "MDK-ARM\reset.bat" list`(CLI 经 `DTU_STLINK_CLI` / PATH 解析) | 列出探针 SN `0B250D12A218303030303032` | | A6 | ST-LINK 探针可见 | `& "MDK-ARM\reset.bat" list`(CLI 经 `DTU_STLINK_CLI` / PATH 解析) | 列出探针 SN `0B250D12A218303030303032` |
| A7 | 串口端口正确 | 检查 `.opencode/serial_config.json` 的 `portName` | 端口单一配置源 = 此文件(当前 COM6,2026-09-21 台架重插后由 COM7 变更);变更后先杀旧 daemon 再 `open` | | A7 | 串口端口正确 | 检查 `.serialtest/config.json` 的 `portName` | 端口单一配置源 = 此文件(当前 COM6,2026-09-21 台架重插后由 COM7 变更);变更后先杀旧 daemon 再 `open` |
| A8 | 串口助手 daemon 可用 | `& '.venv\Scripts\python.exe' tools/serial_tool/serial_tool.py status` | 返回 JSON,`open: true/false` 可读 | | A8 | 串口助手 daemon 可用 | `& '.venv\Scripts\python.exe' tools/serial_tool/serial_tool.py status` | 返回 JSON,`open: true/false` 可读 |
> ⚠️ **A4 的陷阱**(2026-10-02 实测):`@build` 是**增量**构建,`run_all_suites.py` 跑完 > ⚠️ **A4 的陷阱**(2026-10-02 实测):`@build` 是**增量**构建,`run_all_suites.py` 跑完
@@ -275,7 +275,7 @@ static void phaseN_run(void) {
| **`test/host/sys_clock/run_unit_test.py`** | `test/host/sys_clock/` | **L0.5 主机侧单测**:穷举 `sys_clock` 时间换算 / 星期 / 惰性再基准化 / RTC 故障降级(100 断言,S1-a) | | **`test/host/sys_clock/run_unit_test.py`** | `test/host/sys_clock/` | **L0.5 主机侧单测**:穷举 `sys_clock` 时间换算 / 星期 / 惰性再基准化 / RTC 故障降级(100 断言,S1-a) |
| **`test/host/cfg_file/run_unit_test.py`** | `test/host/cfg_file/` | **L0.5 主机侧单测**:INI 解析 / 值编解码 / 段注册 / load-save-poll 契约,内存虚拟 FS 驱动(139 断言,S1-b) | | **`test/host/cfg_file/run_unit_test.py`** | `test/host/cfg_file/` | **L0.5 主机侧单测**:INI 解析 / 值编解码 / 段注册 / load-save-poll 契约,内存虚拟 FS 驱动(139 断言,S1-b) |
| **`scripts/ci_local.py`** | `scripts/` | **本地跑与 CI 等价的门禁**(L0 + L0.5):自动选用装了 pre-commit 的解释器;`.map` 自适应跳过 `mem-numbers`;退出码 0/1/2 与 CI 对齐。见 §9.3 | | **`scripts/ci_local.py`** | `scripts/` | **本地跑与 CI 等价的门禁**(L0 + L0.5):自动选用装了 pre-commit 的解释器;`.map` 自适应跳过 `mem-numbers`;退出码 0/1/2 与 CI 对齐。见 §9.3 |
| `.opencode/serial_config.json` | 仓库 | **串口端口单一配置源**(当前 COM6 / 115200);换端口只改这个文件 | | `.serialtest/config.json` | 仓库 | **串口端口单一配置源**(当前 COM6 / 115200);换端口只改这个文件 |
| `test/host/ftp_*.py`(4 个)/ `ch395f_socket_test.py` / `time_sync_server.py` | `test/host/` | PC 端交互测试工具(FTP 客户端 / socket 回显 / 对时推送) | | `test/host/ftp_*.py`(4 个)/ `ch395f_socket_test.py` / `time_sync_server.py` | `test/host/` | PC 端交互测试工具(FTP 客户端 / socket 回显 / 对时推送) |
| `simulator/tests/*.py`(4 个) | `simulator/tests/` | LVGL 模拟器回归脚本(见 §5) | | `simulator/tests/*.py`(4 个) | `simulator/tests/` | LVGL 模拟器回归脚本(见 §5) |
@@ -440,7 +440,7 @@ flowchart TD
### 6.3 三条铁律 ### 6.3 三条铁律
1. **采集必须先于烧录武装** —— 开串口 + clear 在烧录前完成(daemon 常驻全量日志兜底,不会漏字节;`run_boot_test.py` 已保证顺序); 1. **采集必须先于烧录武装** —— 开串口 + clear 在烧录前完成(daemon 常驻全量日志兜底,不会漏字节;`run_boot_test.py` 已保证顺序);
2. **端口以 `.opencode/serial_config.json` 为准** —— 每次连接前先读它; 2. **端口以 `.serialtest/config.json` 为准** —— 每次连接前先读它;
3. **静默判据覆盖时间窗** —— `-SettleMs=8000`(覆盖 boot ~3-4s 自然静默间隙)、`-MaxWaitMs=30000`(整体超时兜底); 3. **静默判据覆盖时间窗** —— `-SettleMs=8000`(覆盖 boot ~3-4s 自然静默间隙)、`-MaxWaitMs=30000`(整体超时兜底);
--- ---
+4 -4
View File
@@ -20,9 +20,9 @@
| 名称 | 位置 | 作用 | | 名称 | 位置 | 作用 |
|---|---|---| |---|---|---|
| `@build` | `.opencode/command/build.md` | 仅编译验证(0 错误 0 警告) | | `@build` | `keil-build` skill(`.dsh/skills/keil-build/`,自带 `keil_build.py`) | 仅编译验证(0 错误 0 警告) |
| `@download` | `.opencode/command/download.md` | 编译并烧录(ST-Link) | | `@download` | `stlink-flash` skill(`.dsh/skills/stlink-flash/`,自带 `stlink_flash.py`) | 编译并烧录(ST-Link) |
| `@test` | `.opencode/command/test.md` | 完整闭环:build → capture+download → verify → iterate | | `@test` | `serial-boot-test` skill(`.dsh/skills/serial-boot-test/`) | 完整闭环:build → capture+download → verify → iterate |
| `test/host/run_boot_test.py` | `test/host/run_boot_test.py` | **一键闭环**:open + clear → 烧录 → 固件身份戳(从 hex 抠 `fw build:`)→ wait-idle(静默 8s/超时 30s/首字节 12s)→ 提取 `[RX]`(每行加 `[+Nms]` 时基)→ 回读校验(`reset.bat cmp`)→ verify;`--keep-out` 清旧产物;退出码 0=PASS/1=FAIL/2=采集·烧录·回读错误/3=用法错误 | | `test/host/run_boot_test.py` | `test/host/run_boot_test.py` | **一键闭环**:open + clear → 烧录 → 固件身份戳(从 hex 抠 `fw build:`)→ wait-idle(静默 8s/超时 30s/首字节 12s)→ 提取 `[RX]`(每行加 `[+Nms]` 时基)→ 回读校验(`reset.bat cmp`)→ verify;`--keep-out` 清旧产物;退出码 0=PASS/1=FAIL/2=采集·烧录·回读错误/3=用法错误 |
| `MDK-ARM/reset.bat` | `MDK-ARM/reset.bat` | ST-LINK CLI 封装:`flash [hex]`(烧 + `-Rst -Run`)/ **`cmp [hex]`(回读比对,2026-10-10 加)**/ 无参复位 / `halt`/`run`/`list`/`hard` | | `MDK-ARM/reset.bat` | `MDK-ARM/reset.bat` | ST-LINK CLI 封装:`flash [hex]`(烧 + `-Rst -Run`)/ **`cmp [hex]`(回读比对,2026-10-10 加)**/ 无参复位 / `halt`/`run`/`list`/`hard` |
| `tools/serial_tool/serial_tool.py` | `tools/serial_tool/` | Python 串口助手(常驻 daemon + 薄 CLI):ports/open/send/status/log/clear/wait-idle/selftest;全量日志 `state/serial.log`(20MB 自动截头) | | `tools/serial_tool/serial_tool.py` | `tools/serial_tool/` | Python 串口助手(常驻 daemon + 薄 CLI):ports/open/send/status/log/clear/wait-idle/selftest;全量日志 `state/serial.log`(20MB 自动截头) |
@@ -86,7 +86,7 @@
| `marker order violated` | 标记全中但**先后顺序颠倒** = 某步在另一步之后补做(就绪屏障失序 / 失败重试补跑)。处置:看判定打印的各标记首次行号定位;`--require-order` 只在需要强约束时启用 | | `marker order violated` | 标记全中但**先后顺序颠倒** = 某步在另一步之后补做(就绪屏障失序 / 失败重试补跑)。处置:看判定打印的各标记首次行号定位;`--require-order` 只在需要强约束时启用 |
| `boot time N ms > limit` | 能开机但慢(mount 重试 / PHY 重协商 / 复位重试)。实测基线:`net init: OK` 在 **+537ms**(2026-10-10 台架);默认只报告不判(`--max-boot-ms 0`),要设门禁再给预算 | | `boot time N ms > limit` | 能开机但慢(mount 重试 / PHY 重协商 / 复位重试)。实测基线:`net init: OK` 在 **+537ms**(2026-10-10 台架);默认只报告不判(`--max-boot-ms 0`),要设门禁再给预算 |
| 端口被占用(`port busy`) | 先 `serial_tool.py close` 或 `restart`;检查其他程序占用;Keil ST-Link 不占 COM | | 端口被占用(`port busy`) | 先 `serial_tool.py close` 或 `restart`;检查其他程序占用;Keil ST-Link 不占 COM |
| 配置里的 COM 号失效(换口/换机) | `serial_tool.py ports` 现回 `configured_present` + `hint`(系统里只有一个候选时直接给出口名),`open` 的报错里也带 `[hint: ...]`;确认后只改 `.opencode/serial_config.json` | | 配置里的 COM 号失效(换口/换机) | `serial_tool.py ports` 现回 `configured_present` + `hint`(系统里只有一个候选时直接给出口名),`open` 的报错里也带 `[hint: ...]`;确认后只改 `.serialtest/config.json` |
| 日志中无 UI 相关输出 | 见下节「UI 自动化」 | | 日志中无 UI 相关输出 | 见下节「UI 自动化」 |
| 日志出现 `[ERR]` 行 | 固件错误级日志(`DBG_ERROR`),默认 Forbidden 会判 FAIL:按 TAG 查对应模块的 `docs/问题记录/*_Trap_Records.md`;若属测试套件的**预期**错误(守卫负路径、非法名校验等),用 `--forbidden` 放宽 | | 日志出现 `[ERR]` 行 | 固件错误级日志(`DBG_ERROR`),默认 Forbidden 会判 FAIL:按 TAG 查对应模块的 `docs/问题记录/*_Trap_Records.md`;若属测试套件的**预期**错误(守卫负路径、非法名校验等),用 `--forbidden` 放宽 |
@@ -184,7 +184,7 @@ python tools/font_gen/extract_cn.py # 任意 python3:
| 项 | 要求 | 说明 | | 项 | 要求 | 说明 |
|---|---|---| |---|---|---|
| Python 串口助手 | `tools/serial_tool/serial_tool.py`(仓库内置,.venv + pyserial) | 常驻 daemon + 薄 CLI + 全量日志文件;无需单独安装,`pip install -r requirements.txt` 即就绪 | | Python 串口助手 | `tools/serial_tool/serial_tool.py`(仓库内置,.venv + pyserial) | 常驻 daemon + 薄 CLI + 全量日志文件;无需单独安装,`pip install -r requirements.txt` 即就绪 |
| 调试串口 | USART1(PA9 TX / PA10 RX),115200 8N1 | **端口单一配置源 `.opencode/serial_config.json`**(本机 COM6);换端口只改这个文件 | | 调试串口 | USART1(PA9 TX / PA10 RX),115200 8N1 | **端口单一配置源 `.serialtest/config.json`**(本机 COM6);换端口只改这个文件 |
| 一键测试 | `test\run_boot_test.py`(即 `@test`,`& '.venv\Scripts\python.exe' test/host/run_boot_test.py`) | 开串口 + 清日志 → 烧录 → wait-idle(静默 8s/超时 30s)→ 提取 → 标记校验;默认端口取 `serial_config.json` / 115200,退出码 0=PASS / 1=FAIL / 2=采集或烧录错误 / 3=用法错误 | | 一键测试 | `test\run_boot_test.py`(即 `@test`,`& '.venv\Scripts\python.exe' test/host/run_boot_test.py`) | 开串口 + 清日志 → 烧录 → wait-idle(静默 8s/超时 30s)→ 提取 → 标记校验;默认端口取 `serial_config.json` / 115200,退出码 0=PASS / 1=FAIL / 2=采集或烧录错误 / 3=用法错误 |
> 采集 = 端口常开 + daemon 全量日志(`tools/serial_tool/state/serial.log`),无采集窗口/first-byte/EOF 重试仪式。 > 采集 = 端口常开 + daemon 全量日志(`tools/serial_tool/state/serial.log`),无采集窗口/first-byte/EOF 重试仪式。
@@ -347,7 +347,7 @@ pre-commit run --all-files # 首次全库基线扫描
& '.venv\Scripts\python.exe' tools\serial_tool\serial_tool.py ports # 查 COM 号 & '.venv\Scripts\python.exe' tools\serial_tool\serial_tool.py ports # 查 COM 号
``` ```
把查到的端口写入 `.opencode\serial_config.json` 的 `portName`(唯一配置源,换口只改这里)。 把查到的端口写入 `.serialtest\config.json` 的 `portName`(唯一配置源,换口只改这里)。
**第 10 步:冒烟验证**(全过 = 环境就绪;前置:§2.1.1 的 `DTU_*` 变量已设置——未设时构建脚本会明确报出缺哪个变量及设置方法) **第 10 步:冒烟验证**(全过 = 环境就绪;前置:§2.1.1 的 `DTU_*` 变量已设置——未设时构建脚本会明确报出缺哪个变量及设置方法)
@@ -372,7 +372,7 @@ pre-commit run --all-files
- [ ] `git clone` 仓库 → `pre-commit install`;cppcheck 入 PATH - [ ] `git clone` 仓库 → `pre-commit install`;cppcheck 入 PATH
- [ ] 建 `.venv` 并 `pip install -r requirements.txt` - [ ] 建 `.venv` 并 `pip install -r requirements.txt`
- [ ] `npm i -g lv_font_conv` - [ ] `npm i -g lv_font_conv`
- [ ] 写好 `.opencode/serial_config.json`(本机 COM 口),`serial_tool selftest` PASS - [ ] 写好 `.serialtest/config.json`(本机 COM 口),`serial_tool selftest` PASS
- [ ] 下载器(ST-Link 等)USB 驱动已装,Keil `Options for Target → Debug` 驱动一致 - [ ] 下载器(ST-Link 等)USB 驱动已装,Keil `Options for Target → Debug` 驱动一致
- [ ] 第 10 步冒烟验证全部通过(含 `test/host/watchdog/run_unit_test.py` → PASSED) - [ ] 第 10 步冒烟验证全部通过(含 `test/host/watchdog/run_unit_test.py` → PASSED)
- [ ] (可选)Graphviz 已装(引用关系图渲染:`winget install Graphviz.Graphviz`,见 §2.8) - [ ] (可选)Graphviz 已装(引用关系图渲染:`winget install Graphviz.Graphviz`,见 §2.8)
@@ -614,7 +614,7 @@ $env:DTU_KEIL_UV4 = 'D:\NotExist\UV4.exe' # 仅当前会话,不动用户级
| pip 装依赖报 UnicodeDecodeError | `requirements.txt` 含中文注释 | 保持该文件**纯 ASCII**(pip 按 GBK 代码页解码) | | pip 装依赖报 UnicodeDecodeError | `requirements.txt` 含中文注释 | 保持该文件**纯 ASCII**(pip 按 GBK 代码页解码) |
| 字体生成 / 转 PNG 报缺模块 | 误用 PATH 里的 msys64 python | 一律用 `.venv\Scripts\python.exe` | | 字体生成 / 转 PNG 报缺模块 | 误用 PATH 里的 msys64 python | 一律用 `.venv\Scripts\python.exe` |
| 内容页回归 exit 2(参考缺失) | `tests/ref/` 是本地生成物、不入库 | 先 `check_content_pages.py --update` 生成参考,再跑回归 | | 内容页回归 exit 2(参考缺失) | `tests/ref/` 是本地生成物、不入库 | 先 `check_content_pages.py --update` 生成参考,再跑回归 |
| 串口采集不到 / 端口占用 | COM 号变化 / 上一进程未释放 | 改 `.opencode/serial_config.json`;`serial_tool.py ports` 查实际端口,`open` 占用 ≤1s 报 `port busy` | | 串口采集不到 / 端口占用 | COM 号变化 / 上一进程未释放 | 改 `.serialtest/config.json`;`serial_tool.py ports` 查实际端口,`open` 占用 ≤1s 报 `port busy` |
| 烧录失败 | 下载器未选 / USB 驱动缺失 | Keil `Options for Target → Debug` 选对驱动;安装下载器 USB 驱动 | | 烧录失败 | 下载器未选 / USB 驱动缺失 | Keil `Options for Target → Debug` 选对驱动;安装下载器 USB 驱动 |
| 下载报 Flash 算法 / FLM 文件找不到 | 未安装 `Keil::STM32F4xx_DFP` pack(FLM 来自 pack 目录,不在仓库) | Pack Installer 安装该 pack 后重新下载 | | 下载报 Flash 算法 / FLM 文件找不到 | 未安装 `Keil::STM32F4xx_DFP` pack(FLM 来自 pack 目录,不在仓库) | Pack Installer 安装该 pack 后重新下载 |
| UV4 启动弹 license 窗口 / 无法编译 | 90 天评估 license 过期或未激活 | `Options for Target → License` 查看/输入;长期使用购买正式 license | | UV4 启动弹 license 窗口 / 无法编译 | 90 天评估 license 过期或未激活 | `Options for Target → License` 查看/输入;长期使用购买正式 license |
+188
View File
@@ -0,0 +1,188 @@
> 从《项目详细分析-交流采样与有效值计算》中提取扩展,配图讲解傅氏算法(全周波傅里叶算法)的原理与工程实现。
> 工程背景:CLS100 DTU 保护测控装置,每周波 20 点采样(1000Hz @ 50Hz),保护有效值/相量由 `GlobalUnit/CalFFTVecFull.c` 全周傅氏计算。
> 公式为 LaTeX 排版,需 Markdown 预览(VS Code / Typora 等)渲染。
---
## 1. 一句话思想
**把一周波的采样序列分别和"标准正弦、标准余弦"做内积(投影)**:正弦分量投影到正弦上得实部,余弦分量投影到余弦上得虚部——用两根已知相位的"标杆"去量未知信号里基波的幅值和相位。数学上这就是**单频 DFT**(只计算 $k=1$ 那一根谱线)。
![投影原理](https://lsky.bitnasdaq.vip/gqvbns.png)
图中含 20% 五次谐波和 1V 直流的畸变波形,经两次投影后仍精确还原出基波:幅值 57.70V、相角 30.0°——谐波和直流在投影中"自己抵消了",这是傅氏算法的全部魔力,第 3 节用面积图解释为什么。
## 2. 数学推导
### 2.1 连续形式
设周期信号 $x(t)$,基波角频率 $\omega = 2\pi f$,其基波分量:
$$
x_1(t) = A_1\sin(\omega t + \varphi_1) = a_1\sin(\omega t) + b_1\cos(\omega t)
$$
在一个整周波 $T$ 上:
$$
a_1 = \frac{2}{T}\int_0^T x(t)\sin(\omega t)\,\mathrm{d}t
\qquad
b_1 = \frac{2}{T}\int_0^T x(t)\cos(\omega t)\,\mathrm{d}t
$$
### 2.2 离散形式(工程实现)
一个周波采 $N$ 个点 $x(0),\dots,x(N-1)$,积分 → 求和:
$$
a_1 \approx \frac{2}{N}\sum_{k=0}^{N-1} x(k)\,\sin\!\left(\frac{2\pi k}{N}\right)
\qquad
b_1 \approx \frac{2}{N}\sum_{k=0}^{N-1} x(k)\,\cos\!\left(\frac{2\pi k}{N}\right)
$$
幅值、相位、有效值:
$$
A_1 = \sqrt{a_1^2 + b_1^2}
\qquad
\varphi_1 = \arctan\!\frac{b_1}{a_1}
\qquad
U = \frac{A_1}{\sqrt{2}}
$$
### 2.3 本项目的写法:把 $\sqrt{2}$ 藏进系数表
`GIUnit/GLobalFunc.c` L337 的系数表预乘了 $\sqrt{2}/N$:
$$
g\_fFFTSin20[k] = \sin\!\left(\frac{2\pi k}{N}\right)\cdot\frac{\sqrt{2}}{N}
$$
于是累加结果直接是**有效值相量**分量:
$$
f_{Re} = \sum_{k=0}^{N-1} x(k)\,\sin\!\left(\frac{2\pi k}{N}\right)\frac{\sqrt{2}}{N} = \frac{a_1}{\sqrt{2}}
\qquad
f_{Im} = \frac{b_1}{\sqrt{2}}
$$
$$
f_{AmSQ} = \sqrt{f_{Re}^2 + f_{Im}^2} = \frac{A_1}{\sqrt{2}} = U
\quad (\text{直接就是有效值,无需再除}\ \sqrt{2})
$$
## 3. 为什么能滤除直流和谐波 —— 正交性
整周波上三角函数系正交:
$$
\int_0^T \sin(\omega t)\,\mathrm{d}t = 0,
\qquad
\int_0^T \sin(m\omega t)\sin(\omega t)\,\mathrm{d}t = 0 \quad (m \in \mathbb{Z},\ m \neq 1)
$$
![正交性](https://lsky.bitnasdaq.vip/BGNWjf.png)
看图最直观:**蓝=正面积,红=负面积**。
| 信号成分 × sinθ | 面积结果 | 结论 |
| --------------- | ------------ | ----------- |
| 直流 1V | 红蓝完全抵消 | **完全滤除** |
| 3、5、…次整数谐波 | 红蓝完全抵消 | **完全滤除** |
| 基波 | 正面积占优,净值 ≠ 0 | **被提取** |
| 衰减直流(非严格周期) | 近似抵消,有残余 | 大部分滤除 |
| 非整数次谐波(如 2.7 次) | 不严格抵消 | 按"泄漏"比例部分反映 |
**前提是数据窗恰好一个整周波**。窗长偏离整周波(系统频率漂移),正交性被破坏,直流和 2 次谐波就会漏进测量——这就是本工程用"软件测频 + 片内 EHRPWM 调频"严格锁定 20 点/周波的原因。
## 4. 频域视角:一个梳状带通滤波器
把整周数据窗对基波做相关,等效为滤波器,其频率响应:
$$
|H(f)| = \left|\frac{\sin\!\big(\pi (f/f_1 - 1)\big)}{N\,\sin\!\big(\pi (f/f_1 - 1)/N\big)}\right|
$$
![频率响应](https://lsky.bitnasdaq.vip/I67YT9.png)
- 中心 50Hz 通带,增益 1;
- **零点恰好落在 0Hz(直流)和 100、150、200…Hz(全部整数谐波)**;
- 窗越长(周波数越多)主瓣越窄、选择性越好,但时延越大。
"一窗 = 一个整周波"是保护领域在**速度**与**滤波**之间的经典折中,IEC 标准对保护动作时间的考核也按全周波算法进行。
## 5. 滑动窗:每 1ms 一个新有效值
数据窗定义为"以当前时刻为终止的前一周波"。每来一个新采样点(1ms),窗口滑动一格、丢弃最老点、20 点全部重算("暴力法"):
![滑动窗](https://lsky.bitnasdaq.vip/R6a1YN.png)
- **暴力法**:每点重算 20 次乘加 ×2(sin/cos),实现简单、无累积误差;
- **迭代法**(`CalIterFFTVecFull.c`,本工程未启用):增量更新
$$
S_{\text{new}} = S_{\text{old}} - x_{\text{old}}\cdot\sin\!\left(\frac{2\pi k}{N}\right) + x_{\text{new}}\cdot\sin\!\left(\frac{2\pi k}{N}\right)
$$
每次只 2 次乘加,但浮点舍入误差随时间累积,需定期用暴力法清洗。本工程 CPU 余量足够,选暴力法。
## 6. 数值算例:三种算法各测到什么
同一组 20 点采样(57.7V 基波 + 20% 五次谐波 + 1V 直流):
![数值算例](https://lsky.bitnasdaq.vip/A7dnSv.png)
| 算法 | 读数 | 说明 |
| -------------------------------- | ---------------- | ------------------------------------------------ |
| 直流平均 | 1.00 V | 只剩直流 |
| 真有效值 $\sqrt{\overline{x^2}}$ | ≈58.85 V | $\sqrt{57.7^2 + 11.5^2 + 1^2}$,谐波直流全算进去 |
| **全周傅氏** | **57.70 V ∠30°** | 精确回到基波,且给出相位 |
保护要的是"基波有效值 + 相位"(方向元件、同期、功率都要相角),且故障时短路电流含衰减直流——傅氏一个整周波积分把它抹掉,真 RMS 则会虚高导致误动。**这就是保护偏爱傅氏、计量偏爱真 RMS 的根本原因**(方法全景见主文档附录C)。
## 7. 工程实现:本工程数据流
![工程实现流程](https://lsky.bitnasdaq.vip/DFUBED.png)
核心循环只有 6 行(`GlobalUnit/CalFFTVecFull.c` L392-405):
```c
for( dwDot=0; dwDot<dwCalRate; dwDot++) {
fVal = pfCalUse[dwAdr]; // 第n个采样点(已乘通道系数)
fRe += fVal * pfFFTSin[dwFFTCnt]; // Σ x·sin·(√2/N)
fIm += fVal * pfFFTCos[dwFFTCnt]; // Σ x·cos·(√2/N)
dwAdr += dwLen; // 滑到下一个采样点
}
Fun_VectorAmp(ptVecSave); // fAmSQ = √(fRe²+fIm²) = 有效值
```
配套工程细节:
- **任意次谐波**:系数索引步进量从 1 改为 $k$ 即算 $k$ 次谐波相量 $f_{Re}(k)=\sum x(n)\sin\!\big(\tfrac{2\pi k n}{N}\big)\tfrac{\sqrt2}{N}$,同一循环复用于 2 次/5 次谐波(涌流判别、选线);遥测对 $k=1\sim11$ 全算再方和根 $U_{\text{rms}}=\sqrt{\sum_{k=1}^{11}U_k^2}$;
- **双区切换**:结果写入向量区 `tVectorRly[2][N]` 的计算区,算完原子交换"计算区/使用区"指针,保护元件读使用区,无锁并发;
- **4 个实例**:C01 基波 I/U、C02 零序基波、C03 二次谐波、C04 五次谐波(`APP/CLS100_Dsp/InstCalCell.c`)。
## 8. 全周波 vs 半周波
| | 全周傅氏(本工程用) | 半周傅氏(代码有但未启用) |
| -------- | -------------------- | ---------------------------------- |
| 数据窗 | 1 个周波(20 点) | 半个周波(10 点) |
| 滤波能力 | 直流 + 全部整数谐波 | 半周相减消直流和偶次谐波,奇次保留 |
| 速度 | 1 周波 | 快一倍 |
| 精度 | 高 | 低(受奇次谐波影响) |
本工程 `CN_ANA_DIF=FALSE`,半周波/差分组件全部注释掉——DTU 场景更看重精度和抗衰减直流能力。
## 9. 常见疑问
**Q1:傅氏算的是有效值还是幅值?**
取决于系数表。标准系数 $2/N$ 得幅值 $A_1$;本工程预乘 $\sqrt2/N$ 直接得有效值 $U=A_1/\sqrt2$。看 `GLobalFunc.c` 系数表注释"折算成有效值"即可确认。
**Q2:和 FFT 什么关系?**
傅氏算法 = 只算 $k=1$(或少数几次)谱线的 DFT。FFT 是同时算全部 $N$ 根谱线的快速算法。保护只需要基波(+少数谐波),逐次 DFT 比 FFT 更省算力。
**Q3:频率偏离 50Hz 还准吗?**
本工程靠闭环调频保证窗长恒等于实际周波(20 点/周波随频率微调),所以准。固定 1000Hz 采样不调整的方案,频偏 ±2Hz 时幅值误差约 1~2% 且出现相角偏差。
**Q4:衰减直流滤不干净怎么办?**
整周波对"严格周期"信号才完全正交,衰减直流有残余。对策:加长数据窗(1.5~2 周波)、微分/余弦滤波改进傅氏、或最小二乘把 $A\mathrm{e}^{-t/\tau}$ 作为未知量一并回归。
@@ -0,0 +1,436 @@
# 继电保护功能复现规划
> **定位**:把参考系统(CLS100 DTU 保护测控装置)的**继电保护链路**在本项目(STM32F4-Base DTU 平台)上分层复现的**总规划**——给出要实现的功能、性能指标、定值参数、平台适配口径与分阶段路线图。
> **参考来源**:`D:\Code\DTU\参考资料\huaiyuan\huaiyuan\Scatter_P_HV03.01_SV01.01\项目分析文档\04-算法与采样专题\ADC采集至数据使用全流程详解`(同专题另有《项目详细分析-交流采样与有效值计算》《傅氏算法详解》《项目详细分析-阶段6-继电器保护单元分析》)。
> **阅读前提**:先读 `docs/算法/傅氏算法详解.md`(算法原理)与 `docs/硬件平台/ADC通道映射说明.md`(采样通道权威口径)。
> **相关文档**:`docs/产品需求/产品需求设计文档.md`、`docs/待处理内容.md`、`docs/内存管理/RAM使用分析与优化方案.md`。
> **状态**:规划稿——保护链路本体尚未实现;其前置的 ADC 采集链路(`adc_task`)当前为空桩,见登记簿 **P1-1 / B1**。
---
## 1. 背景、目标与范围
### 1.1 参考系统(CLS100)保护实现概览
| 维度 | 参考实现 |
| ------ | ------------------------------------------------------------------------------------ |
| 硬件平台 | TI OMAP-L138(ARM926EJ-S + C674x DSP **双核**),无 FPGA |
| 操作系统 | VxWorks |
| 核心分工 | **DSP 核**跑保护算法(1 ms 中断节拍),**ARM 核**跑管理 / 通信 / 人机 |
| 采样率 | 50 Hz 系统,每周波 **20 点** → 保护采样率 **1000 Hz**(`CN_SAM_HERTZ=50`、`CN_SAM_RATE=20`) |
| 内部总线节拍 | **4000 Hz**(80 点/周波,为保护采样率的 4 倍,`CN_WAVE_RATIO=4`),供录波/高频量使用 |
| 保护相量 | **全周傅氏**(全周 DFT),每次刷新 15 个基波相量 + 3 个二次谐波相量 + 1 个五次谐波相量,**每 1 ms 重算** |
| 遥测有效值 | 每 **200 ms** 对 k=1~9 次谐波做 DFT 再**方和根**(`CN_NUM_YCHUMWAVE=9`),得"真有效值"轨 |
| 保护元件 | **89 个模块**(`RelayUnit/PRS_*.c`),统一 `tagPRS_XXX` 接口(Init / Main / 执行函数) |
| 并发机制 | 采样**双缓冲**(2 周波 = 40 点)+ 相量**双区指针原子交换** + 遥测 **0x55/0xaa 计算/拷贝标志协议** |
| 调度 | **时间片表**(单点 / 2 点 / 4 点 / 8 点)按 `g_dwSchedulerCnt` 位掩码分发 |
| 定值 | EEPROM 存定值 + 折算系数(`wCoeEEP` / `wCoeCal` / `wCoeRet`),TLV 通道;遥测微调 TLV10(±10%,0.1% 步长) |
| 有效值口径 | 保护用**基波有效值**(`CN_ANA_VEC_SQRT=FALSE`),计量/遥测用**谐波方和根** |
> 数据全链路(采样 → 内部总线 → 缓冲 → 预处理 → 算法 → 存储 → 使用)见参考文档《ADC采集至数据使用全流程详解》§5~§7;傅氏原理与系数折算见 `docs/算法/傅氏算法详解.md`。
### 1.2 本项目现状
| 维度 | 现状 |
| ------ | -------------------------------------------------------------------------------------------------------------------------- |
| 硬件平台 | STM32F407ZGTx(Cortex-M4F,168 MHz,**单核**,硬件 FPU) |
| 操作系统 | FreeRTOS V10.3.1(CMSIS-RTOS v2) |
| 已实现子系统 | 网络(CH395F)、存储(GD5F2GQ5UE SPI NAND + dhara FTL + FatFs)、时间(SD2506 RTC + `sys_clock`)、配置(cfg_file)、日志、UI(远端显示)、看门狗(IWDG)、FTP |
| 采样硬件 | TPAFE5160(16 位、8 通道**同步**、±5 V 双极性、并行 `GPIOG[15:0]`)+ STM32 内部 ADC1(12 位、9 通道**顺序**扫描、0~3.3 V 单极性) |
| 采样软件 | `Drivers/BSP/TPAFE5160/tpafe5160.h` 驱动就绪(原子快照 API);`App/task/adc_task.c` **空桩**;无周期 CONVST 重触发;STM32 侧无 `HAL_ADC_Start` |
| 保护算法层 | **完全缺失**(无傅氏、无保护元件、无出口、无定值模型) |
| 已知硬件缺陷 | **N-18**:8 路电气量分属两片转换器 → V0.1 上禁止跨转换器做相位/功率/矢量运算 |
### 1.3 复现范围界定
**本期做(保护链路最小可用集)**
1. 采样与预处理:周期采样触发、通道语义映射、标定、有效性校验、等周波调频、自产零序。
2. 相量计算:全周傅氏基波/谐波相量、线/负序/零序合成、双区交换、每 1 ms 刷新。
3. 保护元件:三段式相间过流、三段式零序过流、过负荷、过压/欠压、过频/低频、零序过压、涌流制动、PT/CT 断线、母线接地告警、故障启动。
4. 出口与记录:跳闸/告警出口、SOE/动作记录、定值模型与 cfg_file 集成、自检闭锁。
**本期不做(延后或依赖条件)**
- 方向元件 / 功率 / 功率因数 / 对称分量类运算——受 **N-18** 制约,V0.1 上取子板计量结果,或待 V0.2 硬件改版后实现。
- 重合闸、馈线自动化(FA)等 FTU 专有功能——视产品定义(B0)。
- 双核 / DSP 拆分、VxWorks 特有机制、IEC 61850、完整故障录波——与单核 FreeRTOS 平台不匹配。
- 参考系统 89 个模块的**全量**移植——按需裁剪,只复现本产品定义的元件集。
### 1.4 前置依赖与阻塞项
| 依赖 | 说明 | 状态 |
| --------------- | ------------------------------------------------------------ | ------------------------ |
| **B0 产品定义** | 采集是否持续 / 采样率与通道 / 转发目标 / 保护元件清单——决定 B1 与保护层的**规模** | ✅ 已确认(2026-10-10,见 §1.5) |
| **B1 ADC 采集链路** | 周期 CONVST + `adc_task` 消费 `tpafe5160_snapshot()` + 建任务 + 栈预算 | ⬜ 未实现(驱动契约已就绪) |
| **N-18 硬件缺陷** | V0.1 跨转换器无同一时刻性 → 限定 V0.1 可实现的保护范围 | ⚠️ 硬件改版项(V0.2) |
| **栈预算** | 保护任务栈需入 `Src/freertos.c` 并经 `scripts/stack_report.py` 核算 | ⬜ 待做 |
---
## 2. 总体架构设计
### 2.1 数据全链路(分层)
复现后的保护链路分为 6 层,自下而上:
| 层 | 职责 | 参考对应 | 本项目落点(拟) |
| ------------- | ----------------------------- | ---------------------------------------------------------------- | ------------------------------------------------ |
| **L0 采样驱动** | 触发转换、读出原始码值 | AD 芯片 + 总线 | `Drivers/BSP/TPAFE5160/` + STM32 ADC1 HAL |
| **L1 采集与预处理** | 快照取数、语义映射、标定、有效性校验、等周波调频、自产零序 | `DataPreRecv` / `RealDataMerge` / `CalSoftFr` / `GetZoreSamData` | `adc_task` + `App/algo/`(拟) |
| **L2 相量计算** | 全周傅氏基波/谐波、线/负序/零序合成、双区交换 | `CalFFTVecFull` / `CalfLineVec` / `CalfNegVec` | `App/algo/`(拟) |
| **L3 保护元件** | 各保护判据(过流/零序/过压/频率/…) | `RelayUnit/PRS_*.c` | `App/protect/`(拟) |
| **L4 出口与信号** | 跳闸/告警出口、信号灯 | `TripKO` / KO | `App/protect/` + BSP 出口驱动 |
| **L5 记录与上送** | SOE/动作记录、UI 显示、遥测上送 | 记录模块 + 通信 | `App/log/` + `Drivers/BSP/UI/` + `App/protocol/` |
> **分层原则**:L1 只做"数"(码值→工程量),L2 只做"相量"(工程量→复相量),L3 只做"判据"(相量 + 定值→布尔),L4/L5 只做"动作与留痕"。**算法与 I/O 分离**,使 L2/L3 成为主机侧单元测试(L0.5)的天然适格对象(落实登记簿 **A-4**)。
### 2.2 任务与中断模型(映射到 STM32F4 + FreeRTOS)
参考系统用 DSP 的 1 ms 中断串起整条保护链。本项目为单核 FreeRTOS,采用"**中断触发 + 任务计算**"模型:
| 角色 | 载体 | 周期 | 优先级(拟) | 说明 |
| ------ | --------------------------- | -------- | ------- | -------------------------------------- |
| 采样节拍 | 定时器中断(TIMx) | **1 ms** | 最高(ISR) | 产生 CONVST 脉冲;ISR 内只置事件/计数,不做重运算 |
| 采集与预处理 | `adcTask` | 1 ms | 高 | `tpafe5160_snapshot()` 取数 → 标定 → 存入采样环 |
| 相量计算 | `adcTask` 内联 或 `algoTask` | 1 ms | 高 | 全周傅氏 → 相量双区交换 |
| 保护判据 | `protectTask` | 1 ms | 高 | 各保护元件 Init/Main → 出口 |
| 遥测与显示 | 复用 `uiTask` / 新增 `measTask` | 200 ms | 中 | 谐波方和根、死区/平滑、UI 与上送 |
| 记录 | `defaultTask`(已有) | 事件驱动 | 低 | SOE/动作记录落 `sys_log` |
> **铁律**:`adcTask` / `protectTask` 的 FS 路径若触碰存储,必须沿用卷锁 try 语义(`vol_lock_wait(0)`),且每任务纳入看门狗 `required_mask` 心跳(见 `docs/子系统设计/看门狗设计说明.md`)。**保护节拍任务不得阻塞在存储 I/O 上**——记录走队列异步落盘。
### 2.3 并发与数据一致性
参考系统用三重机制保证"采样—相量—保护"三段并发无锁:**采样双缓冲 + 相量双区指针交换 + 遥测标志协议**。本项目沿用同一思想,并复用已有的原子快照范式:
| 机制 | 参考做法 | 本项目做法 |
| ---- | -------------------------------------- | ------------------------------------------------------------------------------ |
| 采样缓冲 | 双缓冲(2 周波 = 40 点),写/读分属两区 | 环形缓冲 + **序号**(`tpafe5160_frame_seq()` 已有)+ 临界区快照,防撕裂 |
| 相量区 | `tVectorRly[2][N]`,算完**原子交换**计算区/使用区指针 | 双区结构体 + 指针/索引原子切换(单核下用临界区或 `__atomic`) |
| 遥测拷贝 | 0x55/0xaa 计算/拷贝握手标志 | 快照 + 序号,或 FreeRTOS 事件标志握手 |
| 取数契约 | —— | **只能用 `tpafe5160_snapshot()`**(`AGENTS.md` 铁律),不用已删除的 `get_buf` / `data_ready` |
### 2.4 与现有子系统的接口
| 保护层需求 | 复用现有能力 | 落点 |
| --------- | ------------------------------------ | ------------------------------------ |
| 定值存取 | **cfg_file 配置引擎**(替代参考的 EEPROM 定值文件) | `App/cfg/cfg_file.c` + 新增定值段 |
| 动作/SOE 记录 | `sys_log`(`/log/run.log`)与运行记录 | `App/log/` |
| 保护定值/测量显示 | 远端显示 UI(`ui_page_ac` 已有 24 行占位) | `Drivers/BSP/UI/ui_page_ac.h` |
| 遥测/事件上送 | 网络栈 + `App/protocol/`(协议预留) | `App/protocol/` + `Drivers/BSP/NET/` |
| 时间戳 | `sys_clock`(RTC 基准) | `App/util/sys_clock.c` |
| 自检留痕 | `selftest.log` / 自检框架 | `App/log/` |
---
## 3. 功能规划(分层清单)
> 每项给出:**功能点 / 关键参数 / 完成判据**。状态列 ⬜ 未实现。
### 3.1 L1 采集与预处理
| # | 功能点 | 关键参数 | 完成判据 | 状态 |
| ---- | ------------------ | ------------------------------------------- | --------------------------------------------------------------- | -- |
| L1-1 | 周期 CONVST 重触发 | 1000 Hz(与保护节拍同源) | 示波器见 U3 `BUSY` 连续脉冲;`tpafe5160_frame_seq()` 持续递增 | ⬜ |
| L1-2 | TPAFE 快照消费 | 8 通道 / 帧 | `adc_task` 每拍调 `tpafe5160_snapshot()`,返回 1/0/-1 有处理 | ⬜ |
| L1-3 | STM32 ADC1 启动与扫描 | 9 通道、144 cycles、软件触发 | 全库出现 `HAL_ADC_Start`;9 通道码值随注入变化 | ⬜ |
| L1-4 | 通道语义映射 | 见 `docs/硬件平台/ADC通道映射说明.md`(**按语义取数,不写死下标**) | `UA→buf[6]`、`UN→buf[5]`、`IA→buf[2]`;STM32 侧 `UB→ADC_CHANNEL_14` | ⬜ |
| L1-5 | 通道标定(一次系数 + 零漂) | 每通道 `k`、`b`;系数定点 `2^10` | 注入标准源,读数误差 ≤ 目标精度 | ⬜ |
| L1-6 | 采样有效性校验 | FRSTDATA 跳变、帧序号单调、丢帧计数 | 芯片静默/丢帧被检出并置告警 | ⬜ |
| L1-7 | 等周波采样(软件测频 + 调频闭环) | 20 点/周波;测频范围建议 **45~55 Hz**(参考 40.5~60.5) | 频率偏移 ±2 Hz 时幅值误差 ≤ 1% | ⬜ |
| L1-8 | 自产零序合成 | `3I0 = Ia+Ib+Ic`;外部/自产可选(参考 TLV13) | 零序通道可切换来源,值正确 | ⬜ |
| L1-9 | 二次谐波/五次谐波提取入口 | k=2(涌流)、k=5(选线) | 相量层可产出对应谐波相量 | ⬜ |
> ⚠️ **N-18 约束**:L1-4 中 `UA/UN/IA` 在 TPAFE、`UB/UC/IB/IC/IN` 在 STM32 ADC。V0.1 上 L1 只做**逐通道独立监视**,不得假设同族量同刻(`UA` 与 `UB` 无共同采样触发)。
### 3.2 L2 相量计算
| # | 功能点 | 关键参数 | 完成判据 | 状态 |
| ---- | ------------ | ----------------------------------------------- | -------------------------------- | -- |
| L2-1 | 全周傅氏系数表 | `sin(2πk/N)·(√2/N)`、`cos(2πk/N)·(√2/N)`,N=20 | 系数表与 `docs/算法/傅氏算法详解.md` §2.3 一致 | ⬜ |
| L2-2 | 基波相量实例 | 4 个实例:C01 基波 I/U、C02 零序基波、C03 二次谐波、C04 五次谐波 | 每实例输出 `{fRe, fIm, fAm, fAmSQ}` | ⬜ |
| L2-3 | 相量有效值输出 | `fAmSQ = √(fRe²+fIm²)` 直接为**有效值**(系数已折算) | 标准源读数 = 设定有效值 | ⬜ |
| L2-4 | 相位输出 | `φ = atan2(fIm, fRe)` | 相位误差 ≤ 目标值 | ⬜ |
| L2-5 | 线/负序/零序合成 | 线电压 `Uab=Ua−Ub` 等;负序 `3U2`/`3I2`;零序 `3U0`/`3I0` | 合成相量可复用于保护元件 | ⬜ |
| L2-6 | 每 1 ms 滑动窗刷新 | 每来新点,窗滑动一格、20 点重算(暴力法) | 相量每拍更新 | ⬜ |
| L2-7 | 双区指针原子交换 | 计算区/使用区切换 | 保护元件读使用区,无撕裂 | ⬜ |
| L2-8 | 遥测谐波轨(可选) | k=1~9,方和根 `√(ΣHk²)` | 与基波轨并行,供 UI/上送 | ⬜ |
> **双轨有效值**:保护要"基波有效值 + 相位"(抗衰减直流、抗谐波误动);计量/遥测要"真有效值"(谐波方和根)。两者**不可混用**——这是参考系统 `CN_ANA_VEC_SQRT=FALSE` 与遥测谐波轨并存的根本原因。
### 3.3 L3 保护元件
采用参考系统的**统一接口模式**:每元件一个 `tagPRS_XXX` 配置结构(定值号、模拟通道号、KI/KO、标志位、校验和)+ `Init` / `Main`(判据)/ 出口函数。
**核心集(P0/P1)**
| # | 元件 | 判据要点 | 关键定值 | 状态 |
| ---- | ------- | ----------------------- | ---------------- | -- |
| L3-1 | 三段式相间过流 | 相电流基波 > 定值,经延时出口;返回系数滞回 | Ⅰ/Ⅱ/Ⅲ 段电流 + 延时 | ⬜ |
| L3-2 | 三段式零序过流 | 零序电流 > 定值,经延时出口 | Ⅰ/Ⅱ/Ⅲ 段零序电流 + 延时 | ⬜ |
| L3-3 | 过负荷 | 相电流 > 定值,告警/跳闸可整定 | 过负荷电流 + 延时 | ⬜ |
| L3-4 | 过压 / 欠压 | 相/线电压越限,经延时出口 | 过压定值、失压定值 + 延时 | ⬜ |
| L3-5 | 过频 / 低频 | 频率越限,经延时出口 | 过频、低频定值 + 延时 | ⬜ |
| L3-6 | 零序过压 | 零序电压 > 定值 | 零序过压定值 + 延时 | ⬜ |
**扩展集(P2/P3)**
| # | 元件 | 判据要点 | 状态 |
| ----- | ---------- | --------------------- | -- |
| L3-7 | 涌流制动 | 二次谐波占比 > 制动系数,闭锁差动/过流 | ⬜ |
| L3-8 | PT 断线 | 电压突变/负序判据,闭锁电压类保护 | ⬜ |
| L3-9 | CT 断线 | 电流不平衡判据,闭锁 | ⬜ |
| L3-10 | 母线接地告警 | 零序电压 + 零序电流组合 | ⬜ |
| L3-11 | 故障启动(波形启动) | 突变量启动,触发录波/启动元件 | ⬜ |
| L3-12 | 无压/无流闭锁 | 低电压或低电流时闭锁过流 | ⬜ |
**延后集(受 N-18 / 产品定义制约)**
| # | 元件 | 制约 |
| ----- | ----------- | -------------------- |
| L3-13 | 方向元件 / 功率方向 | 需跨转换器相位一致 → V0.1 不可做 |
| L3-14 | 重合闸 | FTU 专有,视 B0 产品定义 |
| L3-15 | 手动跳闸 / 遥控 | 依赖出口硬件与协议定义 |
### 3.4 L4 出口与记录
| # | 功能点 | 说明 | 状态 |
| ---- | --------- | --------------------- | -- |
| L4-1 | 跳闸出口(KO) | 判据满足 → 驱动出口继电器,带保持/脉冲 | ⬜ |
| L4-2 | 告警出口 / 信号 | 告警类动作驱动信号灯/继电器 | ⬜ |
| L4-3 | 出口自检 | 出口回路检测(若有回读) | ⬜ |
| L4-4 | SOE 记录 | 带时标的事件顺序记录 | ⬜ |
| L4-5 | 动作记录 | 保护动作/返回记录,落 `run.log` | ⬜ |
| L4-6 | 故障录波(可选) | 4000 Hz 内部节拍预留 | ⬜ |
### 3.5 L5 自检与闭锁
| # | 功能点 | 说明 | 状态 |
| ---- | ------ | ----------------------------------- | -- |
| L5-1 | 定值校验和 | `tagPRS_XXX` 的 `dwTagSum` 自检,定值非法闭锁 | ⬜ |
| L5-2 | 采样通道自检 | 通道码值越界/静默检出 | ⬜ |
| L5-3 | 硬件自检 | 出口回路、电源、时钟 | ⬜ |
| L5-4 | 自检失败闭锁 | 任一项失败 → 闭锁相关保护并告警 | ⬜ |
---
## 4. 性能指标
### 4.1 实时性
| 指标 | 目标值 | 依据 |
| ------ | ---------------------------- | ------------------------------ |
| 保护节拍 | **1 ms** | 参考 `CN_PROTECT_PERIOD=1000 µs` |
| 保护采样率 | **1000 Hz**(20 点/周波 @ 50 Hz) | 全周傅氏窗长 |
| 相量刷新周期 | **1 ms** | 每来一点重算 |
| 遥测刷新周期 | **200 ms** | 参考遥测链周期 |
| 出口动作时间 | ≥ 1 周波(20 ms)算法窗 + 定值延时 | 全周波算法固有延时 |
| 内部总线节拍 | 4000 Hz(80 点/周波) | 参考 `CN_WAVE_RATIO=4`(本项目按需裁剪) |
### 4.2 精度
| 指标 | 目标值 | 说明 |
| ------ | --------------------------- | ------------------------- |
| 基波幅值误差 | ≤ 1%(频偏 ±2 Hz 内) | 依赖等周波采样 |
| 相位误差 | ≤ 目标角误差(依产品定义) | 依赖调频闭环 |
| 测频范围 | 建议 45~~55 Hz(参考 40.5~~60.5) | `CN_HERTZ_SOFTFR_MIN/MAX` |
| 定值折算 | 定点系数 `2^10` | 与参考一致 |
| 遥测微调 | ±10%,0.1% 步长(TLV10) | 参考口径 |
> **误差来源与预算**(参考《ADC采集至数据使用全流程详解》§12):采样量化(TPAFE LSB ≈ 152.6 µV)、通道标定残差、窗长偏离整周波(频偏)、系数定点舍入。前三项决定可达精度,最后一项可忽略。
### 4.3 算力与内存预算
| 项 | 估算 | 说明 |
| -------- | ------------------------------- | -------------------------------------- |
| 傅氏乘加 | 20 点 × 2(sin/cos)× 相量数 / 拍 | 参考用暴力法(CPU 余量足够);本项目 168 MHz + FPU 需实测 |
| 相量区 RAM | `tVectorRly[2][N] × sizeof(相量)` | 双区 + 4 实例 |
| 采样缓冲 RAM | 40 点/通道 × 通道数 × 字宽 | 双缓冲 2 周波 |
| 定值区 RAM | 元件数 × 定值项 × 字宽 | 可由 cfg_file 载入 |
| 任务栈 | `adcTask` / `protectTask` 待核算 | 建任务前后各跑 `scripts/stack_report.py` |
> ⚠️ **改内存相关代码后必须刷新快照**:`@build` 出 `.map` 后跑 `scripts/check_mem_numbers.py --write`,同步 `docs/内存管理/RAM使用分析与优化方案.md` §2.1(`mem-numbers` 钩子会因体积变化 FAIL)。
### 4.4 频率跟踪
- **原理**:软件测频(对电压/电流信号过零点或相角差测频)→ 调整采样定时器周期 → 使窗长恒等于实际周波(严格 20 点/周波)。
- **闭环**:测频 → 周期修正 → 采样 → 再测频;参考用片内 EHRPWM 调频,本项目用 TIM 重装载值调整。
- **必要性**:窗长偏离整周波会破坏正交性,直流与二次谐波漏入测量(见 `docs/算法/傅氏算法详解.md` §3)。
## 6. 平台适配与差异处理
### 6.1 采样源差异
| 维度 | 参考系统 | 本项目 | 适配要点 |
| ----- | ----------------- | -------------------------------------------------------- | ------------------------------ |
| AD 器件 | 专用同步采样 AD | TPAFE5160(16 位、8 通道同步、±5 V)+ STM32 ADC1(12 位、顺序、0~3.3 V) | 统一到"工程量"层,屏蔽码值差异 |
| 通道数 | 8 实通道(4U+4I,自产零序) | 8 路电气量(4U+4I)+ 温度 | 语义映射见 `docs/硬件平台/ADC通道映射说明.md` |
| 同一时刻性 | 单芯片同步 | **跨转换器无共同触发**(N-18) | V0.1 只做逐通道独立监视 |
| 量程/精度 | 一致 | TPAFE LSB ≈ 152.6 µV vs STM32 LSB ≈ 806 µV | 同族量不可统一算法处理 |
### 6.2 N-18 影响与降级口径
- **V0.1 现板**:主板侧只做**逐通道独立监视**(有效值/越限/温度);**禁止**跨转换器的同周期相位、功率、功率因数、矢量类运算。
- **受影响功能**:方向元件、功率方向、差动类——延后(L3-13)。
- **V0.2 改版后**:8 路电气量全部接 TPAFE(与 5 路备用路对调串联电阻目的地,BOM 不变),上层语义与代码**不动**,只改映射表内容 → 保护层无需重构。
- **接口设计启示**:**按语义取数、不写死下标**,是 V0.2 平滑升级的前提。
### 6.3 单核 vs 双核
| 维度 | 参考(ARM+DSP) | 本项目(单核) | 适配 |
| ----- | ----------- | ------------------- | ----------------------- |
| 保护计算 | DSP 核独占 | 与 FreeRTOS 任务共享 CPU | 保护任务优先级最高;避免长临界区 |
| 管理/通信 | ARM 核 | 同核分任务 | 依赖现有任务优先级体系 |
| 隔离 | 核间隔离 | 无 | 靠任务优先级 + 栈预算 + 看门狗保证实时性 |
### 6.4 零序自产 / 外部
- 参考:TLV13 选择映射表——外部零序(专用通道)或**自产零序**(`3I0 = Ia+Ib+Ic`)。
- 本项目:V0.1 无专用零序通道 → 一期**自产零序**(软件合成),外部零序待 V0.2 或子板提供。
- 落点:L1-8。
### 6.5 定值存储
| 维度 | 参考 | 本项目 |
| -- | ------------ | ------------------------------------ |
| 载体 | EEPROM 定值文件 | cfg_file(`/config.ini`,NAND + FatFs) |
| 折算 | `wCoeEEP` 定点 | 同模型(工程量 ↔ 码值) |
| 微调 | TLV10 ±10% | cfg_file 微调段(按需) |
---
## 7. 分阶段实施路线图
### 7.1 阶段划分与里程碑
| 阶段 | 名称 | 内容 | 前置 | 里程碑 |
| ------ | ---------- | ------------------------------------------------------------------------- | -------------- | ----------------------- |
| **P0** | 采样与相量底座 | B1(周期 CONVST + `adcTask` 消费 + 建任务 + 栈预算)+ L1 + L2 | B0 + B1 | 每 1 ms 产出正确的基波相量 |
| **P1** | 基础保护元件 | L3-1 三段式过流、L3-2 三段式零序过流、L3-3 过负荷 | P0 | 标准源注入可正确动作 |
| **P2** | 电压/频率类保护 | L3-4 过压/欠压、L3-5 过频/低频、L3-6 零序过压 | P0 | 电压/频率越限正确动作 |
| **P3** | 异常检测与闭锁 | L3-7 涌流制动、L3-8 PT 断线、L3-9 CT 断线、L3-10 母线接地、L3-11 故障启动、L3-12 无压/无流闭锁、L5 自检 | P1/P2 | 异常工况闭锁与告警正确 |
| **P4** | 出口、记录与定值集成 | L4 出口与记录、§5 定值模型落 cfg_file、UI 显示、上送 | P1 | 动作出口 + SOE 记录 + UI 显示闭环 |
| **P5** | 方向/功率类(延后) | L3-13 方向元件等 | V0.2 硬件 / 子板计量 | 跨转换器相位一致后实现 |
### 7.2 每阶段交付物与验收判据
| 阶段 | 交付物 | 验收判据 |
| -- | ------------------------------------ | ----------------------------------- |
| P0 | `adcTask`/`algo` 模块 + 设计文档 + L0.5 单测 | 标准源读数误差 ≤ 目标;相量每 1 ms 刷新;`@test` 通过 |
| P1 | 过流/零序/过负荷元件 + 定值模型 | 注入 1.2×/0.9× 定值,正确动作/返回;返回系数正确 |
| P2 | 电压/频率元件 | 越限动作时间符合定值延时 |
| P3 | 异常检测 + 自检闭锁 | 断线/涌流工况正确闭锁;自检失败闭锁 |
| P4 | 出口 + 记录 + 定值 UI | 动作驱动出口;SOE 带正确时标;UI 显示实时值 |
| P5 | 方向元件 | 需 V0.2 前置条件满足 |
### 7.3 依赖关系图
```text
B0 产品定义(阻塞)
└─ B1 ADC 采集链路(周期 CONVST + adcTask + 建任务 + 栈预算)
└─ P0 采样与相量底座
├─ P1 基础保护(过流/零序/过负荷)
│ └─ P4 出口/记录/定值
├─ P2 电压/频率保护
└─ P3 异常检测与闭锁
└─ P5 方向/功率类(依赖 V0.2 硬件 / 子板计量)
```
---
## 8. 测试与验证
### 8.1 测试分层
| 层 | 对象 | 方式 |
| --------- | ----------------------------------- | ------------------------------------------------------------------------------------------ |
| L0 静态 | 代码规范、文档链接、体积 | pre-commit 钩子(`cppcheck` / `doc-links` / `mem-numbers`) |
| L0.5 主机单测 | 傅氏相量、定值解析、保护判据(**算法与 I/O 分离**后天然适格) | `test/host/<模块>/run_unit_test.py`(MSVC 离线编译真实源文件,参考 `test/host/cfg_file/run_unit_test.py`) |
| L1 板端套件 | 采样/相量/保护元件 | `test/run_all_suites.py` 板端套件 |
| L2/L3 台架 | 标准源注入、动作出口、时序 | 台架闭环(COM6 @115200) |
### 8.2 A-4 约束落实
登记簿 **A-4** 对采集链路提出三条设计约束,保护层须一并满足:
1. **协议解析与硬件 I/O 分离**——保护判据函数只吃相量 + 定值,可单测;
2. **采集缓冲双缓冲 + 序号**——防撕裂(单测测不出时序撕裂,须设计上消除);
3. **DE 收尾超时路径**(RS-485,若保护涉及 485 通信)。
### 8.3 验收判据(保护层)
- 标准源注入:幅值/相位/频率读数误差在目标内;
- 定值动作:1.2× 定值可靠动作、0.9× 定值可靠返回,返回系数符合 `wCoeRet`;
- 时序:动作时间 = 算法窗(≥20 ms)+ 定值延时,误差在允许范围;
- 闭锁:PT/CT 断线、涌流、自检失败工况下相关保护可靠闭锁;
- 记录:SOE/动作记录时标正确(注意板载 RTC 比墙钟慢约 8 h)。
---
## 9. 风险与开放问题
| # | 风险 / 开放问题 | 影响 | 处置方向 |
| -- | ------------------ | --------------------- | -------------------------------------- |
| R1 | **B0 产品定义未定** | 保护元件清单与规模无法锁定 | 先按核心集(P1/P2)推进,扩展集待定义 |
| R2 | **N-18 硬件缺陷** | 方向/功率/矢量类无法在 V0.1 实现 | V0.2 硬件改版;V0.1 取子板计量 |
| R3 | **单核算力** | 1 ms 节拍内完成傅氏 + 全部保护元件 | 实测 CPU 余量;必要时裁剪遥测谐波轨或降低元件扫描频率 |
| R4 | **等周波调频实现** | 频偏时精度不达标 | TIM 重装载调频;先固定 1000 Hz 跑通再上闭环 |
| R5 | **STM32 侧无中点偏置** | 交流量负半周削顶,无法算 RMS | V0.1 只做监视;V0.2 补偏置 |
| R6 | **栈预算** | 新任务栈溢出 | 建任务前后跑 `scripts/stack_report.py`;纳入看门狗 |
| R7 | **出口硬件未确认** | 跳闸出口无法验证 | 确认出口继电器/回读电路;无硬件时先做逻辑级验证 |
| R8 | **TPAFE 自动扫描模式未知** | 影响采样实现方式 | 查手册确认(登记簿 §4 待确认项) |
---
## 10. 附录
### 10.1 参考系统关键常量
| <u>常量</u> | <u>值</u> | <u>含义</u> |
| -------------------------------- | --------------------- | ---------------------------- |
| <u>`CN_PROTECT_PERIOD`</u> | <u>1000 µs</u> | <u>保护中断周期</u> |
| <u>`CN_SAM_HERTZ`</u> | <u>50</u> | <u>系统频率(Hz)</u> |
| <u>`CN_SAM_RATE`</u> | <u>20</u> | <u>每周波采样点数</u> |
| <u>`CN_SAM_RATIO`</u> | <u>1</u> | <u>保护采样倍率</u> |
| <u>`CN_YCSAM_RATIO`</u> | <u>2</u> | <u>遥测采样倍率</u> |
| <u>`CN_WAVE_RATIO`</u> | <u>4</u> | <u>录波倍率(4000 Hz)</u> |
| <u>`CN_BUFSAM_CYCLE`</u> | <u>2</u> | <u>采样缓冲周波数</u> |
| <u>`CN_BUFSAM_LEN`</u> | <u>40</u> | <u>采样缓冲长度(点)</u> |
| <u>`CN_ANA_VEC_SQRT`</u> | <u>FALSE</u> | <u>保护用基波有效值(非方和根)</u> |
| <u>`CN_ANA_DIF`</u> | <u>FALSE</u> | <u>半周波/差分组件关闭</u> |
| <u>`CN_HERTZ_SOFTFR_MIN/MAX`</u> | <u>40.5 / 60.5 Hz</u> | <u>软测频范围</u> |
| <u>`CN_NUM_YCHUMWAVE`</u> | <u>9</u> | <u>遥测谐波次数上限(`(20>>1)−1`)</u> |
| <u>`CN_NUM_YCAVER`</u> | <u>4</u> | <u>遥测平均点数</u> |
| <u>系数定点</u> | <u>`2^10`</u> | <u>标定系数定标</u> |
### <u>10.2 术语表</u>
| <u>术语</u> | <u>含义</u> |
| ------------ | -------------------------------------------- |
| <u>全周傅氏</u> | <u>全周波傅里叶算法,单频 DFT,得基波相量</u> |
| <u>相量</u> | <u>`{fRe, fIm, fAm, fAmSQ}`,实部/虚部/幅值/有效值</u> |
| <u>等周波采样</u> | <u>软件测频 + 调频,保持严格 20 点/周波</u> |
| <u>自产零序</u> | <u>`3I0 = Ia+Ib+Ic` 软件合成</u> |
| <u>双区交换</u> | <u>相量计算区/使用区指针原子切换</u> |
| <u>时间片表</u> | <u>单点/2点/4点/8点调度表</u> |
| <u>TLV</u> | <u>参考系统的参数通道(定值/微调/映射)</u> |
| <u>返回系数</u> | <u>动作值/返回值之比(滞回)</u> |
### <u>10.3 待确认清单</u>
1. <u>保护元件清单(核心集/扩展集边界)——依 B0。</u>
2. 定值取值范围/步长/单位——依产品定义。
3. 出口硬件形态(继电器/回读)——依硬件。
4. 等周波调频范围与精度目标——依产品定义。
5. 是否保留遥测谐波轨(k=1~9)——依 UI/上送需求。
---
## 变更记录
| 日期 | 变更 | 说明 |
| ---------- | ----- | --------------------------------------------------------------------------------------------------------- |
| 2026-10-10 | 建立本文档 | 依据参考项目《ADC采集至数据使用全流程详解》等专题文档,规划 CLS100 保护链路在本项目(STM32F4-Base)的分层复现:功能/性能/参数/平台适配/路线图;登记至 `docs/README.md` |
-3
View File
@@ -1,3 +0,0 @@
{
"$schema": "https://opencode.ai/config.json"
}
+1 -1
View File
@@ -71,7 +71,7 @@
& '.venv\Scripts\python.exe' test/host/cfg_file/run_unit_test.py & '.venv\Scripts\python.exe' test/host/cfg_file/run_unit_test.py
``` ```
> 串口端口配置在 `.opencode/serial_config.json`(单一配置源,当前 COM6/115200)。 > 串口端口配置在 `.serialtest/config.json`(单一配置源,当前 COM6/115200)。
## ⚠️ 注意 ## ⚠️ 注意
+6 -6
View File
@@ -48,7 +48,7 @@
[--done-marker 'net init: OK'] [--max-boot-ms 0] [--done-marker 'net init: OK'] [--max-boot-ms 0]
[--raw-log] [--keep-out 20] [--raw-log] [--keep-out 20]
--port 缺省取 .opencode/serial_config.json 的 portName(单一配置源) --port 缺省取 .serialtest/config.json 的 portName(单一配置源)
退出码(与旧版一致): 退出码(与旧版一致):
0 = PASS(采集成功 + Required 全中 + Forbidden 零命中) 0 = PASS(采集成功 + Required 全中 + Forbidden 零命中)
@@ -70,10 +70,10 @@ from pathlib import Path
SCRIPT_DIR = Path(__file__).resolve().parent SCRIPT_DIR = Path(__file__).resolve().parent
# 本脚本位于 test/host/,仓库根需上溯两级(2026-10 实机窗口修:原 .parent 只到 test/, # 本脚本位于 test/host/,仓库根需上溯两级(2026-10 实机窗口修:原 .parent 只到 test/,
# 导致 .opencode/serial_config.json 与 tools/serial_tool 路径解析错误) # 导致 .serialtest/config.json 与 tools/serial_tool 路径解析错误)
REPO_ROOT = SCRIPT_DIR.parent.parent REPO_ROOT = SCRIPT_DIR.parent.parent
SERIAL_TOOL = REPO_ROOT / "tools" / "serial_tool" / "serial_tool.py" SERIAL_TOOL = REPO_ROOT / "tools" / "serial_tool" / "serial_tool.py"
CFG_FILE = REPO_ROOT / ".opencode" / "serial_config.json" CFG_FILE = REPO_ROOT / ".serialtest" / "config.json"
DEFAULT_FLASH = REPO_ROOT / "MDK-ARM" / "download.bat" DEFAULT_FLASH = REPO_ROOT / "MDK-ARM" / "download.bat"
RESET_BAT = REPO_ROOT / "MDK-ARM" / "reset.bat" RESET_BAT = REPO_ROOT / "MDK-ARM" / "reset.bat"
DEFAULT_HEX = REPO_ROOT / "MDK-ARM" / "STM32F407-Demo" / "STM32F407-Demo.hex" DEFAULT_HEX = REPO_ROOT / "MDK-ARM" / "STM32F407-Demo" / "STM32F407-Demo.hex"
@@ -240,7 +240,7 @@ def main(argv=None):
ap = argparse.ArgumentParser(description="boot log capture + verify (pure stdlib, no PowerShell)") ap = argparse.ArgumentParser(description="boot log capture + verify (pure stdlib, no PowerShell)")
ap.add_argument("--port", default="", ap.add_argument("--port", default="",
help="serial port (default: portName from .opencode/serial_config.json)") help="serial port (default: portName from .serialtest/config.json)")
ap.add_argument("--baud-rate", type=int, default=115200) ap.add_argument("--baud-rate", type=int, default=115200)
ap.add_argument("--settle-ms", type=int, default=8000, ap.add_argument("--settle-ms", type=int, default=8000,
help="RX silence that ends the capture") help="RX silence that ends the capture")
@@ -267,7 +267,7 @@ def main(argv=None):
help="local copy of /log/selftest.log (skips device-side fetch when given)") help="local copy of /log/selftest.log (skips device-side fetch when given)")
ap.add_argument("--device-ip", default="192.168.1.100", ap.add_argument("--device-ip", default="192.168.1.100",
help="device IP for fetching /log/selftest.log via FTP (lftpd anonymous)") help="device IP for fetching /log/selftest.log via FTP (lftpd anonymous)")
ap.add_argument("--out-dir", default=str(Path(tempfile.gettempdir()) / "opencode")) ap.add_argument("--out-dir", default=str(Path(tempfile.gettempdir()) / "serial-boot-test"))
ap.add_argument("--show-tail-lines", type=int, default=15) ap.add_argument("--show-tail-lines", type=int, default=15)
ap.add_argument("--fw-hex", default=str(DEFAULT_HEX), ap.add_argument("--fw-hex", default=str(DEFAULT_HEX),
help="built image for the firmware-identity stamp and the readback " help="built image for the firmware-identity stamp and the readback "
@@ -303,7 +303,7 @@ def main(argv=None):
# 串口助手 CLI 用当前解释器(AGENTS.md 统一入口 = 仓库 .venv python) # 串口助手 CLI 用当前解释器(AGENTS.md 统一入口 = 仓库 .venv python)
py = sys.executable py = sys.executable
# 端口缺省:单一配置源 .opencode/serial_config.json(UTF-8,容忍 BOM) # 端口缺省:单一配置源 .serialtest/config.json(UTF-8,容忍 BOM)
port = args.port port = args.port
if not port and CFG_FILE.is_file(): if not port and CFG_FILE.is_file():
try: try:
+1 -1
View File
@@ -16,7 +16,7 @@
## 使用方式 ## 使用方式
```bash ```bash
# 打开串口(daemon 自动拉起;端口缺省取 .opencode/serial_config.json,不必带参数) # 打开串口(daemon 自动拉起;端口缺省取 .serialtest/config.json,不必带参数)
& '.venv\Scripts\python.exe' tools/serial_tool/serial_tool.py open & '.venv\Scripts\python.exe' tools/serial_tool/serial_tool.py open
# 看有哪些口 / 配置口是否还在位(单一候选时回 hint) # 看有哪些口 / 配置口是否还在位(单一候选时回 hint)
+3 -3
View File
@@ -55,7 +55,7 @@ STATE_DIR = TOOL_DIR / "state"
LOG_FILE = STATE_DIR / "serial.log" LOG_FILE = STATE_DIR / "serial.log"
META_FILE = STATE_DIR / "meta.json" META_FILE = STATE_DIR / "meta.json"
DAEMON_LOG = STATE_DIR / "daemon.log" DAEMON_LOG = STATE_DIR / "daemon.log"
CONFIG_FILE = REPO_ROOT / ".opencode" / "serial_config.json" CONFIG_FILE = REPO_ROOT / ".serialtest" / "config.json"
DEFAULT_HTTP_PORT = 18765 DEFAULT_HTTP_PORT = 18765
CAP_BYTES = 20 * 1024 * 1024 # 日志体积上限 CAP_BYTES = 20 * 1024 * 1024 # 日志体积上限
@@ -71,7 +71,7 @@ DAEMON_LOG_KEEP = 256 * 1024
# ---------------------------------------------------------------- 配置 / 元数据 # ---------------------------------------------------------------- 配置 / 元数据
def load_config(): def load_config():
"""读唯一配置源 .opencode/serial_config.json。 """读唯一配置源 .serialtest/config.json。
编码用 utf-8-sig(与 run_boot_test.py 同口径):纯 utf-8 遇 BOM 会让 json.load 编码用 utf-8-sig(与 run_boot_test.py 同口径):纯 utf-8 遇 BOM 会让 json.load
抛错,旧实现再把它静默吞掉 → 端口/波特率全回退默认 → 报 "no port specified", 抛错,旧实现再把它静默吞掉 → 端口/波特率全回退默认 → 报 "no port specified",
@@ -1287,7 +1287,7 @@ def build_parser():
sub.add_parser("ports", parents=[common], help="枚举 COM 口(本地执行,不依赖 daemon)") sub.add_parser("ports", parents=[common], help="枚举 COM 口(本地执行,不依赖 daemon)")
p = sub.add_parser("open", parents=[common], help="开串口(幂等;daemon 未运行会自动拉起)") p = sub.add_parser("open", parents=[common], help="开串口(幂等;daemon 未运行会自动拉起)")
p.add_argument("--port", help="COM 口(默认取 .opencode/serial_config.json)") p.add_argument("--port", help="COM 口(默认取 .serialtest/config.json)")
p.add_argument("--baud", type=int, help="波特率(默认取配置)") p.add_argument("--baud", type=int, help="波特率(默认取配置)")
p.add_argument("--dtr", type=int, choices=[0, 1], help="显式置 DTR(默认不翻转)") p.add_argument("--dtr", type=int, choices=[0, 1], help="显式置 DTR(默认不翻转)")
p.add_argument("--rts", type=int, choices=[0, 1], help="显式置 RTS(默认不翻转)") p.add_argument("--rts", type=int, choices=[0, 1], help="显式置 RTS(默认不翻转)")