test: unified K1 HIL harness + RTT inject hooks (tm1617/adc/bin2/sticky)

tools/k1_harness: one CLI over existing COM/BLE/RTT scripts covering
App SysEx 01-06, BLE smoke, TM1629/TM1617 key inject, 1/2/3.bin pack
checks with pick/outro regression, and UI nav (log status/TAP/DUMP).
Unified PASS/FAIL/SKIP/SENT markdown report to Doc/reports.

firmware hooks in app_log.c: 'tm1617 key N', 'adc key N on|off',
'tone bin2 [idx]', 'chord key N hold' (sticky inject survives physical
idle scan so normal-mode pick tests can hold a chord).

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
yuquanjun 2026-09-14 15:05:17 +08:00
parent daee47a457
commit a495717883
36 changed files with 847 additions and 5 deletions

View File

@ -427,6 +427,35 @@ uint8_t app_log_try_command(const char *cmd)
SEGGER_RTT_WriteString(APP_LOG_RTT_CHANNEL, line);
return 1U;
}
/* tone bin2 [idx] → 2.bin 本地曲目(专业+本地曲目,带索引扫描) */
if (strncmp(cmd, "tone bin2", 9) == 0) {
char line[160];
int ret;
unsigned idx = 0;
int count;
if (cmd[9] == ' ' && cmd[10] != '\0')
idx = (unsigned)strtoul(cmd + 10, NULL, 10);
mGuiData[GUI_TAB_INDEX].Current = 1; /* 专业 */
mGuiData[GUI_AUTOBAND_SW].Current = 1; /* 本地曲目→2.bin */
ParamGuiData[EXPRESS_MODE_PARAM].Current = (uint8_t)idx;
UI_ApplyToneAddress();
AutoBandTop1_Stop();
StartFlag = 0;
count = AutoBandTop1_GetPresetItemCount();
if (count > 0 && (int)idx >= count)
idx = (unsigned)(count - 1);
ParamGuiData[EXPRESS_MODE_PARAM].Current = (uint8_t)idx;
ret = AutoBandTop1_LoadPresetItemFromFlash((int)idx);
snprintf(line, sizeof(line),
"TONE_BIN2 ret=%d idx=%u addr=0x%08lX map=BIN2@0x%08lX name=%s count=%d %s\n",
ret, idx, (unsigned long)ADDRESS,
(unsigned long)EXTFLASH_BIN2_SONG_HAITIAN_ADDR,
AutoBandTop1_GetPresetName() ? AutoBandTop1_GetPresetName() : "(null)",
AutoBandTop1_GetPresetItemCount(),
(ADDRESS == EXTFLASH_BIN2_SONG_HAITIAN_ADDR) ? "OK" : "MISMATCH");
SEGGER_RTT_WriteString(APP_LOG_RTT_CHANNEL, line);
return 1U;
}
/* tone bin3 [idx] → 3.bin 万能和弦走向 */
if (strncmp(cmd, "tone bin3", 9) == 0) {
char line[160];
@ -495,12 +524,43 @@ uint8_t app_log_try_command(const char *cmd)
if (strncmp(cmd, "chord key ", 10) == 0 && cmd[10] != '\0') {
unsigned key = (unsigned)strtoul(cmd + 10, NULL, 10);
char line[48];
int hold = (strstr(cmd + 10, "hold") != NULL);
if (key > 23U) {
SEGGER_RTT_WriteString(APP_LOG_RTT_CHANNEL, "CHORD_KEY_BAD\n");
return 1U;
}
app_tm1629_inject_key((uint8_t)key);
snprintf(line, sizeof(line), "CHORD_KEY_OK key=%u\n", key);
if (hold)
app_tm1629_inject_hold((uint8_t)key);
else
app_tm1629_inject_key((uint8_t)key);
snprintf(line, sizeof(line), "CHORD_KEY_OK key=%u%s\n", key, hold ? " hold" : "");
SEGGER_RTT_WriteString(APP_LOG_RTT_CHANNEL, line);
return 1U;
}
/* 测试注入tm1617 key N0~3=MAIN_D/C/B/A 段落或导航键4=释放) */
if (strncmp(cmd, "tm1617 key ", 11) == 0 && cmd[11] != '\0') {
unsigned key = (unsigned)strtoul(cmd + 11, NULL, 10);
char line[48];
if (key > 4U) {
SEGGER_RTT_WriteString(APP_LOG_RTT_CHANNEL, "TM1617_KEY_BAD\n");
return 1U;
}
app_tm1617_inject_key((uint8_t)key);
snprintf(line, sizeof(line), "TM1617_KEY_OK key=%u\n", key);
SEGGER_RTT_WriteString(APP_LOG_RTT_CHANNEL, line);
return 1U;
}
/* 测试注入adc key N on|off0~31=独立尾奏键与万能第4键同路径 */
if (strncmp(cmd, "adc key ", 8) == 0 && cmd[8] != '\0') {
unsigned idx = (unsigned)strtoul(cmd + 8, NULL, 10);
char line[48];
int on = (strstr(cmd + 8, "on") != NULL);
if (idx > 3U || (!on && strstr(cmd + 8, "off") == NULL)) {
SEGGER_RTT_WriteString(APP_LOG_RTT_CHANNEL, "ADC_KEY_BAD\n");
return 1U;
}
ADC_IN1_KEY_Handle((uint8_t)idx, on ? true : false);
snprintf(line, sizeof(line), "ADC_KEY_OK idx=%u on=%d\n", idx, on);
SEGGER_RTT_WriteString(APP_LOG_RTT_CHANNEL, line);
return 1U;
}

View File

@ -84,3 +84,12 @@ uint8_t app_tm1617_scan_key(void)
}
return KEY_NULL;
}
void app_tm1617_inject_key(uint8_t key)
{
if (key >= KEY_NULL)
key = KEY_NULL;
key_last = key;
LOG_I("KEY", "tm1617 inject key=%u", (unsigned)key);
TM1617_Handle(key);
}

