#!/usr/bin/env python3 """软硬件全链路引导测试(docs/field-test-plan.md 的执行器)。 用法:.venv/bin/python tools/field_test.py [--mac AA:BB:CC:DD:EE:FF] [--ota] [--no-pause] 自动步骤直接判定;目视步骤打印预期并等待回车;结果写 docs/field-test-result.md。 """ from __future__ import annotations import argparse import datetime import json import subprocess import sys import tempfile from pathlib import Path ROOT = Path(__file__).resolve().parent.parent PPCLOCK = ROOT / ".venv" / "bin" / "ppclock" RESULT = ROOT / "docs" / "field-test-result.md" results = [] def run(args, timeout=120): """执行 ppclock --json,返回 (exit_code, dict|None, raw)。""" cmd = [str(PPCLOCK), "--json"] + args p = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout) try: out = json.loads(p.stdout.strip().splitlines()[-1]) except Exception: out = None return p.returncode, out, p.stdout + p.stderr def record(step, name, expect, ok, note=""): results.append({"step": step, "name": name, "expect": expect, "ok": ok, "note": note}) mark = "✅" if ok else "❌" print(f" {mark} {step} {name}" + (f" — {note}" if note else "")) def step_auto(step, name, cli_args, expect, check=None, timeout=120): code, out, raw = run(cli_args, timeout) ok = code == 0 and out is not None and out.get("ok") is True if ok and check: ok = bool(check(out)) record(step, name, expect, ok, "" if ok else raw.strip()[:200]) return ok, out def step_visual(step, name, cli_args, expect, pause, timeout=120): code, out, raw = run(cli_args, timeout) ok = code == 0 and out is not None and out.get("ok") is True if not ok: record(step, name, expect, False, raw.strip()[:200]) return print(f" → 命令已发送成功。屏显预期:{expect}") ans = "y" if not pause else input(" 屏显符合预期?[Y/n] ").strip().lower() or "y" record(step, name, expect, ans != "n", "目视确认" if ans != "n" else "目视不符") def make_test_images(tmp: Path): from PIL import Image, ImageDraw img = Image.new("RGB", (400, 300), (255, 255, 255)) d = ImageDraw.Draw(img) for y in range(0, 300, 20): for x in range(0, 400, 20): if (x // 20 + y // 20) % 2: d.rectangle([x, y, x + 19, y + 19], fill=(0, 0, 0)) d.rectangle([150, 100, 250, 200], fill=(255, 0, 0)) p1 = tmp / "checker_red.png" img.save(p1) img2 = Image.new("RGB", (400, 180), (255, 255, 255)) d2 = ImageDraw.Draw(img2) d2.rectangle([50, 40, 350, 140], fill=(0, 0, 0)) p2 = tmp / "small.png" img2.save(p2) return p1, p2 def main(): ap = argparse.ArgumentParser() ap.add_argument("--mac", help="跳过扫描直连") ap.add_argument("--ota", action="store_true", help="执行高危固件烧录阶段") ap.add_argument("--no-pause", action="store_true", help="目视步骤全部记为通过(无人值守)") args = ap.parse_args() pause = not args.no_pause mac_args = ["--mac", args.mac] if args.mac else [] tmp = Path(tempfile.mkdtemp(prefix="ppclock-field-")) print("== 阶段 0 预检 ==") code, out, raw = run(["ota", str(ROOT / "app" / "firmware-PP_da14585_4.2_CH.img")]) record("0.3", "固件样本预检", "precheck ok(不烧录)", code == 0 and out and out.get("ok"), raw.strip()[:120]) print("== 阶段 1 发现与识别 ==") mac = args.mac if not mac: ok, out = step_auto("1.1", "扫描发现 NRF- 设备", ["scan"], "发现 NRF-XXXXXX", lambda o: any(d["name"].startswith("NRF-") for d in o["data"]["devices"])) if ok: dev = next(d for d in out["data"]["devices"] if d["name"].startswith("NRF-")) mac = dev["address"] print(f" → 选中 {dev['name']} {mac}") mac_args = ["--mac", mac] else: print("未发现设备,终止。请确认设备已开机并在范围内。") finish(1) ok, out = step_auto("1.2", "读取设备 ID", mac_args + ["activate", "--show-id"], "14hex 设备 ID", lambda o: len(o["data"]["device_id"]) == 14) if ok: dev_id = out["data"]["device_id"] sys.path.insert(0, str(ROOT / "src")) from ppclock.protocol import recover_mac_from_device_id rec = recover_mac_from_device_id(dev_id) print(f" → ID={dev_id} 反解 MAC(NVDS序)={rec.hex(':')}") print("== 阶段 2 激活 ==") step_auto("2.1", "离线自动激活", mac_args + ["activate", "--auto"], "keygen 下发成功") ok, _ = step_auto("2.2", "错误码无惩罚", mac_args + ["activate", "000000000000"], "发全0码后命令仍可用") step_auto("2.2b", "错误码后命令可用", mac_args + ["mode", "clock1"], "mode 仍成功(验证固件不门禁)") print("== 阶段 3 时钟/模式(目视)==") step_visual("3.1", "对时", mac_args + ["time"], "屏显时间正确", pause) for m, desc in [("clock1", "时钟版式一"), ("clock2", "版式二"), ("clock3", "版式三"), ("calendar1", "日历版式一"), ("calendar3", "日历版式三")]: step_visual(f"3.x", f"mode {m}", mac_args + ["mode", m], desc, pause) for tg, desc in [("invert", "反色"), ("font", "字体变化"), ("rotate180", "旋转180°"), ("rotate180", "旋转恢复"), ("hour_format", "12/24h 切换"), ("clock_color", "颜色切换")]: step_visual("3.x", f"toggle {tg}", mac_args + ["toggle", tg], desc, pause) step_visual("3.10", "刷屏", mac_args + ["clear"], "全屏刷新", pause) print("== 阶段 4 图像(目视)==") p1, p2 = make_test_images(tmp) step_visual("4.1", "棋盘+红块", mac_args + ["image", str(p1), "--slot", "0"], "棋盘清晰、中央红块为红", pause) run(mac_args + ["mode", "image0"]) step_visual("4.1b", "显示图一", mac_args + ["mode", "image0"], "上张图在屏", pause) for algo in ["floydsteinberg", "bayer", "none"]: step_visual("4.2", f"抖动 {algo}", mac_args + ["image", str(p1), "--algo", algo], f"{algo} 风格可见", pause) step_visual("4.4", "小图 400x180", mac_args + ["image", str(p2)], "cod=04 路径小图正常", pause) print("== 阶段 5 模板(目视)==") for name, data in [ ("custom", {"text": "全链路测试【通过】"}), ("schedule", {"items": [["09:00", "晨会"], ["18:30", "复盘【重要】"]]}), ("memo", {"title": "测试", "lines": ["第一条", "第二条"]}), ("businesscard", {"name": "测试", "title": "工程师", "phone": "138"}), ("course", {"grid": [["数学", "语文"], ["英语", "物理"]]}), ("qrcode", {"content": "https://example.com", "caption": "扫码测试"}), ]: step_visual("5.x", f"模板 {name}", mac_args + ["template", name, "--data", json.dumps(data, ensure_ascii=False)], f"{name} 版式正确、红字为红", pause) print("== 阶段 6 倒计时(目视)==") step_visual("6.1", "时钟倒计时", mac_args + ["countdown", "2026-12-31", "--prefix", "目标"], "前缀+天数", pause) step_visual("6.2", "日历倒计时", mac_args + ["countdown", "--mode", "calendar", "2026-12-31", "--prefix", "中考"], "日历界面倒计时", pause) step_visual("6.4", "关闭倒计时", mac_args + ["countdown", "--off"], "倒计时消失", pause) print("== 阶段 7 其他命令 ==") step_visual("7.2", "停车牌", mac_args + ["parking", "13800138000"], "号码显示", pause) step_visual("7.3", "LUT 红", mac_args + ["lut", "0x05"], "红色加深", pause) ok, _ = run(mac_args + ["rotation", "--count", "4"]) record("7.4", "轮播(no-op 验证)", "命令成功但预期无屏效(固件 no-op §3.3)", ok == 0, "已发送,目视应无变化") ok, _ = run(mac_args + ["wifi", "TestAP", "pw123456"]) record("7.5", "WiFi(no-op 验证)", "命令成功但预期无屏效", ok == 0, "已发送") step_visual("7.6", "raw 提交", mac_args + ["raw", "e2"], "强制刷新", pause) print("== 阶段 8 异常与契约(自动)==") code, out, raw = run(mac_args + ["countdown"]) record("8.1", "错误参数契约", "ok=false + 退出码1", code == 1 and out and not out["ok"]) ok, out = step_auto("8.3", "batch 批量", mac_args + ["batch", str(ROOT / "examples" / "field_test.jsonl")], "单连接逐行 ok", lambda o: all(r["ok"] for r in o["data"]["results"])) if args.ota: print("== 阶段 9 固件烧录(高危)==") tst = make_test_firmware(tmp) if tst is None: record("9.1", "制作测试镜像", "版本串等长替换+CRC 有效", False, "版本串未找到") else: record("9.1", "制作测试镜像", "版本串等长替换+CRC 有效", True, str(tst)) ans = "y" if not pause else input(" 确认烧录测试镜像?[y/N] ").strip().lower() if ans == "y": ok, out = run(mac_args + ["ota", str(tst), "--yes"], timeout=600) record("9.2", "OTA 烧录", "全部块 ok", ok == 0 and out and out.get("ok"), str(out)[:200]) input(" 等待设备重启后回车…") if pause else None ok, _ = run(["scan"]) record("9.3", "重启后设备可用", "scan 正常", ok == 0) ans = "y" if not pause else input(" 回滚刷入原镜像?[Y/n] ").strip().lower() or "y" if ans == "y": ok, out = run(mac_args + ["ota", str(ROOT / "app" / "firmware-PP_da14585_4.2_CH.img"), "--yes"], timeout=600) record("9.4", "回滚原镜像", "烧录成功", ok == 0 and out and out.get("ok")) finish(0) def make_test_firmware(tmp: Path): """仅改版本串的等长测试镜像(安全改动)。""" sys.path.insert(0, str(ROOT)) from tools.fw import pack_image, unpack_body, parse_image src = ROOT / "app" / "firmware-PP_da14585_4.2_CH.img" body = bytearray(unpack_body(src.read_bytes())) old = b"V:PP_da14585_4.2" new = b"V:PP_da14585_TST" assert len(old) == len(new) idx = body.find(old) if idx < 0: return None body[idx:idx + len(old)] = new img = pack_image(bytes(body)) assert parse_image(img)["crc_valid"] out = tmp / "fw_tst.img" out.write_bytes(img) return out def finish(code): passed = sum(1 for r in results if r["ok"]) total = len(results) lines = [f"# 全链路测试结果({datetime.datetime.now():%Y-%m-%d %H:%M})", f"\n通过 {passed}/{total}\n", "| 步骤 | 名称 | 预期 | 结果 | 备注 |", "|---|---|---|---|---|"] for r in results: lines.append("| {step} | {name} | {expect} | {ok} | {note} |".format( **r, ok="✅" if r["ok"] else "❌")) RESULT.write_text("\n".join(lines), encoding="utf-8") print(f"\n结果:{passed}/{total} 通过 → {RESULT}") sys.exit(code) if __name__ == "__main__": main()