K1Guitar/tools/test_chord_map_fix.py

288 lines
12 KiB
Python

# -*- coding: utf-8 -*-
"""
test_chord_map_fix.py — 和弦映射 m7/M7 等读回编码修复的专项验证
背景:
固件读路径(handleReadRhythmMap / Return_Light_buff)曾把内部 type 与协议码对调:
m7(type2) 误报 0x02(M7), M7(type4) 误报 0x0A(m7),
ad9/sus2/sus4/m7-5 同样错位; 属七写只认 0x13 不认文档的 0x0D。
本脚本验证修复后的固件: 写->读 回环必须逐字节一致。
链路: 笔记本 BLE <-> "Smart Guitar MIDI" <-> 吉他 UART4
用法:
python test_chord_map_fix.py # 协议回环 + 交互按键上报验证
python test_chord_map_fix.py --skip-manual # 只跑协议回环(无需碰琴)
python test_chord_map_fix.py --transport uart # 自定义串口通道
报告: Doc/reports/chord_map_fix_<时间戳>.md
"""
from __future__ import annotations
import argparse
import datetime
import os
import sys
import time
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from test_protocol_app_sim import build, hexs, Results, HEAD, DEV_ID # noqa: E402
from test_protocol_ble import BleMidiSim # noqa: E402
# 协议码 -> (名称, 内部type) 依据 Doc/指令测试.docx 和弦指令映射表
CHORDS = [
(0x00, "M 大三"),
(0x08, "m 小三"),
(0x02, "M7 大七"),
(0x0D, "7 属七"),
(0x0A, "m7 小七"),
(0x1F, "su2 挂二"),
(0x1E, "sus4 挂四"),
(0x04, "ad9 增九"),
(0x0B, "m7-5 半减七"),
]
REPORTS_DIR = os.path.join(
os.path.dirname(os.path.abspath(__file__)), "..", "..", "..", "Doc", "reports"
)
def read_map(sim):
"""02 01 -> 42B (21 pitch + 21 chord) 或 None"""
r = sim.query(build(0x02, 0x01), timeout=3.0)
if r is None or len(r) != 47 or r[0] != HEAD or r[1] != 0x60 or r[2] != 0x02 or r[3] != 0x01:
return None
return bytes(r[4:-1])
def chord_of(m: bytes, key1: int) -> int:
"""key1: 1..21 -> 协议和弦码"""
return m[21 + key1 - 1]
def run(report: Results, sim: BleMidiSim, skip_manual: bool, auto: bool = False) -> None:
# ---- 0. 连接与版本 ----
r = sim.query(build(0x01, 0x01))
ok = r is not None and r[:3] == bytes([HEAD, 0x60, 0x01])
report.add("01 01 连接设备", "PASS" if ok else "FAIL", hexs(r))
if not ok:
report.add("后续用例", "SKIP", "连接失败, 级联跳过")
return
r = sim.query(build(0x01, 0x0C))
build_id = bytes(r[4:-1]).decode("ascii", "replace") if r and len(r) > 5 else "?"
report.add("01 0C 固件构建", "PASS" if r else "FAIL", build_id)
# ---- 1. 读原始映射表并保存(测试完恢复) ----
orig = read_map(sim)
report.add("02 01 读原始映射表", "PASS" if orig else "FAIL",
hexs(bytes([HEAD, 0x60, 0x02, 0x01]) + orig + bytes([0xF7])) if orig else "无应答")
if not orig:
report.add("后续用例", "SKIP", "读表失败, 级联跳过")
return
KEY = 1 # 用 1 号键(I 区上C)做逐码回环
try:
# ---- 2. 单键逐码写->读回环(核心回归: m7/M7/sus2/sus4/ad9/m7-5/属七) ----
for code, name in CHORDS:
sim.send(build(0x02, 0x03, KEY - 1, code))
time.sleep(0.15)
m = read_map(sim)
got = chord_of(m, KEY) if m else None
ok = got == code
report.add(
f"02 03 写 {name} (0x{code:02X}) 读回一致",
"PASS" if ok else "FAIL",
f"读回=0x{got:02X}" if got is not None else "读表失败",
)
# 属七旧码 0x13 兼容: 写入后应按文档码 0x0D 读回
sim.send(build(0x02, 0x03, KEY - 1, 0x13))
time.sleep(0.15)
m = read_map(sim)
got = chord_of(m, KEY) if m else None
report.add(
"02 03 写属七旧码 0x13 -> 读回 0x0D",
"PASS" if got == 0x0D else "FAIL",
f"读回=0x{got:02X}" if got is not None else "读表失败",
)
# ---- 3. 02 04 整表写入(含全部 9 种和弦码) -> 回读逐字节一致 ----
pitch = bytes([0x01] * 21)
codes = bytes(c for c, _ in CHORDS)
chord = bytes(codes[i % len(codes)] for i in range(21))
full = pitch + chord
r = sim.query(build(0x02, 0x04, *full), timeout=3.0)
ok = r is not None and len(r) == 47 and bytes(r[4:-1]) == full
report.add("02 04 整表写入(回读帧一致)", "PASS" if ok else "FAIL", hexs(r))
m = read_map(sim)
ok2 = m == full
report.add(
"02 04 写入后独立读回一致",
"PASS" if ok2 else "FAIL",
"42B 全等" if ok2 else f"diff at {[i for i in range(42) if m and m[i] != full[i]]}",
)
# ---- 4. F0 51 04 按键上报(Return_Light_buff)编码验证 ----
if skip_manual:
report.add("F0 51 04 按键上报编码", "SKIP", "--skip-manual")
elif auto:
run_auto(report, sim, orig)
else:
run_manual(report, sim, orig)
finally:
# ---- 5. 恢复原始映射表 ----
r = sim.query(build(0x02, 0x04, *orig), timeout=3.0)
m = read_map(sim)
ok = m == orig
report.add("恢复原始映射表", "PASS" if ok else "FAIL",
"已恢复" if ok else "恢复失败, 请用 App 重置")
def run_manual(report: Results, sim: BleMidiSim, orig: bytes) -> None:
"""交互验证 F0 51 04 按键上报编码; 结束后恢复原始映射表"""
try:
for code, name in ((0x02, "M7"), (0x0A, "m7")):
# 04 00: LED_L1 -> Led=0, 且把 2 号键(中间C) type 设为该和弦
sim.send(build(0x04, 0x00, 0x00, code))
time.sleep(0.2)
sim.drain()
print(f"\n>>> 下一轮验证 {name}: 5 秒后开始监听, 听到倒计时结束请【按住】I 区中间键(2号键) 并【拨弦】")
for i in range(5, 0, -1):
print(f" {i} ...")
time.sleep(1)
print(f">>> 请操作! 监听中 (30s) ...")
# 先抓任意设备上报帧做诊断, 再判定内容
f = sim.read_frame(timeout=30.0, want_dev=True)
if f is None:
report.add(f"F0 51 04 按键上报 {name} (期望 0x{code:02X})", "FAIL",
"30s 内无任何设备上报帧")
continue
ok = (
len(f) == 7
and f[1] == DEV_ID and f[2] == 0x04
and f[3] == 0x00 and f[5] == code
)
report.add(
f"F0 51 04 按键上报 {name} (期望 0x{code:02X})",
"PASS" if ok else "FAIL",
hexs(f),
)
finally:
sim.query(build(0x02, 0x04, *orig), timeout=3.0)
ok = read_map(sim) == orig
report.add("恢复原始映射表", "PASS" if ok else "FAIL",
"已恢复" if ok else "恢复失败, 请用 App 重置")
def run_auto(report: Results, sim: BleMidiSim, orig: bytes) -> None:
"""RTT 注入自动验证: 无需人工操作。
流程: RTT `tone pick` 进入专业模式播放态 -> BLE `04 00` 设 Led=0 并改 2 号键和弦
-> RTT `chord key 2` 注入指板按压(与物理按键同路径 TM1629_Handle)
-> BLE 捕获 F0 51 04 上报帧验证编码; RTT 日志确认和弦切换。
"""
try:
from k1_harness.transports.rtt_session import open_session
except Exception as e:
report.add("RTT 自动验证", "FAIL", f"无法加载 RTT 传输: {e}")
return
try:
with open_session(reset=False) as sess:
sess.cmd("tone pick", settle=1.0) # 专业+本地曲目, 起奏 I 和弦
sess.pump(2.0)
for code, name in ((0x02, "M7"), (0x0A, "m7")):
sim.send(build(0x04, 0x00, 0x00, code)) # Led=0, map[2]=该和弦
time.sleep(0.2)
sim.drain()
sess.cmd("chord key 2", settle=0.5) # 注入按 I 区中间键
f = sim.read_frame(timeout=5.0, want_dev=True)
ok = (
f is not None and len(f) == 7
and f[1] == DEV_ID and f[2] == 0x04
and f[3] == 0x00 and f[5] == code
)
report.add(
f"F0 51 04 注入按键上报 {name} (期望 0x{code:02X})",
"PASS" if ok else "FAIL",
hexs(f) if f else "5s 内无设备上报帧",
)
recent = [l for l in sess.lines[-30:] if "chord" in l or "pick" in l]
report.add("RTT 日志(和弦切换)", "PASS" if recent else "SKIP",
" / ".join(recent[-4:]) if recent else "无相关日志")
except SystemExit as e:
report.add("RTT 自动验证", "FAIL", str(e))
except Exception as e:
report.add("RTT 自动验证", "FAIL", f"{type(e).__name__}: {e}")
finally:
sim.query(build(0x02, 0x04, *orig), timeout=3.0)
ok = read_map(sim) == orig
report.add("恢复原始映射表", "PASS" if ok else "FAIL",
"已恢复" if ok else "恢复失败, 请用 App 重置")
def write_report(R: Results, info: dict, path: str) -> None:
n = {s: sum(1 for r in R.rows if r[1] == s) for s in ("PASS", "FAIL", "SKIP", "SENT")}
lines = [
"# 和弦映射修复验证报告",
"",
f"- 日期: {datetime.datetime.now():%Y-%m-%d %H:%M:%S}",
f"- 设备: {info.get('name')} ({info.get('address')})",
f"- 传输: {info.get('transport')} / bleak, payload={info.get('max_payload')}",
"- 测试脚本: tools/test_chord_map_fix.py",
"- 修复范围: handleReadRhythmMap / Return_Light_buff 读回编码; 属七 0x0D 兼容",
"",
"## 汇总",
"",
"| PASS | FAIL | SKIP |",
"|------|------|------|",
f"| {n['PASS']} | {n['FAIL']} | {n['SKIP']} |",
"",
"## 明细",
"",
"| # | 用例 | 结果 | 详情 |",
"|---|------|------|------|",
]
for i, (name, status, detail) in enumerate(R.rows, 1):
lines.append(f"| {i} | {name} | {status} | {str(detail).replace('|', '/')} |")
os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True)
with open(path, "w", encoding="utf-8") as fp:
fp.write("\n".join(lines) + "\n")
def main() -> int:
ap = argparse.ArgumentParser(description="和弦映射修复专项验证")
ap.add_argument("--transport", choices=["midi", "uart"], default="midi")
ap.add_argument("--skip-manual", action="store_true", help="跳过按键上报交互验证")
ap.add_argument("--manual-only", action="store_true", help="只跑按键上报交互验证")
ap.add_argument("--auto", action="store_true",
help="用 RTT(J-Link) 注入按键代替人工操作, 全自动")
ap.add_argument("--address", help="直接指定 BLE 地址")
args = ap.parse_args()
sim = BleMidiSim(transport=args.transport, address=args.address, unpair=False)
try:
if not sim.probe_or_rebind():
print("设备无应答, 终止")
return 1
R = Results()
if args.manual_only:
orig = read_map(sim)
if not orig:
print("读映射表失败, 终止")
return 1
(run_auto if args.auto else run_manual)(R, sim, orig)
else:
run(R, sim, args.skip_manual, auto=args.auto)
print(R.summary())
ts = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
path = os.path.join(REPORTS_DIR, f"chord_map_fix_{ts}.md")
write_report(R, sim.info, path)
print(f"报告: {os.path.abspath(path)}")
return 1 if any(r[1] == "FAIL" for r in R.rows) else 0
finally:
sim.close()
if __name__ == "__main__":
sys.exit(main())