birdcage/scripts/hidden_menu_probe.py
Ryan Malloy 7ff91b08ea Refactor probe tool to generic embedded console scanner, document full G2 command inventory
Rewrote hidden_menu_probe.py from Winegard-hardcoded to auto-discovering:
detects prompt, error string, and submenu structure from any firmware console.
Extracted Winegard-specific candidate words to scripts/wordlists/winegard.txt.

Deep probe of all 12 G2 submenus discovered commands across A3981 (driver
diagnostics), ADC (RSSI monitoring + position sweep), DVB (extended help via
man, transponder selection), EEPROM (read/write), GPIO (pin R/W), LATLON
(calculator), MOT (azscan, sw), PEAK (EchoStar switch), and STEP (raw
stepper control). NVS submenu generates false positives — treats any input
as sequential index reads.

Safety: added q/Q to default blocklist, bare-CR check before navigate_to_root
to prevent accidental shell termination between submenus.
2026-02-12 21:05:33 -07:00

1044 lines
30 KiB
Python

#!/usr/bin/env python3
"""Probe for undocumented/hidden commands on any embedded console.
Auto-discovers the device's prompt character, error string, and submenu
structure, then sends candidate command names and flags anything that
produces a non-error response.
Works with any prompt-based firmware console: Winegard Trav'ler / G2,
U-Boot, FreeRTOS shells, vendor debug consoles, etc.
Usage:
uv run scripts/hidden_menu_probe.py --port /dev/ttyUSB2 --baud 115200
uv run scripts/hidden_menu_probe.py --deep
uv run scripts/hidden_menu_probe.py --submenu mot
uv run scripts/hidden_menu_probe.py --wordlist scripts/wordlists/winegard.txt
uv run scripts/hidden_menu_probe.py --prompt "U-Boot>" --error "Unknown command"
"""
from __future__ import annotations
import argparse
import json
import re
import string
import sys
import time
from dataclasses import dataclass, field
from pathlib import Path
import serial # pyright: ignore[reportMissingImports]
# ---------------------------------------------------------------------------
# Device profile — populated by auto-discovery or CLI overrides
# ---------------------------------------------------------------------------
@dataclass
class DeviceProfile:
"""Everything we know (or detected) about the attached console."""
port: str = "/dev/ttyUSB0"
baud: int = 115200
root_prompt: str = "" # e.g. "TRK>"
prompts: list[str] = field(default_factory=list) # all known prompts
error_string: str = "" # e.g. "Invalid command."
known_commands: set[str] = field(default_factory=set) # from help output
submenus: list[str] = field(default_factory=list) # detected submenu names
exit_cmd: str = "q"
line_ending: str = "\r"
# ---------------------------------------------------------------------------
# Serial I/O
# ---------------------------------------------------------------------------
def send_cmd(
ser: serial.Serial,
cmd: str,
profile: DeviceProfile,
timeout: float = 1.0,
) -> str:
"""Send *cmd* + line-ending, read until a known prompt or timeout."""
ser.reset_input_buffer()
ser.write(f"{cmd}{profile.line_ending}".encode("ascii", errors="replace"))
ser.timeout = timeout
buf = bytearray()
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
chunk = ser.read(4096)
if chunk:
buf.extend(chunk)
text = buf.decode("utf-8", errors="replace")
if text.rstrip().endswith(">"):
break
elif buf:
text = buf.decode("utf-8", errors="replace")
if text.rstrip().endswith(">"):
break
return buf.decode("utf-8", errors="replace")
# ---------------------------------------------------------------------------
# Auto-discovery
# ---------------------------------------------------------------------------
_PROMPT_RE = re.compile(r"(\S+[>$#])\s*$")
def detect_prompt(ser: serial.Serial, profile: DeviceProfile) -> str | None:
"""Send a bare line-ending and extract the prompt from the response."""
ser.reset_input_buffer()
ser.write(profile.line_ending.encode("ascii"))
ser.timeout = 2.0
buf = bytearray()
deadline = time.monotonic() + 2.0
while time.monotonic() < deadline:
chunk = ser.read(4096)
if chunk:
buf.extend(chunk)
text = buf.decode("utf-8", errors="replace")
tail = text.rstrip()
if tail.endswith((">", "#", "$")):
break
elif buf:
break
text = buf.decode("utf-8", errors="replace").strip()
if not text:
return None
# Take the last line, look for a prompt-like token
last_line = text.split("\n")[-1].strip()
m = _PROMPT_RE.search(last_line)
return m.group(1) if m else (last_line if last_line else None)
def detect_error_string(ser: serial.Serial, profile: DeviceProfile) -> str | None:
"""Send a garbage command and extract the error message template."""
resp = send_cmd(ser, "__xyzzy_probe__", profile, timeout=1.0)
# Strip the echo of our command and any prompt
lines = resp.replace("__xyzzy_probe__", "").strip().split("\n")
# Filter out lines that are just prompts
content_lines = []
for line in lines:
stripped = line.strip()
if not stripped:
continue
# Skip lines that are purely a prompt token
if _PROMPT_RE.fullmatch(stripped):
continue
# Strip trailing prompt from content lines
stripped = _PROMPT_RE.sub("", stripped).strip()
if stripped:
content_lines.append(stripped)
if content_lines:
# The error message is typically a single line
return content_lines[0].strip()
return None
def parse_help_output(
help_text: str,
profile: DeviceProfile,
) -> tuple[set[str], list[str]]:
"""Parse help output for command names and submenu hints.
Returns (known_commands, submenu_names).
"""
commands: set[str] = set()
submenus: list[str] = []
# Common help-output patterns:
# "command - description"
# "command Description text"
# " command"
# "Enter <name> - Description Menu" (Winegard G2 style)
# "Enter name sub-menu"
cmd_dash_re = re.compile(r"^\s*(\w[\w.]*)\s+[-—]\s+")
cmd_spaces_re = re.compile(r"^\s{0,4}(\w[\w.]*)\s{2,}")
bare_cmd_re = re.compile(r"^\s{0,4}(\w[\w.]*)\s*$")
# Angle-bracket format: "Enter <command> - Description"
bracket_re = re.compile(r"<(\w[\w.]*)>")
enter_re = re.compile(r"[Ee]nter\s+<?(\w+)>?", re.IGNORECASE)
menu_re = re.compile(r"(\w+)\s+[Mm]enu")
submenu_re = re.compile(r"(\w+)\s+[Ss]ub-?[Mm]enu")
for line in help_text.split("\n"):
stripped = line.strip()
if not stripped:
continue
# Skip lines that are just prompts
if _PROMPT_RE.fullmatch(stripped):
continue
# Try angle-bracket commands first (e.g. "Enter <a3981> - ...")
bracket_match = bracket_re.search(stripped)
if bracket_match:
cmd_name = bracket_match.group(1).lower()
if len(cmd_name) <= 20:
commands.add(cmd_name)
else:
# Fall back to generic patterns
for pat in (cmd_dash_re, cmd_spaces_re, bare_cmd_re):
m = pat.match(stripped)
if m:
cmd_name = m.group(1).lower()
if len(cmd_name) <= 20:
commands.add(cmd_name)
break
# Look for submenu hints — prefer bracketed names
enter_match = enter_re.search(stripped)
if enter_match:
sub = enter_match.group(1).lower()
if sub not in submenus and len(sub) <= 20:
submenus.append(sub)
else:
for pat in (submenu_re, menu_re):
m = pat.search(stripped)
if m:
sub = m.group(1).lower()
if sub not in submenus and len(sub) <= 20:
submenus.append(sub)
break
# Any command that appears in the help output might also be a submenu
# if the help text mentions "Enter <cmd>" or "<cmd> Menu".
# Commands themselves that are single-word and also in submenus get both.
return commands, submenus
def auto_discover(ser: serial.Serial, profile: DeviceProfile) -> DeviceProfile:
"""Run the discovery sequence and populate the profile."""
# Step 1: Detect root prompt
print("Phase 1: Auto-discovering device console...\n")
prompt = detect_prompt(ser, profile)
if prompt:
profile.root_prompt = prompt
profile.prompts = [prompt]
print(f" Root prompt: {prompt}")
else:
print(" WARNING: Could not detect root prompt.")
print(" Use --prompt to specify manually.")
# Step 2: Detect error string
err = detect_error_string(ser, profile)
if err:
profile.error_string = err
print(f' Error string: "{err}"')
else:
print(" WARNING: Could not detect error string.")
print(" Use --error to specify manually.")
# Step 3: Parse help output
print(" Sending help command: ?")
help_resp = send_cmd(ser, "?", profile, timeout=2.0)
commands, submenus = parse_help_output(help_resp, profile)
if commands:
profile.known_commands = commands
print(f" Known commands ({len(commands)}): {', '.join(sorted(commands))}")
else:
print(" No commands parsed from help output.")
if submenus:
profile.submenus = submenus
print(f" Detected submenus ({len(submenus)}): {', '.join(submenus)}")
else:
# Fall back: any known command that looks like a submenu name
# (we'll discover actual submenus during probing)
print(" No submenus detected from help output.")
# Build prompt list from submenus
for sub in profile.submenus:
sub_prompt = f"{sub.upper()}>"
if sub_prompt not in profile.prompts:
profile.prompts.append(sub_prompt)
print()
return profile
# ---------------------------------------------------------------------------
# Navigation
# ---------------------------------------------------------------------------
def navigate_to_root(ser: serial.Serial, profile: DeviceProfile) -> str:
"""Send exit command until we're at the root prompt.
Sends a bare line-ending first to check if we're already at root,
avoiding the case where 'q' at root level kills the shell entirely.
"""
# Check if we're already at root (bare CR won't kill anything)
resp = send_cmd(ser, "", profile)
if profile.root_prompt and profile.root_prompt in resp:
return profile.root_prompt
for _ in range(5):
resp = send_cmd(ser, profile.exit_cmd, profile)
if profile.root_prompt and profile.root_prompt in resp:
return profile.root_prompt
# If no root prompt set, check if the last line looks prompt-like
last_line = resp.strip().split("\n")[-1].strip()
m = _PROMPT_RE.search(last_line)
if m:
detected = m.group(1)
if not profile.root_prompt:
profile.root_prompt = detected
if detected == profile.root_prompt:
return detected
# Last resort: bare line-ending
resp = send_cmd(ser, "", profile)
last_line = resp.strip().split("\n")[-1].strip()
return last_line
def enter_submenu(ser: serial.Serial, menu: str, profile: DeviceProfile) -> str:
"""Enter a submenu and return the prompt we land on."""
navigate_to_root(ser, profile)
resp = send_cmd(ser, menu, profile)
lines = resp.strip().split("\n")
last = lines[-1].strip() if lines else ""
# Register this prompt if new
m = _PROMPT_RE.search(last)
if m:
new_prompt = m.group(1)
if new_prompt not in profile.prompts:
profile.prompts.append(new_prompt)
return new_prompt
return last
# ---------------------------------------------------------------------------
# Probing
# ---------------------------------------------------------------------------
def clean_response(
resp: str,
cmd: str,
profile: DeviceProfile,
) -> str:
"""Strip echo, prompts, and whitespace from a response."""
clean = resp
# Remove echo of the command (with various line-ending combos)
for suffix in ("\r\n", "\r", "\n", ""):
clean = clean.replace(f"{cmd}{suffix}", "", 1)
clean = clean.strip()
# Remove any known prompt tokens
for p in profile.prompts:
clean = clean.replace(p, "")
# Also strip any bare prompt-like token at the very end
clean = _PROMPT_RE.sub("", clean)
return clean.strip()
def probe_commands(
ser: serial.Serial,
candidates: list[str],
prompt: str,
label: str,
profile: DeviceProfile,
timeout: float = 0.5,
) -> list[tuple[str, str]]:
"""Probe candidates at the current menu level. Returns (cmd, preview) hits."""
hits = []
total = len(candidates)
for i, cmd in enumerate(candidates):
if (i + 1) % 50 == 0:
print(f" [{label}] Progress: {i + 1}/{total}...", flush=True)
resp = send_cmd(ser, cmd, profile, timeout=timeout)
clean = clean_response(resp, cmd, profile)
# A "hit" is anything that isn't the error string and has content
is_error = profile.error_string and profile.error_string in resp
if not is_error and clean:
preview = clean[:100].replace("\r\n", " | ").replace("\n", " | ")
hits.append((cmd, preview))
print(f" *** HIT: '{cmd}' -> {preview}", flush=True)
# If the command kicked us out of the submenu, recover
if "Terminating shell" in resp or "exiting" in resp.lower():
if profile.root_prompt and prompt != profile.root_prompt:
print(f" ('{cmd}' exited submenu, re-entering...)")
menu_name = prompt.replace(">", "").strip().lower()
enter_submenu(ser, menu_name, profile)
else:
# At root — shell may have died. Wait for firmware
# to respawn it, then verify with a bare CR.
print(f" ('{cmd}' terminated shell, waiting for restart...)")
time.sleep(1.0)
check = send_cmd(ser, "", profile)
if profile.root_prompt and profile.root_prompt not in check:
print(
" WARNING: Shell did not restart. Remaining "
"results may be incomplete.",
flush=True,
)
return hits
# ---------------------------------------------------------------------------
# Candidate generation
# ---------------------------------------------------------------------------
def generate_candidates(
blocklist: set[str],
wordlist_paths: list[Path] | None = None,
) -> list[str]:
"""Build the candidate command list.
Includes generic embedded debug commands + single chars + two-letter combos.
Merges in any external wordlist files. Applies blocklist last.
"""
candidates: list[str] = []
# Single characters
candidates.extend(list(string.ascii_lowercase))
candidates.extend(list(string.ascii_uppercase))
candidates.extend(list(string.digits))
# Generic embedded debug commands (no device-specific words)
generic = [
# Memory access
"md",
"mw",
"mm",
"mr",
"mem",
"peek",
"poke",
"rd",
"wr",
"read",
"write",
"dump",
"load",
"save",
"md.b",
"md.w",
"md.l",
"x",
"xx",
"xd",
# Flash
"flash",
"fl",
"erase",
"program",
"verify",
"protect",
"flinfo",
"fldump",
"flashdump",
# Boot / system
"boot",
"reboot",
"reset",
"go",
"run",
"exec",
"jump",
"bootd",
"bootm",
"bootp",
"version",
"ver",
"info",
"about",
"sysinfo",
"uptime",
"date",
"time",
"clk",
"clock",
# Debug
"debug",
"dbg",
"trace",
"log",
"print",
"echo",
"test",
"diag",
"selftest",
"bist",
"bench",
"assert",
"crash",
"fault",
"panic",
# Shell / OS
"sh",
"shell",
"cmd",
"command",
"cli",
"task",
"tasks",
"ps",
"top",
"threads",
"kill",
"suspend",
"resume",
"heap",
"stack",
"free",
"malloc",
"meminfo",
"cpu",
"cpuinfo",
"temp",
"temperature",
# Network / comms
"ping",
"net",
"ifconfig",
"ip",
"mac",
"uart",
"serial",
"spi",
"i2c",
"can",
# Service / factory
"factory",
"service",
"mfg",
"production",
"prod",
"cal",
"calibrate",
"calibration",
"config",
"cfg",
"setup",
"settings",
"hidden",
"secret",
"admin",
"su",
"root",
"login",
"password",
"passwd",
"auth",
"unlock",
# Update
"update",
"upgrade",
"firmware",
"fw",
"ota",
"download",
"upload",
"xmodem",
"ymodem",
"zmodem",
"tftp",
"ftp",
# Generic hardware
"sw",
"hw",
"id",
"sn",
"help",
"man",
"usage",
# Two-letter combos
"bl",
"bt",
"db",
"dm",
"dp",
"ds",
"dt",
"eb",
"ed",
"ee",
"ef",
"em",
"en",
"ep",
"er",
"es",
"et",
"fa",
"fb",
"fc",
"fd",
"fe",
"ff",
"fg",
"fh",
"fi",
"fj",
"ga",
"gb",
"gc",
"gd",
"ge",
"gf",
"gg",
"gh",
"gi",
"gj",
"ha",
"hb",
"hc",
"hd",
"he",
"hf",
"hg",
"hh",
"hi",
"hj",
"ia",
"ib",
"ic",
"io",
"ir",
"ka",
"kb",
"kc",
"kd",
"ke",
"la",
"lb",
"lc",
"ld",
"le",
"lf",
"lg",
"lh",
"li",
"lj",
"ma",
"mb",
"mc",
"me",
"mf",
"mg",
"mh",
"mi",
"mj",
"na",
"nb",
"nc",
"nd",
"ne",
"nf",
"ng",
"nh",
"ni",
"nj",
"oa",
"ob",
"oc",
"od",
"oe",
"of",
"og",
"oh",
"oi",
"oj",
"pa",
"pb",
"pc",
"pd",
"pe",
"pf",
"pg",
"ph",
"pi",
"pj",
"ra",
"rb",
"rc",
"re",
"rf",
"rg",
"rh",
"ri",
"rj",
"sa",
"sb",
"sc",
"sd",
"se",
"sf",
"sg",
"si",
"sj",
"ta",
"tb",
"tc",
"td",
"te",
"tf",
"tg",
"th",
"ti",
"tj",
"ua",
"ub",
"uc",
"ud",
"ue",
"uf",
"ug",
"uh",
"ui",
"uj",
"va",
"vb",
"vc",
"vd",
"ve",
"vf",
"vg",
"vh",
"vi",
"vj",
"wa",
"wb",
"wc",
"wd",
"we",
"wf",
"wg",
"wh",
"wi",
"wj",
"za",
"zb",
"zc",
"zd",
"ze",
"zf",
]
candidates.extend(generic)
# Merge external wordlists
if wordlist_paths:
for wl_path in wordlist_paths:
try:
text = wl_path.read_text()
for line in text.split("\n"):
word = line.strip()
# Skip blanks and comments
if word and not word.startswith("#"):
candidates.append(word)
except OSError as exc:
print(
f"WARNING: Could not read wordlist {wl_path}: {exc}",
file=sys.stderr,
)
# Deduplicate preserving order
seen: set[str] = set()
unique: list[str] = []
for c in candidates:
if c not in seen:
seen.add(c)
unique.append(c)
# Apply blocklist
unique = [c for c in unique if c not in blocklist]
return unique
# ---------------------------------------------------------------------------
# JSON output
# ---------------------------------------------------------------------------
def write_json_report(
path: Path,
profile: DeviceProfile,
results: dict[str, list[tuple[str, str]]],
) -> None:
"""Write machine-readable JSON probe report."""
report: dict = {
"device": {"port": profile.port, "baud": profile.baud},
"detected": {
"root_prompt": profile.root_prompt,
"error_string": profile.error_string,
"known_commands": sorted(profile.known_commands),
"submenus": profile.submenus,
},
"results": {},
}
for label, hits in results.items():
known_hits = [
(cmd, resp) for cmd, resp in hits if cmd.lower() in profile.known_commands
]
unknown_hits = [
(cmd, resp)
for cmd, resp in hits
if cmd.lower() not in profile.known_commands
]
report["results"][label] = {
"total_hits": len(hits),
"known": len(known_hits),
"unknown": len(unknown_hits),
"hits": [{"cmd": cmd, "response": resp} for cmd, resp in hits],
}
path.write_text(json.dumps(report, indent=2) + "\n")
print(f"\nJSON report written to {path}")
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
LINE_ENDINGS = {"cr": "\r", "lf": "\n", "crlf": "\r\n"}
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Probe for hidden commands on an embedded console",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""\
examples:
%(prog)s --port /dev/ttyUSB2 --baud 115200
%(prog)s --deep
%(prog)s --submenu mot
%(prog)s --wordlist scripts/wordlists/winegard.txt
%(prog)s --prompt "U-Boot>" --error "Unknown command"
""",
)
conn = parser.add_argument_group("connection")
conn.add_argument(
"--port", default="/dev/ttyUSB0", help="Serial port (default: /dev/ttyUSB0)"
)
conn.add_argument(
"--baud", type=int, default=115200, help="Baud rate (default: 115200)"
)
conn.add_argument(
"--line-ending",
choices=LINE_ENDINGS,
default="cr",
help="Line ending to send (default: cr)",
)
disc = parser.add_argument_group("discovery overrides")
disc.add_argument(
"--prompt", default=None, help="Override auto-detected root prompt"
)
disc.add_argument(
"--error", default=None, help="Override auto-detected error string"
)
disc.add_argument(
"--help-cmd", default="?", help="Command to request help (default: ?)"
)
disc.add_argument(
"--exit-cmd", default="q", help="Command to exit submenu (default: q)"
)
probe = parser.add_argument_group("probing")
probe.add_argument(
"--deep", action="store_true", help="Probe all discovered submenus"
)
probe.add_argument(
"--submenu", type=str, default=None, help="Probe a single submenu by name"
)
probe.add_argument(
"--timeout",
type=float,
default=0.5,
help="Per-command timeout in seconds (default: 0.5)",
)
probe.add_argument(
"--blocklist",
default="reboot,stow,def,q,Q",
help="Comma-separated commands to never send (default: reboot,stow,def,q,Q)",
)
probe.add_argument(
"--wordlist",
action="append",
default=None,
metavar="FILE",
help="Extra candidate words file (one per line, repeatable)",
)
output = parser.add_argument_group("output")
output.add_argument(
"--json", metavar="FILE", default=None, help="Write results as JSON to FILE"
)
return parser.parse_args()
def main() -> None:
args = parse_args()
# Build profile from CLI args
profile = DeviceProfile(
port=args.port,
baud=args.baud,
line_ending=LINE_ENDINGS[args.line_ending],
exit_cmd=args.exit_cmd,
)
# Apply explicit overrides (before auto-discovery)
if args.prompt:
profile.root_prompt = args.prompt
profile.prompts = [args.prompt]
if args.error:
profile.error_string = args.error
# Build candidate list
blocklist = {w.strip() for w in args.blocklist.split(",") if w.strip()}
wordlist_paths = [Path(p) for p in args.wordlist] if args.wordlist else None
candidates = generate_candidates(blocklist, wordlist_paths)
print(f"Generated {len(candidates)} candidate commands\n")
# Open serial
ser = serial.Serial(profile.port, profile.baud, timeout=1)
ser.reset_input_buffer()
# Collect results for JSON report
all_results: dict[str, list[tuple[str, str]]] = {}
try:
# Auto-discovery (or validate overrides)
if not profile.root_prompt or not profile.error_string:
profile = auto_discover(ser, profile)
else:
print("Using overrides:")
print(f" Root prompt: {profile.root_prompt}")
print(f' Error string: "{profile.error_string}"')
# Still run help to discover known commands and submenus
print(f" Sending help command: {args.help_cmd}")
help_resp = send_cmd(ser, args.help_cmd, profile, timeout=2.0)
commands, submenus = parse_help_output(help_resp, profile)
profile.known_commands = commands
if not profile.submenus:
profile.submenus = submenus
for sub in profile.submenus:
sub_prompt = f"{sub.upper()}>"
if sub_prompt not in profile.prompts:
profile.prompts.append(sub_prompt)
if commands:
print(
f" Known commands ({len(commands)}): {', '.join(sorted(commands))}"
)
if submenus:
print(f" Submenus ({len(submenus)}): {', '.join(submenus)}")
print()
if not profile.root_prompt:
print(
"ERROR: Could not determine root prompt. Use --prompt to specify.",
file=sys.stderr,
)
sys.exit(1)
# Navigate to root
prompt = navigate_to_root(ser, profile)
print(f"Starting at: {prompt}\n")
# Probe root menu
root_label = profile.root_prompt.rstrip(">$#").strip() or "ROOT"
print(
f"=== Probing {profile.root_prompt} (root) "
f"-- {len(candidates)} candidates ===\n"
)
root_hits = probe_commands(
ser,
candidates,
profile.root_prompt,
root_label,
profile,
timeout=args.timeout,
)
all_results[root_label] = root_hits
# Classify hits
unknown_hits = [
(cmd, resp)
for cmd, resp in root_hits
if cmd.lower() not in profile.known_commands
]
known_count = len(root_hits) - len(unknown_hits)
print("\n--- Root Results ---")
print(f" Total hits: {len(root_hits)}")
print(f" Known commands: {known_count}")
print(f" UNKNOWN commands: {len(unknown_hits)}")
for cmd, resp in unknown_hits:
print(f" '{cmd}' -> {resp}")
# Determine which submenus to probe
submenus_to_probe: list[str] = []
if args.submenu:
submenus_to_probe.append(args.submenu)
elif args.deep:
submenus_to_probe = list(profile.submenus)
for menu in submenus_to_probe:
label = menu.upper()
print(f"\n=== Probing {label} submenu ===\n")
sub_prompt = enter_submenu(ser, menu, profile)
print(f" Prompt: {sub_prompt}")
sub_hits = probe_commands(
ser,
candidates,
sub_prompt,
label,
profile,
timeout=args.timeout,
)
all_results[label] = sub_hits
print(f"\n--- {label} Results: {len(sub_hits)} hits ---")
for cmd, resp in sub_hits:
print(f" '{cmd}' -> {resp}")
navigate_to_root(ser, profile)
# JSON report
if args.json:
write_json_report(Path(args.json), profile, all_results)
except KeyboardInterrupt:
print("\n\nInterrupted.")
finally:
ser.close()
print("\nPort closed.")
if __name__ == "__main__":
main()