test:@test 闭环迁移 PowerShell→Python(run_boot_test/verify_log 纯标准库,文档同步)

This commit is contained in:
2026-09-20 03:07:42 +08:00
parent 2375569f96
commit 8ae7a12016
18 changed files with 395 additions and 323 deletions
-174
View File
@@ -1,174 +0,0 @@
<#
run_boot_test.ps1 - Automated boot-log capture + verify in one call.
Uses the repo's Python serial assistant (tools/serial_tool/serial_tool.py,
resident daemon on 127.0.0.1:18765, auto-started on first call):
1. open COM port + clear log (fresh generation for this run)
2. flash via -FlashCmd (download.bat: download + Reset & Run)
3. wait-idle: until RX silence >= SettleMs, or MaxWaitMs timeout
4. extract [RX] payload lines from the log file (prefix/timestamp stripped)
5. verify_log.ps1 marker check
The capture is armed (port open, log cleared) BEFORE flashing, and the
resident daemon buffers everything in tools/serial_tool/state/serial.log -
no capture-window timing ritual, no first-byte wait, no EOF retries.
Usage:
powershell -ExecutionPolicy Bypass -File run_boot_test.ps1 `
[-Port COM6] [-BaudRate 115200] `
[-SettleMs 8000] [-MaxWaitMs 30000] `
[-FlashCmd "D:\...\download.bat"] `
[-Required "App init OK;Network Init;net init: OK"] `
[-Forbidden "FAIL;ERROR;HardFault"] `
[-OutDir C:\temp] [-ShowTailLines 15]
-Port default: .opencode/serial_config.json "portName" entry.
-SettleMs: RX silence that ends the capture (default 8000 - boot has a
natural 3~4s silent gap between tasks; 2000 would truncate the log).
Exit codes:
0 = PASS (captured + all required markers + no forbidden)
1 = FAIL (marker miss / forbidden hit)
2 = capture or flash error
3 = usage / preflight error
NOTE: ASCII-only on purpose - project source files contain Chinese and must
never be rewritten by encoding-sensitive PowerShell. Keep this script ASCII.
#>
[CmdletBinding()]
param(
[string]$Port = "",
[int]$BaudRate = 115200,
[int]$SettleMs = 8000,
[int]$MaxWaitMs = 30000,
[string]$FlashCmd = "",
[string]$Required = "App init OK;Network Init;net init: OK",
[string]$Forbidden = "FAIL;ERROR;HardFault",
[string]$OutDir = "$env:TEMP\opencode",
[int]$ShowTailLines = 15
)
$ErrorActionPreference = 'Stop'
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$repoRoot = Split-Path -Parent $scriptDir
$py = Join-Path $repoRoot ".venv\Scripts\python.exe"
$tool = Join-Path $repoRoot "tools\serial_tool\serial_tool.py"
$verifyScript = Join-Path $scriptDir "verify_log.ps1"
$cfgFile = Join-Path $repoRoot ".opencode\serial_config.json"
# -Port default: the single source of truth is .opencode/serial_config.json
if (-not $Port) {
if (Test-Path -LiteralPath $cfgFile) {
$Port = (Get-Content -LiteralPath $cfgFile -Raw -Encoding UTF8 | ConvertFrom-Json).portName
}
}
if (-not $Port) {
Write-Output "RUN_TEST_ERROR: no -Port given and no portName in $cfgFile"
exit 3
}
# Default flash command: resolve relative to the repo (no stale absolute paths)
if (-not $FlashCmd) {
$FlashCmd = Join-Path $repoRoot "MDK-ARM\download.bat"
}
foreach ($p in @($py, $tool, $verifyScript)) {
if (-not (Test-Path -LiteralPath $p)) {
Write-Output "RUN_TEST_ERROR: missing $p"
exit 3
}
}
if (-not (Test-Path -LiteralPath $FlashCmd)) {
Write-Output "RUN_TEST_ERROR: flash command not found: $FlashCmd"
exit 3
}
if (-not (Test-Path -LiteralPath $OutDir)) {
New-Item -ItemType Directory -Path $OutDir -Force | Out-Null
}
$stamp = Get-Date -Format "yyyyMMdd_HHmmss"
$bootTxt = Join-Path $OutDir "boot_log_$stamp.txt"
function Invoke-Tool {
param(
[Parameter(Position = 0)][string]$Command,
[Parameter(ValueFromRemainingArguments = $true)][string[]]$Rest
)
$script:toolOut = (& $py $tool $Command @Rest 2>$null) -join "`n"
$script:toolRc = $LASTEXITCODE
}
Write-Output "=== Boot test $stamp ==="
Write-Output "Port: $Port @ ${BaudRate} settle=${SettleMs}ms max=${MaxWaitMs}ms"
# 1. open port (auto-starts the serial daemon if it is not running)
Invoke-Tool "open" "--port" $Port "--baud" $BaudRate "--timeout" 15
if ($script:toolRc -ne 0) {
Write-Output "RUN_TEST_ERROR: open failed: $($script:toolOut)"
exit 2
}
# 2. clear log (fresh generation; only data after this belongs to the run)
Invoke-Tool "clear" "--timeout" 15
if ($script:toolRc -ne 0) {
Write-Output "RUN_TEST_ERROR: clear failed: $($script:toolOut)"
exit 2
}
# 3. flash (download + Reset & Run). A failed flash does not abort: the
# board may still have reset; capture + marker check will judge the result.
Write-Output "FLASH_START: $FlashCmd"
$sw = [System.Diagnostics.Stopwatch]::StartNew()
& $FlashCmd
$flashRc = $LASTEXITCODE
$sw.Stop()
Write-Output ("FLASH_RC: {0} elapsed_ms={1}" -f $flashRc, $sw.ElapsedMilliseconds)
if ($flashRc -ne 0) {
Write-Output "RUN_TEST_WARN: flash rc=$flashRc (continuing: board may still have reset)"
}
# 4. wait for RX silence (settle) or the overall timeout
$waitTimeoutS = [int]($MaxWaitMs / 1000) + 20
Invoke-Tool "wait-idle" "--settle-ms" $SettleMs "--max-ms" $MaxWaitMs "--timeout" $waitTimeoutS
if ($script:toolRc -ne 0) {
Write-Output "RUN_TEST_ERROR: wait-idle failed: $($script:toolOut)"
exit 2
}
$wait = $script:toolOut | ConvertFrom-Json
Write-Output ("WAIT: reason={0} rx_bytes={1} waited_ms={2}" -f $wait.reason, $wait.rx_bytes, $wait.waited_ms)
if ($wait.reason -eq "no_data") {
Write-Output "RUN_TEST_ERROR: no RX data at all (check port / board power / flash)"
exit 2
}
# 5. extract [RX] payload lines (strip the "[RX] <iso-timestamp> " prefix)
Invoke-Tool "log" "--tail" 0 "--timeout" 15
if ($script:toolRc -ne 0) {
Write-Output "RUN_TEST_ERROR: log read failed: $($script:toolOut)"
exit 2
}
$logJson = $script:toolOut | ConvertFrom-Json
$rx = New-Object System.Collections.Generic.List[string]
foreach ($l in $logJson.lines) {
if ($l -is [string] -and $l.StartsWith("[RX] ")) {
$rest = $l.Substring(5)
$sp = $rest.IndexOf(" ")
if ($sp -ge 0) { $rx.Add($rest.Substring($sp + 1)) } else { $rx.Add($rest) }
}
}
if ($rx.Count -eq 0) {
Write-Output "RUN_TEST_ERROR: no [RX] lines in log (device silent?)"
exit 2
}
[System.IO.File]::WriteAllLines($bootTxt, $rx, (New-Object System.Text.UTF8Encoding($false)))
Write-Output "Boot log: $bootTxt (lines=$($rx.Count) gen=$($logJson.gen))"
# 6. marker verification
& $verifyScript -LogPath $bootTxt -Required $Required -Forbidden $Forbidden `
-ShowTailLines $ShowTailLines
$verifyRc = $LASTEXITCODE
Write-Output "RUN_TEST_RC=$verifyRc"
exit $verifyRc
+218
View File
@@ -0,0 +1,218 @@
#!/usr/bin/env python3
"""run_boot_test.py - 一键开机日志抓取 + 校验(纯标准库,2026-09-20 替代 run_boot_test.ps1)。
调用仓库内置串口助手(tools/serial_tool,常驻 daemon,子进程 CLI + JSON):
1. open 串口 + clear 日志(本次运行独立代际)
2. 烧录(默认 MDK-ARM\\download.bat:下载 + Reset & Run)
3. wait-idle:RX 静默 >= settle-ms,或 max-wait-ms 超时
4. 从日志提取 [RX] 载荷行(剥掉 "[RX] <ISO时间戳> " 前缀)
5. verify_log.py 标记校验
采集在烧录**之前**武装(open + clear 先于烧录),daemon 常驻全量日志兜底,
无采集窗口/首字节等待仪式(与旧 PowerShell 版行为一致)。
相对旧 PowerShell 版的加固(本机 Windows 环境实测教训,2026-09-20):
- 子进程输出按**字节**读取、显式 UTF-8 解码 —— 不受控制台代码页(GBK)影响
- serial_config.json 按 UTF-8(容忍 BOM)解析 —— 无 PS 5.1 按 GBK 读 JSON
导致 "Unterminated string" 崩溃的问题
- 不经 PowerShell —— 无执行策略(scripts disabled)拦截
- 烧录带超时(默认 180s;旧版无超时,坏 SWD 连接会永久挂起)
用法:
& '.venv\\Scripts\\python.exe' test/run_boot_test.py
[--port COM7] [--baud-rate 115200] [--settle-ms 8000] [--max-wait-ms 30000]
[--flash-cmd MDK-ARM\\download.bat] [--flash-timeout 180]
[--required "App init OK;Network Init;net init: OK"]
[--forbidden "FAIL;ERROR;HardFault"]
[--out-dir <目录>] [--show-tail-lines 15]
--port 缺省取 .opencode/serial_config.json 的 portName(单一配置源)
退出码(与旧版一致):
0 = PASS(采集成功 + Required 全中 + Forbidden 零命中)
1 = FAIL(标记缺失/命中)
2 = 采集或烧录错误
3 = 用法/预检错误
"""
import argparse
import json
import subprocess
import sys
import tempfile
import time
from datetime import datetime
from pathlib import Path
SCRIPT_DIR = Path(__file__).resolve().parent
REPO_ROOT = SCRIPT_DIR.parent
SERIAL_TOOL = REPO_ROOT / "tools" / "serial_tool" / "serial_tool.py"
CFG_FILE = REPO_ROOT / ".opencode" / "serial_config.json"
DEFAULT_FLASH = REPO_ROOT / "MDK-ARM" / "download.bat"
REQUIRED_DEFAULT = "App init OK;Network Init;net init: OK"
FORBIDDEN_DEFAULT = "FAIL;ERROR;HardFault"
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 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="",
help="serial port (default: portName from .opencode/serial_config.json)")
ap.add_argument("--baud-rate", type=int, default=115200)
ap.add_argument("--settle-ms", type=int, default=8000,
help="RX silence that ends the capture")
ap.add_argument("--max-wait-ms", type=int, default=30000,
help="overall wait-idle timeout")
ap.add_argument("--flash-cmd", default="",
help="flash command (default: MDK-ARM/download.bat; pass reset.bat for reset-only)")
ap.add_argument("--flash-timeout", type=int, default=180,
help="flash subprocess timeout in seconds")
ap.add_argument("--required", default=REQUIRED_DEFAULT,
help="required markers, ';' separated")
ap.add_argument("--forbidden", default=FORBIDDEN_DEFAULT,
help="forbidden patterns, ';' separated")
ap.add_argument("--out-dir", default=str(Path(tempfile.gettempdir()) / "opencode"))
ap.add_argument("--show-tail-lines", type=int, default=15)
args = ap.parse_args(argv)
# 串口助手 CLI 用当前解释器(AGENTS.md 统一入口 = 仓库 .venv python)
py = sys.executable
# 端口缺省:单一配置源 .opencode/serial_config.json(UTF-8,容忍 BOM)
port = args.port
if not port and CFG_FILE.is_file():
try:
cfg = json.loads(CFG_FILE.read_text(encoding="utf-8-sig"))
port = str(cfg.get("portName") or "")
except (OSError, ValueError) as e:
print("RUN_TEST_ERROR: cannot parse {}: {}".format(CFG_FILE, e))
return 3
if not port:
print("RUN_TEST_ERROR: no --port given and no portName in {}".format(CFG_FILE))
return 3
flash_cmd = Path(args.flash_cmd) if args.flash_cmd else DEFAULT_FLASH
# 预检(对应旧版 missing-file 检查)
for p in (SERIAL_TOOL, SCRIPT_DIR / "verify_log.py", flash_cmd):
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)
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)。烧录失败不中断:板子可能已复位,
# 由采集 + 标记判定结果(与旧版一致)
print("FLASH_START: {}".format(flash_cmd))
t0 = time.monotonic()
try:
proc = subprocess.run(["cmd", "/c", str(flash_cmd)],
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))
# 4. wait-idle(RX 静默收尾或整体超时)
wait_timeout_s = args.max_wait_ms // 1000 + 20
data, out = tool("wait-idle",
"--settle-ms", str(args.settle_ms),
"--max-ms", str(args.max_wait_ms),
"--timeout", str(wait_timeout_s),
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时间戳> " 前缀)
data, out = tool("log", "--tail", "0", "--timeout", "15")
if data is None:
print("RUN_TEST_ERROR: log read failed: {}".format(out))
return 2
rx = []
for l in data.get("lines", []):
if isinstance(l, str) and l.startswith("[RX] "):
rest = l[5:]
sp = rest.find(" ")
rx.append(rest[sp + 1:] if sp >= 0 else rest)
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={})".format(boot_txt, len(rx), data.get("gen")))
# 6. 标记校验
from verify_log import verify
rc = verify(str(boot_txt), args.required, args.forbidden, args.show_tail_lines)
print("RUN_TEST_RC={}".format(rc))
return rc
if __name__ == "__main__":
sys.exit(main())
-102
View File
@@ -1,102 +0,0 @@
<#
verify_log.ps1 - Check a captured MCU debug log against boot/test markers.
Used by the @test loop: after flashing, the fresh boot log is extracted from
the serial assistant log (tools/serial_tool), then this script greps it and
prints a structured verdict.
Usage:
powershell -ExecutionPolicy Bypass -File verify_log.ps1 `
-LogPath <file> `
[-Required 'marker1;marker2'] `
[-Forbidden 'FAIL;ERROR;HardFault'] `
[-ShowTailLines 40]
Exit code:
0 = PASS (every required marker found, no forbidden pattern matched)
1 = FAIL (missing required marker or forbidden pattern present)
2 = ERROR (log file not found / unreadable)
Default required markers match the STM32F4-Base boot sequence
(see App/app_main.c + App/task/net_task.c). Override per test case, e.g.:
-Required 'App init OK;net init: OK;PHY: 100M FULL'
-Forbidden 'FAIL;ERROR;HardFault;assert'
NOTE: ASCII-only on purpose - project source files contain Chinese and must
never be rewritten by encoding-sensitive PowerShell. Keep this script ASCII.
#>
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$LogPath,
[string]$Required = 'App init OK;Network Init;net init: OK',
[string]$Forbidden = 'FAIL;ERROR;HardFault;assert',
[int]$ShowTailLines = 40
)
$ErrorActionPreference = 'Stop'
if (-not (Test-Path -LiteralPath $LogPath)) {
Write-Output "VERIFY_ERROR: log file not found: $LogPath"
exit 2
}
try {
$content = Get-Content -LiteralPath $LogPath -Encoding UTF8 -ErrorAction Stop
} catch {
# Fall back to default encoding if UTF-8 read fails
try {
$content = Get-Content -LiteralPath $LogPath -ErrorAction Stop
} catch {
Write-Output "VERIFY_ERROR: cannot read log file: $LogPath"
exit 2
}
}
$all = $content -join "`n"
$requiredList = @($Required -split ';' | ForEach-Object { $_.Trim() } | Where-Object { $_ -ne '' })
$forbiddenList = @($Forbidden -split ';' | ForEach-Object { $_.Trim() } | Where-Object { $_ -ne '' })
Write-Output "=== Log verdict: $LogPath ==="
Write-Output ("Lines: {0}, bytes: {1}" -f $content.Count, (Get-Item -LiteralPath $LogPath).Length)
$missing = @()
foreach ($m in $requiredList) {
if ($all.Contains($m)) {
Write-Output ("[OK] required marker found: {0}" -f $m)
} else {
Write-Output ("[MISS] required marker NOT found: {0}" -f $m)
$missing += $m
}
}
$hits = @()
foreach ($f in $forbiddenList) {
$matches = @($content | Where-Object { $_ -like "*$f*" })
if ($matches.Count -gt 0) {
Write-Output ("[HIT] forbidden pattern: {0} ({1} line(s))" -f $f, $matches.Count)
$hits += $f
} else {
Write-Output ("[OK] forbidden pattern absent: {0}" -f $f)
}
}
if ($ShowTailLines -gt 0 -and $content.Count -gt 0) {
Write-Output ""
Write-Output "=== Last $ShowTailLines line(s) of log ==="
$start = [Math]::Max(0, $content.Count - $ShowTailLines)
$content[$start..($content.Count - 1)] | ForEach-Object { Write-Output $_ }
}
if ($missing.Count -gt 0 -or $hits.Count -gt 0) {
Write-Output ""
Write-Output "VERDICT: FAIL"
exit 1
}
Write-Output ""
Write-Output "VERDICT: PASS"
exit 0
+130
View File
@@ -0,0 +1,130 @@
#!/usr/bin/env python3
"""verify_log.py - 对抓取的 MCU 调试日志做标记校验(纯标准库,2026-09-20 替代 verify_log.ps1)。
用于 @test 闭环:烧录后从串口助手日志提取的开机日志交给本脚本 grep 标记,
输出结构化判定(格式与旧 PowerShell 版逐字一致,下游文档/习惯不受影响)。
用法:
& '.venv\\Scripts\\python.exe' test/verify_log.py --log-path <file>
[--required 'marker1;marker2']
[--forbidden 'FAIL;ERROR;HardFault;assert']
[--show-tail-lines 40]
退出码(与旧版一致):
0 = PASS(所有 Required 标记找到,Forbidden 零命中)
1 = FAIL(缺 Required 或命中 Forbidden)
2 = ERROR(日志文件不存在/不可读)
默认 Required 标记对应 STM32F4-Base 启动序列(见 App/app_main.c + App/task/net_task.c),
按测试用例覆盖,例如:
--required 'App init OK;net init: OK;PHY: 100M FULL'
--forbidden 'FAIL;ERROR;HardFault;assert'
语义约定(与旧 PowerShell 版对齐):
- Required:大小写敏感子串匹配(全文)
- Forbidden:大小写**不敏感**子串匹配、逐行计数(旧版 -like 语义;
如 Forbidden 'FAIL' 会命中 "write /log/run.log fail (7)" 这类行)
- 读取编码:先按 UTF-8,解码失败回退系统 locale 编码(旧版 Get-Content 行为)
- 控制台输出重配置为 UTF-8(errors=replace):任何 Windows 控制台代码页
都只影响终端显示,不会导致本脚本崩溃
"""
import argparse
import locale
import sys
from pathlib import Path
REQUIRED_DEFAULT = "App init OK;Network Init;net init: OK"
FORBIDDEN_DEFAULT = "FAIL;ERROR;HardFault;assert"
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 verify(log_path, required=REQUIRED_DEFAULT, forbidden=FORBIDDEN_DEFAULT,
show_tail=40, out=print):
"""标记校验核心(可被 run_boot_test.py 复用)。返回退出码 0/1/2。"""
p = Path(log_path)
if not p.is_file():
out("VERIFY_ERROR: log file not found: {}".format(log_path))
return 2
try:
lines = _read_lines(log_path)
except OSError:
out("VERIFY_ERROR: cannot read log file: {}".format(log_path))
return 2
full = "\n".join(lines)
req_list = _split_list(required)
forb_list = _split_list(forbidden)
out("=== Log verdict: {} ===".format(log_path))
out("Lines: {}, bytes: {}".format(len(lines), 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)
hits = []
for f in forb_list:
fl = f.lower()
n = sum(1 for ln in lines if fl in ln.lower())
if n > 0:
out("[HIT] forbidden pattern: {} ({} line(s))".format(f, n))
hits.append(f)
else:
out("[OK] forbidden pattern absent: {}".format(f))
if show_tail > 0 and lines:
out("")
out("=== Last {} line(s) of log ===".format(show_tail))
for ln in lines[-show_tail:]:
out(ln)
out("")
if missing or hits:
out("VERDICT: FAIL")
return 1
out("VERDICT: PASS")
return 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("--show-tail-lines", type=int, default=40,
help="show last N log lines (0 = off)")
args = ap.parse_args(argv)
return verify(args.log_path, args.required, args.forbidden, args.show_tail_lines)
if __name__ == "__main__":
sys.exit(main())