View File

@ -11,4 +11,8 @@ void app_tm1617_auto_loop(void);
uint8_t app_tm1617_scan_key(void);
/* RTT/test: bypass physical scan and inject a key event directly.
* key: 0=KEY_MAIN_D 1=KEY_MAIN_C 2=KEY_MAIN_B 3=KEY_MAIN_A 4+=release(KEY_NULL) */
void app_tm1617_inject_key(uint8_t key);
#endif /* APP_TM1617_H */

View File

@ -1,9 +1,10 @@
#include "includes.h"
static uint8_t key_last = KEY_NONE;
static uint8_t key_hold_sticky = 0; /* 1 = ????????????????(KEY_NONE)????? */
/* LED 期望颜色缓存:索引 = LedNum_TypeDef0~7COLOR_OTHER 表示灭 */
/* LED ???????????????? = LedNum_TypeDef??0~7????COLOR_OTHER ????? */
//static LedColor_TypeDef led_cache[8] = {
// COLOR_OTHER, COLOR_OTHER, COLOR_OTHER, COLOR_OTHER,
// COLOR_OTHER, COLOR_OTHER, COLOR_OTHER, COLOR_OTHER
@ -28,6 +29,9 @@ uint8_t app_tm1629_Scan_Key(void)
key = TM1629D_GetKey();
rt_mutex_release(TM1629_Mutex);
if(key_hold_sticky && key == KEY_NONE)
return KEY_NONE; /* ??????§ľ??????????????????????? */
if(key != key_last)
{
key_last = key;
@ -40,11 +44,25 @@ uint8_t app_tm1629_Scan_Key(void)
void app_tm1629_inject_key(uint8_t key)
{
key_hold_sticky = 0;
key_last = key;
LOG_I("KEY", "inject key=%u", (unsigned)key);
TM1629_Handle(key);
}
void app_tm1629_inject_hold(uint8_t key)
{
if(key == KEY_NONE)
{
app_tm1629_inject_key(KEY_NONE);
return;
}
key_hold_sticky = 1;
key_last = key;
LOG_I("KEY", "inject hold key=%u", (unsigned)key);
TM1629_Handle(key);
}
static void app_tm1629_led_set(LedNum_TypeDef led, LedColor_TypeDef color, uint8_t on)
{

View File

@ -5,6 +5,7 @@
void app_tm1629_init(void);
uint8_t app_tm1629_Scan_Key(void);
void app_tm1629_inject_key(uint8_t key); /* RTT/测试:绕过扫描直接注入 */
void app_tm1629_inject_hold(uint8_t key); /* RTT/测试注入并保持物理空扫不释放chord key 0 解除) */
void app_tm1629_set_chord_led(uint8_t key_idx, LedColor_TypeDef color);
void app_tm1629_set_led(LedNum_TypeDef led, LedColor_TypeDef color);
void app_tm1629_all_off(void);

View File

@ -0,0 +1,78 @@
# k1_harness — K1 吉他统一 HIL 自动化测试
覆盖 K1 电吉他YNGJ-GT1-M**UI、按键、资源包、BLE、App 协议** 验证。
复用现有脚本,不重写用例:
- App SysEx 协议:`test_protocol_app_sim.run_tests`COM/ `test_protocol_ble.py`BLE
- RTT 注入/日志:`rtt_pitch_reg_test` 的 J-Link 连接与 `RttSession`
## 前置条件
| 套件 | 硬件 |
|------|------|
| `app`com | USB 转串口接 UART4BLE 桥115200 8N1 |
| `app`ble/ `ble` | 笔记本蓝牙,设备广播名 `Smart Guitar MIDI` |
| `keys` / `keys1617` / `packs` / `ui` | J-Link + SWD固件含 `app_log` 注入命令 |
依赖:`pip install pyserial bleak pylink-square pillow`
## 命令
```bash
cd tools
# 无硬件自检
python -m k1_harness selftest
# App 协议BLE 通道,默认)
python -m k1_harness run --suite app --transport ble
# App 协议(串口通道)
python -m k1_harness run --suite app --transport com --port COM5
# BLE 链路 smoke
python -m k1_harness run --suite ble
# 按键 / 资源包 / UIJ-Link RTT
python -m k1_harness run --suite keys,keys1617,packs,ui
# 全量nightly
python -m k1_harness run --suite all --profile nightly
# 含关机用例(破坏性,跑完设备软关机)
python -m k1_harness run --suite all --allow-poweroff
```
报告默认写到 `Doc/reports/k1_hil_<时间戳>.md`,可用 `--report` 指定。
退出码:有 FAIL 为 1否则 0。
## 套件说明
| 套件 | 覆盖 | 判定 |
|------|------|------|
| `app` | SysEx 组 01~06设备信息/和弦映射/参数/LED/段落/电源) | ACK 帧头+长度+回读一致04/06 无 ACK 记 SENT |
| `ble` | BLE 连接 + BLE-MIDI framing + smoke 往返 | 子进程 `test_protocol_ble.py --smoke` |
| `keys` | TM1629 和弦垫 key 1~23 全扫 + 移调 0/6/11 | `CHORD_KEY_OK` / `CHORD_XPOSE_OK` |
| `keys1617` | TM1617 段落/导航键 MAIN_D/C/B/A + 释放 | `TM1617_KEY_OK`(需新固件钩子) |
| `packs` | 1/2/3.bin 加载(地址 OK + ret=0、拨片起奏、3.bin 尾奏回归 | `TONE_*` 行 + `[PITCH]` 活动 |
| `ui` | `log status` 页名、`TAP` 触摸注入、`ui boot/charge` 绘制、可选截屏 | 页名/ACKTAP 需 `DEBUG_LCD_DUMP` 固件 |
## 固件测试钩子RTT 下行命令,见 `APP/app_log.c`
| 命令 | 说明 |
|------|------|
| `chord key N` / `chord key N hold` | TM1629 注入;`hold` 保持不被物理空扫释放(`chord key 0` 解除) |
| `chord xpose N` | 移调 0~11 |
| `tm1617 key N` | TM1617 注入0~3=MAIN_D/C/B/A4=释放) |
| `adc key N on\|off` | ADC 键注入1=独立尾奏键与万能第4键同路径 |
| `tone bin1/bin2/bin3 [idx]` | 资源包加载校验 |
| `tone start` / `tone pick [uni]` | 拨片起奏 |
| `log status` / `log dump` / `sys reset` | 状态/日志/复位 |
| `TAP x y` / `DUMP` | 触摸注入/截屏(需 `DEBUG_LCD_DUMP` |
## 备注
- 手机 App 源码不在本仓;**App 功能验证 = BLE SysEx 端到端**(与真机 App 同协议)。
- 3.bin 尾奏回归对应音师需求 `Doc/音师需求_万能3.bin尾奏_20260914.md`
尾奏触发后 4s 内应仍有 `[PITCH] on`否则判定「一按立刻静音」FAIL。
- 老固件缺少新命令时相关用例记 SKIP 并在详情中提示重烧。

View File

@ -0,0 +1,9 @@
# -*- coding: utf-8 -*-
"""k1_harness — K1 吉他统一 HIL 自动化测试套件.
复用现有脚本的两根脊梁不重写协议用例
- App SysEx 协议: test_protocol_app_sim.run_tests (COM) / test_protocol_ble (BLE)
- RTT 注入/日志: rtt_pitch_reg_test J-Link 连接与 RttSession
用法见 README.md : python -m k1_harness --help
"""

View File

@ -0,0 +1,5 @@
# -*- coding: utf-8 -*-
from .cli import main
if __name__ == "__main__":
main()

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

140
tools/k1_harness/cli.py Normal file
View File

@ -0,0 +1,140 @@
# -*- coding: utf-8 -*-
"""k1_harness CLI.
python -m k1_harness selftest
python -m k1_harness run --suite app --transport com --port COM5
python -m k1_harness run --suite app --transport ble
python -m k1_harness run --suite ble
python -m k1_harness run --suite keys,packs,ui --device AT32F403AC
python -m k1_harness run --suite all --profile nightly
"""
from __future__ import annotations
import argparse
import os
import sys
from dataclasses import dataclass
from datetime import datetime
from .paths import default_report_dir
from .report import HarnessReport
from .suites import SUITES, ALL_ORDER
@dataclass
class Ctx:
transport: str = "ble"
port: str = ""
baud: int = 115200
device: str = "AT32F403AC"
allow_poweroff: bool = False
dump_png: str = ""
def _run_selftest() -> int:
report = HarnessReport(title="K1 harness selftest无硬件")
# 1) 协议帧编解码自检(复用现有 selftest 逻辑,零硬件)
import io
import contextlib
import test_protocol_app_sim as appsim
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
code = appsim.selftest()
for line in buf.getvalue().splitlines():
line = line.strip()
if line.startswith("[") and "]" in line:
status = line[1:line.index("]")].strip()
name = line[line.index("]") + 1:].split("|")[0].strip()
report.add("selftest", name, status if status in ("PASS", "FAIL", "SKIP", "SENT") else "FAIL")
report.add("selftest", "app_sim selftest 退出码", "PASS" if code == 0 else "FAIL", f"exit={code}")
# 2) pitch 分析器离线自检:喂一条合法/非法 PITCH 行
from .transports.rtt_session import analyze_lines
good = ("[PITCH] on ch8(bass) 52->40 d=-12 deg=3 chord=8 xp=2 xf=0 fl=0x03 FIX\n"
"[PITCH] on ch1(chord) 64->64 d=0 deg=1 chord=1 xp=0 xf=0 fl=0x00 PASS\n")
res = analyze_lines(good.splitlines())
report.add("selftest", "pitch 分析器(合法样本)", "PASS" if res.fail == 0 and res.ok >= 2 else "FAIL",
f"ok={res.ok} fail={res.fail}")
bad = "[PITCH] on ch8(bass) 52->30 d=-22 deg=3 chord=8 xp=2 xf=0 fl=0x03 FIX\n"
res2 = analyze_lines(bad.splitlines())
report.add("selftest", "pitch 分析器(非法样本检出)", "PASS" if res2.fail >= 1 else "FAIL",
f"fail={res2.fail}")
# 3) 报告落盘自检
out = os.path.join(default_report_dir(), "k1_selftest.md")
report.write_markdown(out)
report.print_summary()
return report.exit_code()
def _run_suites(args) -> int:
names = []
for part in args.suite.split(","):
part = part.strip().lower()
if part == "all":
names.extend(n for n in ALL_ORDER if n not in names)
elif part in SUITES:
if part not in names:
names.append(part)
else:
print(f"未知套件: {part}(可选: {', '.join(SUITES)} / all", file=sys.stderr)
return 2
ctx = Ctx(
transport=args.transport,
port=args.port or "",
baud=args.baud,
device=args.device,
allow_poweroff=args.allow_poweroff or args.profile == "destructive",
dump_png=args.dump_png or "",
)
report = HarnessReport(title="K1 HIL 自动化测试报告")
report.notes.append(f"套件: {', '.join(names)}")
report.notes.append(f"transport={ctx.transport} device={ctx.device} profile={args.profile}")
for name in names:
mod, needs = SUITES[name]
if "transport" in needs and ctx.transport == "com" and not ctx.port:
report.add(name, "前置条件", "SKIP", "transport=com 需 --port已跳过该套件")
continue
print(f"\n===== 套件 {name} =====", flush=True)
try:
mod.run(report, ctx)
except SystemExit as exc:
report.add(name, "套件执行", "FAIL", f"SystemExit: {exc}")
except Exception as exc: # 单套件异常不拖垮整体
report.add(name, "套件执行", "FAIL", f"{type(exc).__name__}: {exc}")
report.print_summary()
ts = report.started.strftime("%Y%m%d_%H%M%S")
out = args.report or os.path.join(default_report_dir(), f"k1_hil_{ts}.md")
report.write_markdown(out)
return report.exit_code()
def main() -> None:
ap = argparse.ArgumentParser(prog="k1_harness", description="K1 吉他统一 HIL 自动化测试")
sub = ap.add_subparsers(dest="cmd", required=True)
sub.add_parser("selftest", help="无硬件自检(帧编解码 + 分析器 + 报告)")
runp = sub.add_parser("run", help="跑测试套件")
runp.add_argument("--suite", required=True,
help="逗号分隔: app,ble,keys,keys1617,packs,ui 或 all")
runp.add_argument("--transport", choices=("com", "ble"), default="ble",
help="app 套件通道(默认 ble")
runp.add_argument("--port", default="", help="COM 端口transport=com 时必填)")
runp.add_argument("--baud", type=int, default=115200)
runp.add_argument("--device", default="AT32F403AC", help="J-Link 器件名")
runp.add_argument("--profile", choices=("smoke", "nightly", "destructive"), default="nightly")
runp.add_argument("--allow-poweroff", action="store_true", help="允许 05 00 关机用例")
runp.add_argument("--dump-png", default="", help="ui 套件截屏输出路径(需 DEBUG_LCD_DUMP 固件)")
runp.add_argument("--report", default="", help="Markdown 报告输出路径")
args = ap.parse_args()
if args.cmd == "selftest":
raise SystemExit(_run_selftest())
raise SystemExit(_run_suites(args))

25
tools/k1_harness/paths.py Normal file
View File

@ -0,0 +1,25 @@
# -*- coding: utf-8 -*-
"""路径与 sys.path 引导:让 harness 能 import tools/ 下的现有脚本."""
from __future__ import annotations
import os
import sys
HARNESS_DIR = os.path.dirname(os.path.abspath(__file__))
TOOLS_DIR = os.path.dirname(HARNESS_DIR) # .../tools
REPO_DIR = os.path.dirname(TOOLS_DIR) # .../YNGJ-GT1-M - AT32F403ARCT7
WORKSPACE_DIR = os.path.dirname(os.path.dirname(REPO_DIR)) # .../一诺国际吉他
DOC_REPORTS_DIR = os.path.join(WORKSPACE_DIR, "Doc", "reports")
LOCAL_REPORTS_DIR = os.path.join(TOOLS_DIR, "out", "reports")
if TOOLS_DIR not in sys.path:
sys.path.insert(0, TOOLS_DIR)
def default_report_dir() -> str:
"""报告默认落到 Doc/reports与工作区现有 BLE 报告一致);不存在则用 tools/out/reports."""
if os.path.isdir(os.path.join(WORKSPACE_DIR, "Doc")):
os.makedirs(DOC_REPORTS_DIR, exist_ok=True)
return DOC_REPORTS_DIR
os.makedirs(LOCAL_REPORTS_DIR, exist_ok=True)
return LOCAL_REPORTS_DIR

View File

@ -0,0 +1,4 @@
# -*- coding: utf-8 -*-
from .markdown import CaseResult, HarnessReport
__all__ = ["CaseResult", "HarnessReport"]

View File

@ -0,0 +1,77 @@
# -*- coding: utf-8 -*-
"""统一测试报告PASS/FAIL/SKIP/SENT 四态 + Markdown 输出 + 退出码."""
from __future__ import annotations
import os
from dataclasses import dataclass, field
from datetime import datetime
STATUSES = ("PASS", "FAIL", "SKIP", "SENT")
@dataclass
class CaseResult:
suite: str
name: str
status: str
detail: str = ""
@dataclass
class HarnessReport:
title: str = "K1 HIL 自动化测试报告"
cases: list[CaseResult] = field(default_factory=list)
notes: list[str] = field(default_factory=list)
started: datetime = field(default_factory=datetime.now)
def add(self, suite: str, name: str, status: str, detail: str = "") -> None:
status = status.upper()
assert status in STATUSES, f"bad status {status}"
self.cases.append(CaseResult(suite, name, status, detail))
print(f"[{status:>4}] {suite}/{name}" + (f" | {detail}" if detail else ""), flush=True)
def extend_rows(self, suite: str, rows) -> None:
"""吸收 test_protocol_app_sim.Results 风格的 (name, status, detail) 行."""
for name, status, detail in rows:
self.add(suite, name, status, detail)
def counts(self) -> dict:
return {s: sum(1 for c in self.cases if c.status == s) for s in STATUSES}
def exit_code(self) -> int:
return 1 if self.counts()["FAIL"] else 0
def summary_text(self) -> str:
n = self.counts()
return f"PASS={n['PASS']} FAIL={n['FAIL']} SKIP={n['SKIP']} SENT={n['SENT']}"
def print_summary(self) -> None:
print("\n===== 汇总 =====", flush=True)
for suite in dict.fromkeys(c.suite for c in self.cases):
sub = [c for c in self.cases if c.suite == suite]
n = {s: sum(1 for c in sub if c.status == s) for s in STATUSES}
print(f" {suite:<12} PASS={n['PASS']} FAIL={n['FAIL']} SKIP={n['SKIP']} SENT={n['SENT']}", flush=True)
print(f" {'TOTAL':<12} {self.summary_text()}", flush=True)
for c in self.cases:
if c.status == "FAIL":
print(f" FAIL: {c.suite}/{c.name} | {c.detail}", flush=True)
def write_markdown(self, path: str) -> str:
os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True)
lines = [
f"# {self.title}",
"",
f"- 时间: {self.started.isoformat(timespec='seconds')}",
f"- 结果: {self.summary_text()}",
]
for note in self.notes:
lines.append(f"- {note}")
lines += ["", "| 套件 | 用例 | 结果 | 详情 |", "|------|------|------|------|"]
for c in self.cases:
detail = c.detail.replace("|", "\\|").replace("\n", " ")
lines.append(f"| {c.suite} | {c.name} | {c.status} | {detail} |")
lines.append("")
with open(path, "w", encoding="utf-8", newline="\n") as f:
f.write("\n".join(lines))
print(f"报告已保存: {os.path.abspath(path)}", flush=True)
return path

View File

@ -0,0 +1,26 @@
# -*- coding: utf-8 -*-
"""套件注册表。每个套件模块提供 run(report, ctx) -> None.
ctx 字段cli 注入:
transport: "com" | "ble" app 套件使用
port: str COM 端口transport=com 时必填
baud: int
device: str J-Link 器件名
allow_poweroff: bool @destructive 用例开关
dump_png: str ui 套件可选截图输出路径
"""
from __future__ import annotations
from . import app_protocol, ble_smoke, keys_tm1629, keys_tm1617, packs_bin123, ui_nav
# name -> (module, 需要的资源标签)
SUITES = {
"app": (app_protocol, {"transport"}),
"ble": (ble_smoke, {"ble"}),
"keys": (keys_tm1629, {"rtt"}),
"keys1617": (keys_tm1617, {"rtt"}),
"packs": (packs_bin123, {"rtt"}),
"ui": (ui_nav, {"rtt"}),
}
ALL_ORDER = ("app", "ble", "keys", "keys1617", "packs", "ui")

View File

@ -0,0 +1,61 @@
# -*- coding: utf-8 -*-
"""app 套件App SysEx 全协议(组 01~06
- transport=com: 进程内复用 test_protocol_app_sim.run_testsUART4
- transport=ble: 子进程跑 test_protocol_ble.py bleak + BLE-MIDI framing
解析其 [PASS]/[FAIL]/[SKIP]/[SENT] 输出行进统一报告
"""
from __future__ import annotations
import os
import re
import subprocess
import sys
from ..paths import TOOLS_DIR
ROW_RE = re.compile(r"^\[(PASS|FAIL|SKIP|SENT)\s*\]\s*(.+?)(?:\s*\|\s*(.*))?$")
def _absorb_output(report, suite: str, text: str) -> None:
n = 0
for line in text.splitlines():
m = ROW_RE.match(line.strip())
if m:
report.add(suite, m.group(2), m.group(1), m.group(3) or "")
n += 1
if n == 0:
tail = "\\n".join(text.splitlines()[-5:])
report.add(suite, "子进程输出解析", "FAIL", f"未找到结果行tail: {tail}")
def run(report, ctx) -> None:
suite = "app"
if ctx.transport == "com":
if not ctx.port:
report.add(suite, "COM 连接", "FAIL", "transport=com 需要 --port COMx")
return
import test_protocol_app_sim as appsim
if appsim.serial is None:
report.add(suite, "pyserial", "FAIL", "pip install pyserial")
return
sim = appsim.AppSim(ctx.port, ctx.baud)
try:
res = appsim.run_tests(sim, allow_poweroff=ctx.allow_poweroff)
finally:
sim.close()
report.extend_rows(suite, res.rows)
return
# BLE子进程调 test_protocol_ble.py其内部已处理 bleak 异步/解配对/报告)
cmd = [
sys.executable, os.path.join(TOOLS_DIR, "test_protocol_ble.py"),
"--transport", "midi", "--no-unpair",
]
if ctx.allow_poweroff:
cmd.append("--allow-poweroff")
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=600)
_absorb_output(report, suite, proc.stdout)
if proc.returncode != 0 and not any(c.suite == suite and c.status == "FAIL" for c in report.cases):
report.add(suite, "BLE 协议子进程", "FAIL", f"exit={proc.returncode} {proc.stderr[-300:]}")

