The Prompt Termination Bug
Serial protocols are deceptively simple. Bytes go in, bytes come out. No framing beyond start/stop bits, no packet headers, no length fields. When you send a command to an embedded console, how do you know the response is done?
On the Winegard firmware, the answer looks obvious: the prompt character > appears at the end of every response. Read bytes until you see >, and you’ve got the full response. That was the original approach. It worked great until it didn’t.
The symptom
Section titled “The symptom”The console-probe tool was returning truncated responses. Not every time — just for certain commands and certain submenus. A help query that should return 15 lines of output would come back with 6. A command that should show motor positions would cut off mid-value. The truncation points seemed random.
The responses always ended with >. That was the tell — the reader was stopping exactly where it thought the prompt was. But the prompt wasn’t there. Something else was.
Where > hides
Section titled “Where > hides”Consider the DVB submenu’s help output. It includes lines like:
Enter <dvb> - DVB TunerThat angle bracket > at the end of <dvb> is just part of the parameter syntax notation. But to a byte-level reader looking for > as a termination character, it’s indistinguishable from a prompt.
Or NVS parameter descriptions:
help [<command>]The > in <command> is another false positive. And the firmware’s own output sometimes includes > in comparison operators, hex dumps, and log messages. Every one of these is a trap for a naive “read until >” strategy.
The fundamental problem: > is used as both a prompt character and a content character, and at the byte level they look identical.
How the original code worked
Section titled “How the original code worked”The first version of the serial reader was straightforward:
# Original approach (simplified)def read_response(ser): buf = bytearray() while True: chunk = ser.read(4096) buf.extend(chunk) text = buf.decode('utf-8', errors='replace') if text.rstrip().endswith('>'): break return textRead chunks, accumulate them, check if the last non-whitespace character is >. If so, we’re done. This works perfectly when:
- The response contains no
>characters except the final prompt - The prompt is always a bare
>
On the original Trav’ler (HAL 0.0.00 and 2.05), both conditions are mostly true. The help output is sparse, the prompt is just >, and the firmware doesn’t generate much output that contains angle brackets. So the bug hid.
The G2 broke everything
Section titled “The G2 broke everything”The Carryout G2’s firmware is far more verbose than the Trav’ler’s. Twelve submenus, paginated help (the DVB submenu has a ? page and a man page), parameter syntax with angle brackets everywhere, and named prompts like TRK>, MOT>, DVB>.
The named prompts actually made things both worse and better. Worse because TRK> contains > and so does every other prompt string. Better because TRK> is a much more specific pattern than bare >.
Here’s what a real failure looked like. Sending ? in the root menu:
TRK>?Enter <a3981> - A3981 Stepper Driver ICEnter <adc> - Analog to Digital ConverterThe reader would stop at the > in <a3981> on the first help line. Two lines of output instead of twelve. The rest of the help text was still sitting in the serial buffer, and the next command would get a response that started with leftover help output — corrupting everything downstream.
The fix: prompt-aware reading
Section titled “The fix: prompt-aware reading”The insight was that a real firmware prompt has specific structural properties that random > characters in content don’t:
- A prompt appears at the start of a line (or at least the end of the last line)
- A prompt is a specific string —
TRK>,MOT>,NVS>, not just> - A prompt is never inside brackets —
[<parameter>]contains>but it’s clearly parameter syntax
The fix in serial_io.py implements this as a multi-layer check:
PROMPT_RE = re.compile(r"(\S+[>$#])\s*$")
def _is_prompt_terminated(text: str, profile: DeviceProfile) -> bool: stripped = text.rstrip() if not stripped: return False
last_line = stripped.split("\n")[-1]
if profile.prompts: # Check known prompts first (fast path) last_stripped = last_line.rstrip() for p in profile.prompts: if last_stripped.endswith(p): return True
# Accept a PROMPT_RE match only if no brackets on that line if "[" not in last_line: m = PROMPT_RE.search(last_line) if m: return True
return False
# No known prompts yet — fallback to bare > check return stripped.endswith(">")Three layers of defense:
Layer 1: Known prompt matching. Once we’ve discovered the device’s prompts (during the auto-discovery phase), we check the last line against the exact known prompt strings. TRK> matches TRK>. Enter <a3981> does not match TRK>. Fast, precise, no false positives.
Layer 2: Pattern matching with bracket exclusion. For cases where we might encounter a new prompt we haven’t seen (entering an undiscovered submenu), the regex (\S+[>$#])\s*$ matches prompt-like strings at the end of a line. But only if the line doesn’t contain [ — which filters out parameter syntax like help [<command>].
Layer 3: Bare > fallback. During the very first connection, before we’ve discovered any prompt strings, we fall back to the original endswith('>') check. This is the least reliable layer, but it only runs during initial probe when we need some heuristic to get started.
The bootstrap problem
Section titled “The bootstrap problem”There’s a chicken-and-egg situation here. To read responses correctly, you need to know the prompt. To discover the prompt, you need to read a response. The detect_prompt() function handles this by sending a bare line ending (carriage return) and reading whatever comes back:
def detect_prompt(ser, profile): 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() 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)A bare CR on the Winegard firmware produces just the prompt — TRK> or whatever menu you’re in. No content, no help text, no > characters hiding in angle brackets. So the simple endswith('>') check is safe here. The regex then extracts the full prompt string (TRK>, not just >), which goes into profile.prompts for all subsequent reads.
What serial doesn’t give you
Section titled “What serial doesn’t give you”This is the kind of bug that doesn’t exist in protocols with proper framing. HTTP has Content-Length. TCP has packet boundaries. Even SLIP has escape sequences. RS-232/422/485 serial gives you a stream of bytes with no metadata. The firmware doesn’t tell you “this response is 347 bytes long” or “the prompt starts here.” You have to infer structure from content.
The Winegard firmware’s prompts are intended to serve as response terminators — that’s why they’re there. But the firmware authors never had to write a general-purpose parser for their own output. They were writing for human operators using a terminal emulator, where “I can see the prompt, so the response is done” is handled by the human’s eyes.
Residual fragility
Section titled “Residual fragility”The current implementation is much more robust than the original, but it’s still heuristic-based. A pathological firmware response that happens to end a line with SOMEWORD> and no brackets would be misidentified as a prompt. We haven’t hit this in practice on the Winegard firmware, but the possibility exists.
The right long-term fix would be a timeout-based fallback: if we haven’t seen a known prompt within N seconds, assume the response is complete. The current code does this (the deadline in send_cmd), but it’s the slow path — you wait for the full timeout instead of detecting the prompt early. For interactive probing, the prompt detection is fast enough that the timeout rarely triggers. For automated scanning of hundreds of commands, every unnecessary timeout adds up.
For now, the three-layer approach handles everything the G2 firmware throws at us. And we know exactly which layer is doing the work for each submenu, because the prompts are named — TRK>, MOT>, DVB>, NVS>, EE>, A3981>, ADC>, DIPSWITCH>, GPIO>, LATLON>, OS>, PEAK>, STEP>. Thirteen known prompts, all ending in >, all distinguishable from content. The naming convention that initially made parsing harder ended up being what made it solvable.