Skip to content

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 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.

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 split fell along natural boundaries:

profile.py — what we know about the device

Section titled “profile.py — what we know about the device”
@dataclass
class HelpEntry:
name: str # command name (lowercase)
description: str = "" # help description text
params: str = "" # parameter syntax, e.g. "[<motor> [angle]]"
@dataclass
class 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 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.

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).

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.

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.

Terminal window
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.

Terminal window
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.

Terminal window
console-probe --port /dev/ttyUSB2 --baud 115200 --submenu mot

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 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.

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.