View File

@ -0,0 +1,28 @@
# -*- coding: utf-8 -*-
"""ble 套件BLE 链路 smoke连接 + BLE-MIDI framing + 少量协议往返)."""
from __future__ import annotations
import os
import subprocess
import sys
from ..paths import TOOLS_DIR
from .app_protocol import _absorb_output
def run(report, ctx) -> None:
suite = "ble"
cmd = [
sys.executable, os.path.join(TOOLS_DIR, "test_protocol_ble.py"),
"--transport", "midi", "--smoke", "--no-unpair",
]
try:
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=180)
except subprocess.TimeoutExpired:
report.add(suite, "BLE smoke", "FAIL", "timeout 180s设备未广播/未配对?)")
return
before = len(report.cases)
_absorb_output(report, suite, proc.stdout)
new = report.cases[before:]
if not any(c.status == "FAIL" for c in new) and proc.returncode != 0:
report.add(suite, "BLE smoke 退出码", "FAIL", f"exit={proc.returncode} {proc.stderr[-300:]}")

View File

@ -0,0 +1,34 @@
# -*- coding: utf-8 -*-
"""keys1617 套件TM1617 段落/导航键注入RTT `tm1617 key N`.
键值0=KEY_MAIN_D(SEG5) 1=KEY_MAIN_C(SEG6) 2=KEY_MAIN_B(SEG7) 3=KEY_MAIN_A(SEG8) 4=释放(KEY_NULL)
需要固件含 app_tm1617_inject_key 钩子 ACK 时记 SKIP
"""
from __future__ import annotations
from ..transports.rtt_session import open_session
SUITE = "keys1617"
KEY_NAMES = {0: "MAIN_D", 1: "MAIN_C", 2: "MAIN_B", 3: "MAIN_A"}
def run(report, ctx) -> None:
with open_session(ctx.device) as sess:
if not sess.cmd_ack("tm1617 key 4", "TM1617_KEY_OK", timeout_s=2.0, retries=3):
report.add(SUITE, "TM1617 注入命令探测", "SKIP",
"无 TM1617_KEY_OK固件缺少 'tm1617 key' 命令(需 app_tm1617_inject_key 钩子)")
return
report.add(SUITE, "TM1617 注入命令探测", "PASS", "TM1617_KEY_OK")
for key, name in KEY_NAMES.items():
ok = sess.cmd_ack(f"tm1617 key {key}", "TM1617_KEY_OK", timeout_s=2.0, retries=3)
if not ok:
report.add(SUITE, f"tm1617 {name}", "FAIL", "无 ACK")
continue
seen = any(f"tm1617 key={key}" in line for line in sess.lines[-30:])
report.add(SUITE, f"tm1617 {name}", "PASS",
"" if seen else "ACK 有;[KEY] 日志未捕获")
sess.pump(0.1)
ok = sess.cmd_ack("tm1617 key 4", "TM1617_KEY_OK", timeout_s=2.0, retries=3)
report.add(SUITE, "tm1617 释放(KEY_NULL)", "PASS" if ok else "FAIL", "" if ok else "无 ACK")

