590 lines
20 KiB
Python
590 lines
20 KiB
Python
#!/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
|
||
"""
|
||
|
||
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) -> tuple[int, int]:
|
||
"""Return (out, flags) mirroring App_Auto_MapBassNote."""
|
||
out = key - 12
|
||
flags = 0x01
|
||
while out < MIDI_E1:
|
||
out += 12
|
||
flags |= 0x02
|
||
if xp >= XPOSE_FS:
|
||
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) -> 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].
|
||
"""
|
||
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:
|
||
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 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:
|
||
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)
|
||
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
|
||
return f"bass {ev.key_in}->{ev.key_out}", True
|
||
|
||
# chord path
|
||
exp, exp_fl, need_near = map_chord(ev.key_in, ev.xp)
|
||
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()
|
||
for line in lines:
|
||
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")
|
||
return result
|
||
|
||
|
||
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
|
||
):
|
||
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("III key8 -12", key=8, xpose=None, 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)
|
||
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_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 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("--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.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()
|