# -*- 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())