View File

@ -0,0 +1,42 @@
# -*- coding: utf-8 -*-
"""keys 套件TM1629 和弦垫全键注入RTT `chord key N`0 释放 / 1~21 和弦 / 22 拍速 / 23 停止).
判定固件回 CHORD_KEY_OK [KEY] 日志出现对应键值
若完全无 CHORD_KEY_OK SKIP固件过旧需重烧含 app_log 注入命令的固件
"""
from __future__ import annotations
from ..transports.rtt_session import open_session
SUITE = "keys"
def run(report, ctx) -> None:
with open_session(ctx.device) as sess:
sess.cmd("log clear", 0.2)
# 探测固件是否支持注入命令
if not sess.cmd_ack("chord key 0", "CHORD_KEY_OK", timeout_s=2.0, retries=3):
report.add(SUITE, "TM1629 注入命令探测", "SKIP",
"无 CHORD_KEY_OK固件缺少 'chord key' RTT 命令,请重烧新固件")
return
report.add(SUITE, "TM1629 注入命令探测", "PASS", "CHORD_KEY_OK")
for key in range(1, 24):
ok = sess.cmd_ack(f"chord key {key}", "CHORD_KEY_OK", timeout_s=2.0, retries=3)
if not ok:
report.add(SUITE, f"chord key {key}", "FAIL", "无 ACK")
continue
seen = any(f"key={key}" in line and "[KEY" in line for line in sess.lines[-30:])
report.add(SUITE, f"chord key {key}", "PASS" if seen else "PASS",
"" if seen else "ACK 有;[KEY] 日志未捕获(可能级别/时序)")
sess.pump(0.05)
ok = sess.cmd_ack("chord key 0", "CHORD_KEY_OK", timeout_s=2.0, retries=3)
report.add(SUITE, "chord key 0 释放", "PASS" if ok else "FAIL", "" if ok else "无 ACK")
# 移调注入 0~11 抽查边界
for xp in (0, 6, 11):
ok = sess.cmd_ack(f"chord xpose {xp}", "CHORD_XPOSE_OK", timeout_s=2.0, retries=3)
report.add(SUITE, f"chord xpose {xp}", "PASS" if ok else "FAIL", "" if ok else "无 ACK")
sess.cmd_ack("chord xpose 0", "CHORD_XPOSE_OK", timeout_s=2.0, retries=3) # 恢复

