Console Probe Evolution
The console-probe tool started as a single Python file. By the time it stabilized, it was a proper package with five modules, a CLI with multiple operating modes, JSON reporting with versioned schemas, and a clean separation from the dish-specific birdcage package. This is the story of how it grew.
The monolithic beginning
Section titled “The monolithic beginning”The first version did everything in one file: open the serial port, detect the prompt, send ?, parse the help output, try a list of candidate command strings, record which ones produced responses, print results. About 1,044 lines.
It worked. For the Trav’ler’s simple firmware (bare > prompt, handful of commands, no submenus worth probing), it was fine. You ran it, it brute-forced its way through a few hundred candidates, told you which ones the firmware recognized, and you were done in a few minutes.
Then the G2 happened.
Why it had to change
Section titled “Why it had to change”The Carryout G2’s firmware has 12 submenus, each with its own prompt (TRK>, MOT>, DVB>, etc.), its own help format (some paginated across ? and man), and its own set of commands. The monolithic script’s assumptions broke everywhere:
Prompt detection. The original code assumed a bare > prompt. The G2 has TRK>, MOT>, NVS>, and ten others. The prompt termination bug was the most visible failure, but the prompt handling issue ran deeper — you needed to track which prompt you expected, which meant tracking your current position in the menu hierarchy.
Help parsing. The Trav’ler’s help output is a flat list of commands. The G2’s help uses multiple formats: Enter <cmd> - description for submenus, cmd - description for regular commands, paginated across multiple help commands. The DVB submenu alone has 38 commands split between ? and man.
Navigation. On the Trav’ler, there’s essentially one menu level. On the G2, you enter submenus with their name (mot, dvb, nvs) and exit with q. Getting lost — sending q at the root level — terminates the shell entirely and requires a power cycle. The probe tool needed to track where it was and never send q at the wrong level.
Error detection. Different firmware versions return different error messages for unrecognized commands. The G2 uses a specific error string that we detect at runtime by sending a garbage command (__xyzzy_probe__) and reading whatever the firmware says back. This has to happen before probing starts.
A single file trying to handle all of this becomes unreadable fast.
The refactoring
Section titled “The refactoring”The split fell along natural boundaries:
profile.py — what we know about the device
Section titled “profile.py — what we know about the device”@dataclassclass HelpEntry: name: str # command name (lowercase) description: str = "" # help description text params: str = "" # parameter syntax, e.g. "[<motor> [angle]]"
@dataclassclass DeviceProfile: port: str = "/dev/ttyUSB0" baud: int = 115200 root_prompt: str = "" # e.g. "TRK>" prompts: list[str] = ... # all known prompts error_string: str = "" # e.g. "Invalid command." known_commands: set[str] = ... submenus: list[str] = ... exit_cmd: str = "q" line_ending: str = "\r" submenu_help: dict[str, list[HelpEntry]] = ...DeviceProfile is the central data structure. It starts almost empty and accumulates knowledge as discovery progresses. The probe detects the prompt, populates root_prompt and prompts. Help parsing populates known_commands, submenus, and submenu_help. Error detection fills error_string. By the end of a full probe run, the profile is a complete description of the firmware’s console interface.
HelpEntry captures structured command documentation — not just the name, but the parameter syntax and description from the help output. This matters for the JSON report, where we want to preserve the firmware’s own documentation of its commands.
serial_io.py — talking to the device
Section titled “serial_io.py — talking to the device”Serial I/O got its own module because getting it right was the hardest part. Two functions: send_cmd() (send a command, read until prompt or timeout) and detect_prompt() (send a bare line ending, extract the prompt string). Plus the _is_prompt_terminated() function that handles the three-layer prompt detection.
Keeping serial I/O separate means the discovery engine never touches pyserial directly. It passes a serial.Serial handle and a DeviceProfile to send_cmd() and gets back a string. If we ever need to support a different transport (TCP socket for remote serial servers, mock serial for testing), we only change one module.
discovery.py — the engine
Section titled “discovery.py — the engine”This is where the intelligence lives. Five major functions:
parse_help_output() — takes raw help text, returns (commands, submenus). Handles three help formats: Enter <cmd> - description (G2 submenu entries), cmd - description (standard), and bare command names. Filters out parameter placeholders like <command> and <value> that look like commands but aren’t — the _PARAM_PLACEHOLDERS set catches these:
_PARAM_PLACEHOLDERS: set[str] = { "command", "commands", "parameter", "parameters", "value", "values", "index", "name", "arg", "args", ...}This set exists because of a real false positive: the firmware’s help [<command>] usage text was being parsed as a command named command. The placeholder filter is a lesson learned.
parse_help_structured() — same parsing, but returns HelpEntry objects with descriptions and parameter syntax preserved. Used for the JSON report’s help_commands section.
discover_submenu_help() — enters a submenu, queries ?, tries man for paginated help, merges results, deduplicates. The man fallback was added specifically for the DVB submenu, which splits its 38 commands across two help pages.
probe_commands() — the brute-force engine. Iterates through a candidate list, sends each one, checks if the response is different from the error string, records hits. Handles shell termination (if a command kills the session) and submenu escapes (if a command accidentally exits the current submenu).
generate_candidates() — builds the candidate list from three sources: single characters (a-z, A-Z, 0-9), generic embedded debug commands (a hardcoded list of ~150 common commands like dump, flash, reboot, config), and external wordlists. Deduplicates and applies the blocklist (commands we never want to send, like reboot, stow, q).
report.py — structured output
Section titled “report.py — structured output”The JSON report uses a versioned schema (currently format_version: 2). Version 2 added the menus section with per-submenu structured data:
{ "format_version": 2, "device": { "port": "/dev/ttyUSB2", "baud": 115200 }, "detected": { "root_prompt": "TRK>", "error_string": "...", "known_commands": ["a3981", "adc", "dvb", "mot", "nvs", ...], "submenus": ["a3981", "adc", "dipswitch", ...] }, "menus": { "MOT": { "prompt": "MOT>", "help_commands": [ { "cmd": "a", "description": "show/move position", "params": "[<id> <deg>]" }, ... ], "probe_hits": [...], "undiscovered": [...], "stats": { "help_count": 25, "probe_count": 31, "undiscovered_count": 6 } } }}The undiscovered field is the interesting part — it lists commands found by brute-force probing that don’t appear in the help output. These are the hidden commands, the ones the firmware responds to but doesn’t advertise. On the G2, there aren’t many (the help is pretty complete), but on other embedded platforms this list is where the gold is.
cli.py — modes of operation
Section titled “cli.py — modes of operation”The CLI evolved to support three distinct workflows:
--discover-only — fast scan. Auto-detect prompt and error string, query help in every submenu, build a complete command inventory without sending any brute-force probes. Takes about 30 seconds on the G2 (12 submenus, ~2 seconds each). This is the “safe” mode — it only sends ?, man, and navigation commands.
console-probe --port /dev/ttyUSB2 --baud 115200 --discover-only --json /tmp/discover.json--deep — exhaustive probe. After discovery, brute-force every submenu with the full candidate list. This takes 15-30 minutes depending on the candidate count and timeout settings. It finds commands that help doesn’t mention.
console-probe --port /dev/ttyUSB2 --baud 115200 --deep --wordlist scripts/wordlists/winegard.txt--submenu <name> — targeted probe. Run brute-force on a single submenu only. Useful when you’ve found an interesting menu and want to dig deeper without probing everything.
console-probe --port /dev/ttyUSB2 --baud 115200 --submenu motAutomated vs. interactive discovery
Section titled “Automated vs. interactive discovery”The probe tool finds commands that respond without arguments — that’s its fundamental approach. Send a string, check if the response differs from the error message. If it does, it’s a command.
But many firmware commands require arguments to produce useful output. a 0 180.0 moves azimuth to 180 degrees, but a alone just prints the current position. The probe finds a but can’t tell you about the argument format. And commands that require arguments — like e <idx> in the NVS submenu — produce error output that looks different from the unknown-command error but isn’t a successful response either.
This is why the full command inventory in the CLAUDE.md documentation was built from both automated probing and interactive ? exploration. The probe finds the commands. Interactive sessions discover the argument formats, the edge cases, and the behavior that only shows up when you use the commands as intended.
The probe’s --discover-only mode bridges this gap somewhat — by parsing the help output’s parameter syntax ([<motor> [angle]], <hex>, etc.), it captures what the firmware says the arguments should be, even if it can’t verify them. The HelpEntry.params field preserves this.
The two-package split
Section titled “The two-package split”The final architectural decision was separating console-probe from birdcage into two installable packages:
console-probe — generic embedded console scanner. Knows nothing about Winegard dishes, motor commands, or satellite tracking. It understands serial ports, prompts, help parsing, and brute-force command discovery. You could point it at any embedded system with a text-based debug console.
birdcage — Winegard-specific dish control. Protocol implementations (HAL205Protocol, CarryoutG2Protocol), the leapfrog algorithm, antenna abstraction, rotctld server, CLI. This package knows about motor IDs, position formats, NVS indices, and satellite search sequences.
The split happened because we realized the probe tool was useful beyond this project. Any embedded system with a serial debug console — industrial controllers, network equipment, IoT devices, automotive ECUs — has the same pattern: send a command, read a response, figure out what commands exist. The discovery logic, help parsing, and brute-force probing are generic. The Winegard-specific knowledge belongs in the control package, not the probe.
Both packages are installed together via uv sync from the same repository, and they share the same pyproject.toml workspace. But they have separate entry points (console-probe and birdcage), separate source trees (src/console_probe/ and src/birdcage/), and no code dependencies between them.
What the probe finds vs. what we know
Section titled “What the probe finds vs. what we know”Running --discover-only on the G2 finds about 100 commands across 12 submenus in 30 seconds. Running --deep with the Winegard wordlist finds a handful more — undiscovered commands that respond to probing but aren’t in the help output. Interactive ? exploration in each submenu confirmed the full inventory: 6 in A3981, 5 in ADC, 1 in DIPSWITCH, 38 in DVB, 3 in EEPROM, 4 in GPIO, 1 in LATLON, 25 in MOT, 5 in NVS, 3 in OS, 6 in PEAK, 7 in STEP.
The gap between “what the probe finds” and “what exists” is small on the G2 — the firmware’s help is reasonably complete. On other embedded platforms, that gap can be enormous. The probe is designed for the worst case: minimal help, undocumented commands, no error messages. The Winegard G2 just happens to be well-documented by its own firmware.