#!/usr/bin/env python3 """K1 bass/chord register auto-test via J-Link RTT. Requires firmware with: - [PITCH]/[REG] logs (App_Auto.c) - RTT cmds: chord key N / chord xpose N / tone pick / log clear Modes: python tools/rtt_pitch_reg_test.py # inject + live assert python tools/rtt_pitch_reg_test.py --listen # you press keys; we assert python tools/rtt_pitch_reg_test.py --analyze oct_reg3.log python tools/rtt_pitch_reg_test.py --self-test # offline map rules (no J-Link) """ from __future__ import annotations import argparse import os import re import sys import time from dataclasses import dataclass, field from datetime import datetime from typing import Iterable DEFAULT_DEVICES = ("Cortex-M4", "AT32F403AC", "AT32F403A") MIDI_E1 = 28 MIDI_E2 = 40 MIDI_GSHARP4 = 68 XPOSE_FS = 6 BASS_CH_DISP = 8 # 代码/日志通道;BASS_CH=8 DRUM_CH_DISP = 9 PITCH_RE = re.compile( r"\[PITCH\]\s+(on|off)\s+ch(\d+)\((\w+)\)\s+" r"(\d+)->(\d+)\s+d=(-?\d+)\s+deg=(\d+)\s+chord=(\d+)\s+" r"xp=(\d+)\s+xf=(-?\d+)\s+fl=0x([0-9A-Fa-f]+)\s*(.*)$" ) REG_RE = re.compile( r"\[REG\s*\]\s+fp\s+(\d+)->(\d+)\s+chord=(\d+)\s+deg=(\d+)\s+xp=(\d+)\s+xf=(-?\d+)" ) @dataclass class PitchEvent: is_on: bool ch: int role: str key_in: int key_out: int delta: int deg: int chord: int xp: int xf: int flags: int tags: str raw: str @dataclass class CheckResult: ok: int = 0 fail: int = 0 skip: int = 0 messages: list[str] = field(default_factory=list) def add_ok(self, msg: str) -> None: self.ok += 1 self.messages.append(f"OK {msg}") def add_fail(self, msg: str) -> None: self.fail += 1 self.messages.append(f"FAIL {msg}") def add_skip(self, msg: str) -> None: self.skip += 1 self.messages.append(f"SKIP {msg}") def import_deps() -> None: try: import pylink # noqa: F401 except ImportError as exc: raise SystemExit("Missing dependency: pip install pylink-square") from exc def connect_jlink(device: str): import pylink jlink = pylink.JLink() jlink.open() try: jlink.exec_command("HideDeviceSelection = 1") except Exception: pass jlink.set_tif(pylink.enums.JLinkInterfaces.SWD) last_error = None for candidate in (device, *DEFAULT_DEVICES): try: try: jlink.exec_command(f"Device = {candidate}") except Exception: pass jlink.connect(candidate) print(f"Connected as {candidate}", flush=True) return jlink except pylink.errors.JLinkException as exc: last_error = exc jlink.close() raise SystemExit(f"Failed to connect J-Link: {last_error}") def find_rtt_control_block(jlink, ram_base: int = 0x20000000, ram_size: int = 0x18000) -> int | None: needle = b"SEGGER RTT" chunk = 0x1000 was_halted = jlink.halted() if not was_halted: jlink.halt() try: for off in range(0, ram_size, chunk): data = bytes(jlink.memory_read8(ram_base + off, min(chunk, ram_size - off))) idx = data.find(needle) if idx >= 0: return ram_base + off + idx finally: if not was_halted: if hasattr(jlink, "restart"): jlink.restart() else: jlink.go() time.sleep(0.1) return None def wait_rtt_ready(jlink, timeout_s: float = 15.0) -> None: deadline = time.time() + timeout_s while time.time() < deadline: try: if jlink.rtt_get_num_up_buffers() > 0: return except Exception: pass time.sleep(0.2) raise SystemExit("RTT control block found but buffers not ready") def send_command(jlink, payload: bytes, retries: int = 30) -> None: if isinstance(payload, str): payload = payload.encode("ascii") if not payload.endswith(b"\n"): payload += b"\n" for _ in range(retries): wrote = jlink.rtt_write(0, list(payload)) if wrote > 0: print(f">> {payload.decode('ascii', errors='replace').strip()}", flush=True) return time.sleep(0.2) raise SystemExit(f"Failed to send RTT cmd: {payload!r}") def drain_rtt(jlink, seconds: float = 0.15) -> str: buf = b"" deadline = time.time() + seconds while time.time() < deadline: chunk = jlink.rtt_read(0, 4096) if chunk: buf += bytes(chunk) else: time.sleep(0.01) return buf.decode("utf-8", errors="replace") def parse_pitch_line(line: str) -> PitchEvent | None: m = PITCH_RE.search(line) if not m: return None return PitchEvent( is_on=m.group(1) == "on", ch=int(m.group(2)), role=m.group(3), key_in=int(m.group(4)), key_out=int(m.group(5)), delta=int(m.group(6)), deg=int(m.group(7)), chord=int(m.group(8)), xp=int(m.group(9)), xf=int(m.group(10)), flags=int(m.group(11), 16), tags=m.group(12).strip(), raw=line.strip(), ) def chord_degree(chord: int) -> int: if chord < 1 or chord > 21: return 0 return (chord - 1) // 3 + 1 def need_register_fix(chord: int) -> bool: return 7 <= chord <= 21 def map_bass(key: int, xp: int, deg: int) -> tuple[int, int]: """Return (out, flags) mirroring App_Auto_MapBassNote. Xpose extra -12 stacks only from V (deg>=5); III/IV keep register -12 only. """ out = key - 12 flags = 0x01 while out < MIDI_E1: out += 12 flags |= 0x02 if xp >= XPOSE_FS and deg >= 5: out -= 12 flags |= 0x04 while out < MIDI_E1: out += 12 flags |= 0x02 return max(0, min(127, out)), flags def map_chord(key: int, xp: int, deg: int) -> tuple[int, int, bool]: """Return (out_or_clamp_hint, flags, need_near). When need_near is True, exact out is unknown without chord table; caller should only require out in [E2, #G4]. Xpose extra -12 stacks only from V (deg>=5); III/IV keep register -12 only. """ out = key - 12 flags = 0x01 while out < MIDI_E2: out += 12 flags |= 0x08 need_near = out > MIDI_GSHARP4 if need_near: flags |= 0x10 out = MIDI_GSHARP4 # placeholder; exact nearest checked soft if xp >= XPOSE_FS and deg >= 5: out -= 12 flags |= 0x04 while out < MIDI_E2: out += 12 flags |= 0x08 if out > MIDI_GSHARP4: flags |= 0x10 need_near = True out = MIDI_GSHARP4 return max(0, min(127, out)), flags, need_near def map_xpose_overall_bass(key: int) -> tuple[int, int]: """I/II + xp>=#F: overall -12 only (mirrors App_Auto_MapXposeOverall bass).""" out = key - 12 flags = 0x04 while out < MIDI_E1: out += 12 flags |= 0x02 return max(0, min(127, out)), flags def map_xpose_overall_chord(key: int) -> tuple[int, int, bool]: """I/II + xp>=#F: overall -12 only (mirrors App_Auto_MapXposeOverall chord).""" out = key - 12 flags = 0x04 while out < MIDI_E2: out += 12 flags |= 0x08 need_near = out > MIDI_GSHARP4 if need_near: flags |= 0x10 out = MIDI_GSHARP4 return max(0, min(127, out)), flags, need_near def expect_for_event(ev: PitchEvent) -> tuple[str, bool]: """Return (detail, ok).""" if not ev.is_on: return "note-off ignored", True if ev.delta != ev.key_out - ev.key_in: return f"d mismatch {ev.delta} != {ev.key_out - ev.key_in}", False deg_exp = chord_degree(ev.chord) if deg_exp and ev.deg != deg_exp: return f"deg {ev.deg} != expected {deg_exp}", False xf_exp = 1 if ev.xp >= XPOSE_FS else 0 if ev.xf != xf_exp: return f"xf {ev.xf} != expected {xf_exp} (xp={ev.xp})", False if ev.ch == DRUM_CH_DISP or ev.role == "drum": return "drum should not appear in PITCH", False fix = need_register_fix(ev.chord) if not fix: # I/II: C~F 原样;≥#F 表格整体-12 if ev.xp >= XPOSE_FS: if ev.ch == BASS_CH_DISP or ev.role == "bass": exp, exp_fl = map_xpose_overall_bass(ev.key_in) if ev.key_out != exp: return f"I/II xpose bass expect {ev.key_in}->{exp}, got {ev.key_out}", False if not (ev.flags & 0x04): return f"I/II xpose bass missing xf flag fl=0x{ev.flags:02X}", False return f"I/II xpose bass {ev.key_in}->{ev.key_out}", True exp, exp_fl, need_near = map_xpose_overall_chord(ev.key_in) if not (MIDI_E2 <= ev.key_out <= MIDI_GSHARP4): return f"I/II xpose chord out {ev.key_out} not in range", False if need_near: return f"I/II xpose chord NEAR {ev.key_in}->{ev.key_out}", True if ev.key_out != exp: return f"I/II xpose chord expect {ev.key_in}->{exp}, got {ev.key_out}", False return f"I/II xpose chord {ev.key_in}->{ev.key_out}", True if ev.key_out != ev.key_in or ev.delta != 0: return f"I/II should PASS {ev.key_in}->{ev.key_in}, got {ev.key_out}", False if "PASS" not in ev.tags and ev.flags != 0: # flags may be 0 on pass path pass return f"PASS I/II {ev.key_in}", True if ev.ch == BASS_CH_DISP or ev.role == "bass": exp, exp_fl = map_bass(ev.key_in, ev.xp, deg_exp) if ev.key_out != exp: return f"bass expect {ev.key_in}->{exp}, got {ev.key_out}", False if ev.key_out < MIDI_E1: return f"bass below E1: {ev.key_out}", False # flag bits that must be present if (exp_fl & 0x01) and not (ev.flags & 0x01): return f"bass missing -12 flag fl=0x{ev.flags:02X}", False if deg_exp in (3, 4) and ev.xp >= XPOSE_FS and (ev.flags & 0x04): return f"III/IV bass should not stack xpose fl=0x{ev.flags:02X}", False return f"bass {ev.key_in}->{ev.key_out}", True # chord path if deg_exp in (3, 4) and ev.xp >= XPOSE_FS and (ev.flags & 0x04): return f"III/IV chord should not stack xpose fl=0x{ev.flags:02X}", False exp, exp_fl, need_near = map_chord(ev.key_in, ev.xp, deg_exp) if not (MIDI_E2 <= ev.key_out <= MIDI_GSHARP4): return f"chord out {ev.key_out} not in [{MIDI_E2},{MIDI_GSHARP4}]", False if need_near: if not (ev.flags & 0x10) and "NEAR" not in ev.tags: # soft: range ok is enough if nearest not flagged but clamped somehow return f"chord NEAR expected (in={ev.key_in} out={ev.key_out})", True return f"chord NEAR {ev.key_in}->{ev.key_out}", True if ev.key_out != exp: return f"chord expect {ev.key_in}->{exp}, got {ev.key_out}", False return f"chord {ev.key_in}->{ev.key_out}", True def analyze_lines(lines: Iterable[str], result: CheckResult | None = None) -> CheckResult: result = result or CheckResult() pitch_n = 0 roles: set[str] = set() chords: set[int] = set() line_list = list(lines) for line in line_list: line = line.rstrip("\n") if "[REG" in line and "fp " in line: m = REG_RE.search(line) if m: chord = int(m.group(3)) deg = int(m.group(4)) xp = int(m.group(5)) xf = int(m.group(6)) deg_exp = chord_degree(chord) xf_exp = 1 if xp >= XPOSE_FS else 0 if deg_exp and deg != deg_exp: result.add_fail(f"REG deg {deg}!={deg_exp} chord={chord}") elif xf != xf_exp: result.add_fail(f"REG xf {xf}!={xf_exp} xp={xp}") else: result.add_ok(f"REG fp chord={chord} deg={deg} xp={xp} xf={xf}") ev = parse_pitch_line(line) if not ev: continue pitch_n += 1 roles.add(ev.role) chords.add(ev.chord) detail, ok = expect_for_event(ev) msg = f"ch{ev.ch}({ev.role}) chord={ev.chord} {ev.key_in}->{ev.key_out} | {detail}" if ok: result.add_ok(msg) else: result.add_fail(msg) if pitch_n == 0: result.add_skip("no [PITCH] lines found") else: if "bass" not in roles and not any( f"ch{BASS_CH_DISP}(" in m for m in result.messages ): result.add_skip(f"no bass PITCH on ch{BASS_CH_DISP} (style may omit bass, or old FW)") if not any(need_register_fix(c) for c in chords): result.add_skip("no III+ chord keys observed") if not any(not need_register_fix(c) and 1 <= c <= 6 for c in chords): result.add_skip("no I/II chord keys observed") check_iii_xpose_octave(line_list, result) return result def check_iii_xpose_octave(lines: Iterable[str], result: CheckResult) -> None: """III + transpose>=#F: inputs an octave apart must stay an octave apart.""" by_pc: dict[int, dict[int, int]] = {} saw = False for line in lines: ev = parse_pitch_line(line.rstrip("\n")) if not ev or not ev.is_on or ev.role != "chord": continue if ev.deg != 3 or ev.xp < XPOSE_FS: continue saw = True by_pc.setdefault(ev.key_in % 12, {})[ev.key_in] = ev.key_out if not saw: result.add_skip("no III+xpose chord PITCH for octave check") return found_pair = False for pc, inout in by_pc.items(): ins = sorted(inout) for i, a in enumerate(ins): for b in ins[i + 1 :]: if b - a != 12: continue # Lower note below E2+12 hits the E2 floor after register -12; # that is expected, not the G-III B3/B4→B2/B2 stack bug. if a < MIDI_E2 + 12: continue found_pair = True oa, ob = inout[a], inout[b] if ob - oa != 12: result.add_fail( f"III xpose octave collapsed pc={pc} {a}->{oa} vs {b}->{ob}" ) else: result.add_ok(f"III xpose octave {a}/{b} -> {oa}/{ob}") if not found_pair: result.add_skip("III+xpose present but no octave-apart input pair") def print_report(result: CheckResult, out_path: str | None = None) -> int: print("\n======== PITCH REG TEST REPORT ========", flush=True) for msg in result.messages: # only print fails + summary skips loudly; OK compacted if msg.startswith("FAIL") or msg.startswith("SKIP"): print(msg, flush=True) ok_short = [m for m in result.messages if m.startswith("OK")] print(f"OK={result.ok} FAIL={result.fail} SKIP={result.skip}", flush=True) if ok_short: print(f"(first OK samples: {len(ok_short)} total)", flush=True) for m in ok_short[:8]: print(m, flush=True) if len(ok_short) > 8: print(f"... +{len(ok_short) - 8} more OK", flush=True) if out_path: with open(out_path, "w", encoding="utf-8", newline="\n") as f: f.write(f"# pitch reg report {datetime.now().isoformat(timespec='seconds')}\n") f.write(f"OK={result.ok} FAIL={result.fail} SKIP={result.skip}\n") for m in result.messages: f.write(m + "\n") print(f"Report saved: {os.path.abspath(out_path)}", flush=True) return 0 if result.fail == 0 and result.ok > 0 else 1 class RttSession: def __init__(self, jlink): self.jlink = jlink self.buf = b"" self.lines: list[str] = [] def cmd(self, text: str, settle: float = 0.2) -> None: send_command(self.jlink, text.encode("ascii")) self.pump(settle) def pump(self, seconds: float) -> list[str]: new_lines: list[str] = [] deadline = time.time() + seconds while time.time() < deadline: chunk = self.jlink.rtt_read(0, 4096) if chunk: self.buf += bytes(chunk) while b"\n" in self.buf: raw, self.buf = self.buf.split(b"\n", 1) line = raw.decode("utf-8", errors="replace").rstrip("\r") if line: self.lines.append(line) new_lines.append(line) if ( "[PITCH]" in line or "[REG" in line or "[KEY" in line or "CHORD_" in line or "TONE_" in line or "TM1617_" in line or "ADC_KEY_" in line or "LOG_STATUS" in line or "TAP inject" in line or "UI_" in line ): print(line, flush=True) else: time.sleep(0.01) return new_lines def cmd_ack(self, text: str, ack: str, timeout_s: float = 3.0, retries: int = 6) -> bool: """Send command until ack substring appears (handles RTT down loss under load).""" for attempt in range(1, retries + 1): # brief quiet drain then send self.pump(0.15) send_command(self.jlink, text.encode("ascii")) deadline = time.time() + timeout_s while time.time() < deadline: for line in self.pump(0.1): if ack in line: return True print(f"!! no ack '{ack}' for '{text}' (try {attempt}/{retries})", flush=True) time.sleep(0.25) return False def stop_band(self) -> None: self.cmd_ack("chord key 23", "CHORD_KEY_OK", timeout_s=2.0, retries=4) self.pump(0.4) def set_xpose(self, xp: int) -> bool: return self.cmd_ack(f"chord xpose {xp}", "CHORD_XPOSE_OK", timeout_s=2.0, retries=5) def set_key(self, key: int) -> bool: return self.cmd_ack(f"chord key {key}", "CHORD_KEY_OK", timeout_s=2.0, retries=5) def phase_switch(self, name: str, key: int, xpose: int | None, hold_s: float) -> None: """Switch chord/xpose while accompaniment is already running. Note: plain `tone pick` clears KEY_ID to 1 — do not call it after inject. """ print(f"\n=== {name}: key={key} xpose={xpose} ===", flush=True) if xpose is not None: if not self.set_xpose(xpose): print(f"FAIL setup xpose={xpose}", flush=True) self.set_key(0) if not self.set_key(key): print(f"FAIL setup key={key}", flush=True) self.pump(hold_s) def run_auto(device: str, hold_s: float, report: str | None, log_path: str | None) -> int: import_deps() jlink = connect_jlink(device) try: cb = find_rtt_control_block(jlink) if cb is None: raise SystemExit("SEGGER RTT CB not found") print(f"RTT CB @ 0x{cb:08X}", flush=True) jlink.rtt_start(cb) wait_rtt_ready(jlink) sess = RttSession(jlink) sess.pump(0.3) sess.cmd("log clear", 0.3) sess.cmd("tone local", 1.0) # Start band first (pick forces chord=1), then inject keys while playing if not sess.set_xpose(2): print("FAIL initial xpose=2", flush=True) sess.cmd("tone pick", settle=0.8) print("\n=== I (default after pick) ===", flush=True) sess.pump(hold_s) sess.phase_switch("I/II key2 PASS", key=2, xpose=None, hold_s=hold_s) sess.phase_switch("I/II key2 xpose=#F overall-12", key=2, xpose=6, hold_s=hold_s) sess.phase_switch("I/II key2 xpose=F restore", key=2, xpose=2, hold_s=hold_s) sess.phase_switch("III key8 -12", key=8, xpose=None, hold_s=hold_s) sess.phase_switch("III key8 xpose=#F no stack", key=8, xpose=6, hold_s=hold_s) sess.phase_switch("III key8 xpose=F restore", key=8, xpose=2, hold_s=hold_s) sess.phase_switch("VII key20 -12", key=20, xpose=None, hold_s=hold_s) sess.phase_switch("VII key20 xpose=#F", key=20, xpose=6, hold_s=hold_s) print("\n=== stop ===", flush=True) sess.stop_band() sess.pump(0.4) if log_path: with open(log_path, "w", encoding="utf-8", newline="\n") as f: f.write(f"# auto pitch test {datetime.now().isoformat(timespec='seconds')}\n") for line in sess.lines: f.write(line + "\n") print(f"Log saved: {os.path.abspath(log_path)}", flush=True) joined = "\n".join(sess.lines) if "CHORD_KEY_OK" not in joined and "CHORD_KEY_BAD" not in joined: print( "WARNING: no CHORD_KEY_OK — firmware may lack 'chord key' RTT cmd. " "Rebuild/flash, or use --listen / --analyze.", flush=True, ) result = analyze_lines(sess.lines) has_pass = any("PASS I/II" in m for m in result.messages) has_iii_raw = any( "[PITCH]" in line and ("chord=8" in line or "chord=20" in line or "deg=3" in line or "deg=7" in line) for line in sess.lines ) has_iii_ok = any( m.startswith("OK") and "PASS" not in m and "(chord)" in m for m in result.messages ) has_xf = any("[PITCH]" in line and "xf=1" in line and "deg=7" in line for line in sess.lines) has_iii_xf = any( "[PITCH]" in line and "xf=1" in line and "deg=3" in line for line in sess.lines ) if not has_pass: result.add_fail("coverage: no I/II PASS samples") if not has_iii_raw: result.add_fail("coverage: no III+/VII chord in PITCH") elif not has_iii_ok: result.add_fail("coverage: III+ present but mapping asserts failed") if not has_iii_xf: result.add_fail("coverage: no III+xpose=#F samples") if not has_xf: result.add_skip("coverage: no VII+xf=1 samples") return print_report(result, report) finally: try: jlink.rtt_stop() except Exception: pass jlink.close() def run_listen(device: str, seconds: float, report: str | None, log_path: str | None) -> int: import_deps() jlink = connect_jlink(device) try: cb = find_rtt_control_block(jlink) if cb is None: raise SystemExit("SEGGER RTT CB not found") print(f"RTT CB @ 0x{cb:08X}", flush=True) jlink.rtt_start(cb) wait_rtt_ready(jlink) sess = RttSession(jlink) sess.pump(0.2) sess.cmd("log clear", 0.2) print( f"Listening {seconds:.0f}s — press I/II then III+ chords; set transpose>=#F if possible.", flush=True, ) sess.pump(seconds) if log_path: with open(log_path, "w", encoding="utf-8", newline="\n") as f: f.write(f"# listen pitch test {datetime.now().isoformat(timespec='seconds')}\n") for line in sess.lines: f.write(line + "\n") print(f"Log saved: {os.path.abspath(log_path)}", flush=True) result = analyze_lines(sess.lines) return print_report(result, report) finally: try: jlink.rtt_stop() except Exception: pass jlink.close() def self_test_map_rules() -> int: """Offline: C III / G III / G I / G V mapping (no J-Link).""" result = CheckResult() # C III (xp=0, deg=3): register -12, first/third stay an octave c_iii = [(52, 40), (59, 47), (64, 52)] # E3/B3/E4 -> E2/B2/E3 for key_in, exp in c_iii: out, _fl, _near = map_chord(key_in, 0, 3) if out != exp: result.add_fail(f"C III {key_in}->{out} expected {exp}") else: result.add_ok(f"C III {key_in}->{out}") if c_iii[2][1] - c_iii[0][1] != 12: result.add_fail("C III first/third not octave") else: result.add_ok("C III first/third octave E2/E3") # G III (xp=7, deg=3): register -12 only — B2 F#3 B3, not collapsed B2 F#2 B2 g_iii = [(59, 47), (66, 54), (71, 59)] # B3/F#4/B4 -> B2/F#3/B3 outs = [] for key_in, exp in g_iii: out, fl, _near = map_chord(key_in, 7, 3) outs.append(out) if out != exp: result.add_fail(f"G III {key_in}->{out} expected {exp}") elif fl & 0x04: result.add_fail(f"G III stacked xpose fl=0x{fl:02X}") else: result.add_ok(f"G III {key_in}->{out}") if outs != [47, 54, 59]: result.add_fail(f"G III pattern {outs} != [47,54,59] B2-F#3-B3") elif outs[2] - outs[0] != 12: result.add_fail("G III first/third not octave") else: result.add_ok("G III pattern B2-F#3-B3") # G I (xp=7): overall -12 only via MapXposeOverall g_i = [(55, 43), (62, 50), (67, 55)] # G3/D4/G4 -> G2/D3/G3 for key_in, exp in g_i: out, _fl, _near = map_xpose_overall_chord(key_in) if out != exp: result.add_fail(f"G I {key_in}->{out} expected {exp}") else: result.add_ok(f"G I {key_in}->{out}") # G V (xp=7, deg=5): still stacks register + xpose out, fl, _near = map_chord(71, 7, 5) if out != 47 or not (fl & 0x04): result.add_fail(f"G V should stack 71->47 fl&0x04, got {out} fl=0x{fl:02X}") else: result.add_ok("G V 71->47 stacked") # analyze_lines: G III first/third stay an octave (B3/B4 -> B2/B3) keep = [ "[PITCH] on ch0(chord) 59->47 d=-12 deg=3 chord=8 xp=7 xf=1 fl=0x01 ", "[PITCH] on ch0(chord) 66->54 d=-12 deg=3 chord=8 xp=7 xf=1 fl=0x01 ", "[PITCH] on ch0(chord) 71->59 d=-12 deg=3 chord=8 xp=7 xf=1 fl=0x01 ", ] keep_r = analyze_lines(keep) if keep_r.fail: result.add_fail(f"analyze G III keep failed: {keep_r.messages}") elif not any("III xpose octave" in m for m in keep_r.messages): result.add_fail("analyze G III keep missing octave OK") else: result.add_ok("analyze G III keep B2/B3") collapse = [ "[PITCH] on ch0(chord) 59->47 d=-12 deg=3 chord=8 xp=7 xf=1 fl=0x01 ", "[PITCH] on ch0(chord) 71->47 d=-24 deg=3 chord=8 xp=7 xf=1 fl=0x05 ", ] collapse_r = CheckResult() check_iii_xpose_octave(collapse, collapse_r) if collapse_r.fail == 0: result.add_fail("analyze should flag collapsed B2/B2") else: result.add_ok("analyze flags collapsed B2/B2") return print_report(result) def run_analyze(path: str, report: str | None) -> int: with open(path, encoding="utf-8", errors="replace") as f: lines = f.readlines() result = analyze_lines(lines) return print_report(result, report) def main() -> None: parser = argparse.ArgumentParser(description="K1 pitch/register RTT auto-test") parser.add_argument("--device", default="AT32F403AC") parser.add_argument("--hold", type=float, default=2.5, help="Seconds to hold each injected key") parser.add_argument("--seconds", type=float, default=45.0, help="Listen duration") parser.add_argument("--listen", action="store_true", help="Do not inject; monitor only") parser.add_argument("--analyze", metavar="LOG", help="Offline analyze a dump log") parser.add_argument("--self-test", action="store_true", help="Offline map-rule checks (no J-Link)") parser.add_argument("--out-log", default="", help="Save captured RTT lines") parser.add_argument("--report", default="", help="Save pass/fail report") args = parser.parse_args() report = args.report or None log_path = args.out_log or None if args.self_test: raise SystemExit(self_test_map_rules()) if args.analyze: raise SystemExit(run_analyze(args.analyze, report)) if args.listen: raise SystemExit(run_listen(args.device, args.seconds, report, log_path)) raise SystemExit(run_auto(args.device, args.hold, report, log_path)) if __name__ == "__main__": main()