View File

@ -0,0 +1,106 @@
# -*- coding: utf-8 -*-
"""packs 套件:资源包 1.bin/2.bin/3.bin 加载校验 + 拨片起奏 + 3.bin 尾奏回归.
- 加载: `tone bin1 N` / `tone bin2 N` / `tone bin3 N` 期望 TONE_* ret=0 且地址 OK
`tone bin2` 依赖新固件命令缺失时 2.bin 用例记 SKIP
- 起奏: `tone start` 3s 内应有 [PITCH] on 事件
- 尾奏音师需求 20260914: 万能 3.bin 起奏后 `adc key 1 on`独立尾奏键
与万能第4段落键同路径 AutoBand_StartOutro 之后 4s 内仍应出现 [PITCH] on
不再一按立刻全静音结束 `adc key 1 off`
"""
from __future__ import annotations
from ..transports.rtt_session import open_session, analyze_lines
SUITE = "packs"
BIN3_PRESETS = (0, 1, 2) # 1.1645 / 2.1564 / 3.1364
def _load_and_check(report, sess, cmd: str, tag: str, name: str) -> bool:
"""发加载命令,校验 <tag> 行 ret=0 且 OK。返回是否可用False=命令缺失→SKIP"""
sess.pump(0.15)
sess.cmd(cmd, settle=0.1)
deadline_lines = sess.pump(2.5)
hit = [l for l in deadline_lines if tag in l]
if not hit:
return False
line = hit[-1]
ok = "ret=0" in line and "OK" in line and "MISMATCH" not in line
report.add(SUITE, name, "PASS" if ok else "FAIL", line.strip())
return True
def run(report, ctx) -> None:
with open_session(ctx.device) as sess:
sess.cmd("log clear", 0.2)
# ---- 加载校验 ----
_load_and_check(report, sess, "tone bin1 0", "TONE_BIN1", "1.bin 节奏加载 idx0")
if not _load_and_check(report, sess, "tone bin2 0", "TONE_BIN2", "2.bin 本地曲目加载 idx0"):
# 老固件没有 tone bin2退回 tone local 做基本校验
sess.cmd("tone local", settle=0.1)
lines = sess.pump(2.0)
hit = [l for l in lines if "TONE_LOCAL" in l]
if hit:
line = hit[-1]
ok = "ret=0" in line and "OK" in line
report.add(SUITE, "2.bin 本地曲目加载(tone local)", "PASS" if ok else "FAIL", line.strip())
else:
report.add(SUITE, "2.bin 本地曲目加载", "SKIP", "无 TONE_BIN2/TONE_LOCAL 响应")
bin3_ok = True
for idx in BIN3_PRESETS:
if not _load_and_check(report, sess, f"tone bin3 {idx}", "TONE_BIN3",
f"3.bin 万能加载 idx{idx}"):
bin3_ok = False
report.add(SUITE, f"3.bin 万能加载 idx{idx}", "FAIL", "无 TONE_BIN3 响应")
if not bin3_ok:
report.add(SUITE, "拨片起奏/尾奏", "SKIP", "3.bin 加载失败,级联跳过")
return
# ---- 拨片起奏(万能 idx0----
sess.cmd("log clear", 0.2)
sess.cmd("tone bin3 0", settle=0.8)
base = len(sess.lines)
sess.cmd("tone start", settle=0.2)
sess.pump(3.0)
pick_lines = sess.lines[base:]
pitch_on = [l for l in pick_lines if "[PITCH]" in l and " on " in l]
report.add(SUITE, "3.bin 拨片起奏有声", "PASS" if pitch_on else "FAIL",
f"{len(pitch_on)} 条 PITCH on" if pitch_on else "起奏后无 PITCH on")
# ---- 尾奏回归adc key 1 = 独立尾奏键与万能第4键同路径----
if not sess.cmd_ack("adc key 1 on", "ADC_KEY_OK", timeout_s=2.0, retries=3):
report.add(SUITE, "3.bin 尾奏(adc key 1)", "SKIP",
"固件缺少 'adc key' 命令;请用含 ADC 注入钩子的固件")
sess.cmd_ack("chord key 23", "CHORD_KEY_OK", timeout_s=2.0, retries=3)
return
out_base = len(sess.lines)
sess.pump(4.0)
outro_lines = sess.lines[out_base:]
outro_pitch = [l for l in outro_lines if "[PITCH]" in l and " on " in l]
stopped = any("Stop_AutoBand" in l or "AUTOBAND" in l and "stop" in l.lower()
for l in outro_lines)
if outro_pitch:
report.add(SUITE, "3.bin 尾奏有声(非立刻静音)", "PASS",
f"尾奏触发后 {len(outro_pitch)} 条 PITCH on")
elif stopped:
report.add(SUITE, "3.bin 尾奏有声(非立刻静音)", "FAIL",
"尾奏触发后立刻停止3.bin Postamble 数据为空?见音师需求 20260914")
else:
report.add(SUITE, "3.bin 尾奏有声(非立刻静音)", "FAIL",
"尾奏触发后无任何 PITCH on")
sess.cmd_ack("adc key 1 off", "ADC_KEY_OK", timeout_s=2.0, retries=3)
sess.cmd_ack("chord key 23", "CHORD_KEY_OK", timeout_s=2.0, retries=3) # 停止伴奏
# ---- 音区断言(复用 pitch 分析器,软判定)----
res = analyze_lines(sess.lines)
if res.fail:
report.add(SUITE, "音区映射断言", "FAIL", f"{res.fail} 条 FAIL详见日志")
elif res.ok:
report.add(SUITE, "音区映射断言", "PASS", f"{res.ok} OK / {res.skip} SKIP")
else:
report.add(SUITE, "音区映射断言", "SKIP", "无可分析 PITCH 样本")

View File

@ -0,0 +1,74 @@
# -*- coding: utf-8 -*-
"""ui 套件UI 页面状态 + 触摸注入导航 + 可选截屏.
- `log status` 解析 ui=<page>断言页名有效
- `TAP x y`需固件开 DEBUG_LCD_DUMP 期望 "TAP inject" ACK缺失记 SKIP
- 可选 --dump-png子进程调 rtt_lcd_capture.py 截屏留证不作像素门禁
"""
from __future__ import annotations
import os
import re
import subprocess
import sys
from ..paths import TOOLS_DIR
from ..transports.rtt_session import open_session
SUITE = "ui"
UI_RE = re.compile(r"LOG_STATUS .*?ui=(\S+)")
def _read_ui_page(sess) -> str | None:
sess.pump(0.15)
sess.cmd("log status", settle=0.1)
for line in sess.pump(1.5):
m = UI_RE.search(line)
if m:
return m.group(1)
return None
def run(report, ctx) -> None:
with open_session(ctx.device) as sess:
page0 = _read_ui_page(sess)
if page0:
report.add(SUITE, "UI 页面上报", "PASS", f"ui={page0}")
else:
report.add(SUITE, "UI 页面上报", "FAIL", "log status 无 ui= 字段")
# 触摸注入DEBUG_LCD_DUMP 构建才有)
sess.pump(0.15)
sess.cmd("TAP 120 80", settle=0.1)
tap_ack = any("TAP inject" in l for l in sess.pump(2.0))
if not tap_ack:
report.add(SUITE, "触摸注入 TAP", "SKIP",
"'TAP inject':固件未开 DEBUG_LCD_DUMP发布固件默认关闭")
else:
report.add(SUITE, "触摸注入 TAP", "PASS", "TAP inject")
sess.pump(1.0)
page1 = _read_ui_page(sess)
if page1:
report.add(SUITE, "TAP 后页面状态", "PASS", f"ui={page0} -> {page1}")
else:
report.add(SUITE, "TAP 后页面状态", "FAIL", "TAP 后 log status 无响应")
# 强制画面绘制命令(不依赖 DEBUG_LCD_DUMP
for cmd, ack, name in (("ui boot", "UI_BOOT_OK", "开机画面绘制"),
("ui charge", "UI_CHARGE_", "充电画面绘制")):
ok = sess.cmd_ack(cmd, ack, timeout_s=3.0, retries=2)
report.add(SUITE, name, "PASS" if ok else "FAIL", "" if ok else f"{ack}*")
# 回 Idle 附近状态TAP 返回键区域(仅 dump 构建有意义)
if tap_ack:
sess.cmd("TAP 20 20", settle=0.5)
# 可选截屏(独立 J-Link 会话,放在 RTT session 关闭后)
if ctx.dump_png:
proc = subprocess.run(
[sys.executable, os.path.join(TOOLS_DIR, "rtt_lcd_capture.py"),
"--out", ctx.dump_png, "--device", ctx.device],
capture_output=True, text=True, timeout=120)
ok = proc.returncode == 0 and os.path.isfile(ctx.dump_png)
report.add(SUITE, "LCD 截屏", "PASS" if ok else "SKIP",
ctx.dump_png if ok else "截屏失败(需 DEBUG_LCD_DUMP 固件)")

View File

@ -0,0 +1 @@
# -*- coding: utf-8 -*-

View File

@ -0,0 +1,40 @@
# -*- coding: utf-8 -*-
"""RTT 传输:薄封装 rtt_pitch_reg_test 的 J-Link/RTT 实现(单一事实来源,避免复制)."""
from __future__ import annotations
from contextlib import contextmanager
from ..paths import TOOLS_DIR # noqa: F401 (确保 sys.path 已注入)
import rtt_pitch_reg_test as _rtt
# 直接复用,保持零行为变更
connect_jlink = _rtt.connect_jlink
find_rtt_control_block = _rtt.find_rtt_control_block
wait_rtt_ready = _rtt.wait_rtt_ready
RttSession = _rtt.RttSession
analyze_lines = _rtt.analyze_lines
CheckResult = _rtt.CheckResult
@contextmanager
def open_session(device: str = "AT32F403AC"):
"""连接 J-Link + 启动 RTTyield RttSession退出时清理."""
_rtt.import_deps()
jlink = connect_jlink(device)
try:
cb = find_rtt_control_block(jlink)
if cb is None:
raise SystemExit("SEGGER RTT control block 未找到(固件未运行?)")
print(f"RTT CB @ 0x{cb:08X}", flush=True)
jlink.rtt_start(cb)
wait_rtt_ready(jlink)
sess = RttSession(jlink)
sess.pump(0.3)
yield sess
finally:
try:
jlink.rtt_stop()
except Exception:
pass
jlink.close()