host.command_frame

Pluggable shell command framing for ShellSession.

otto drives a remote shell by wrapping each command in unique sentinels — a BEGIN marker, the command itself, a way to recover the exit code, and an END marker — then parsing the echoed byte stream back into (output, retcode). How that wrapping and parsing is done is the shell’s dialect, and it differs per target:

  • a POSIX bash shell bakes $? into the END marker;

  • the Zephyr RTOS shell has no $? and no generic echo, so it appends a stock retval command whose output carries the code on its own line, and parses output positionally (the shell prints its prompt after every executed line);

  • a Zephyr 2.7 target has neither — retval only exists from 3.x — so a custom frame is needed (see todo/command_frame_protocol.md).

This module makes the dialect a first-class, composable strategy: a CommandFrame is a small stateless value object that a session holds rather than is. The per-session sentinels (unique per connection so two sessions can’t cross-talk) live on the session and are passed to the frame as a SessionMarkers value object — keeping the frame pure and unit-testable without a live session, exactly like EmbeddedFileSystem.

Built-in frames

  • BashFrame ("bash") — POSIX bash; used by SSH/telnet/local unix sessions.

  • ZephyrFrame ("zephyr") — the stock Zephyr fs/retval shell (3.7 / 4.4 LTS).

A project can register additional dialects via register_command_frame() from a .otto init module — the same extension hook otto.host.embedded_filesystem.register_filesystem() follows.

class otto.host.command_frame.SessionMarkers(begin, end_prefix, ready, recover)

Bases: object

The unique per-session sentinel tokens a CommandFrame renders into commands and keys on when parsing.

Built once per session from the session id (see for_session()), so two concurrent sessions never match each other’s markers. A frame receives this and never generates markers itself, which keeps frames stateless.

begin : --is-rst--:py:class:`str`

BEGIN sentinel — __OTTO_<id>_BEGIN__.

end_prefix : --is-rst--:py:class:`str`

END sentinel prefix — __OTTO_<id>_END__ (bash appends <code>__).

ready : --is-rst--:py:class:`str`

Readiness-probe token — __OTTO_<id>_READY__.

recover : --is-rst--:py:class:`str`

Post-timeout re-sync token — __OTTO_<id>_RECOVER__.

classmethod for_session(session_id)

Build the marker set for a session id.

Return type:

SessionMarkers

class otto.host.command_frame.CommandFrame

Bases: ABC

A shell’s command-framing dialect: how to wrap a command for execution and how to parse the echoed stream back into (output, retcode).

Concrete frames are stateless value objects. The render half (handshake() / frame() / recover()) and the parse half (end_pattern() / marks_begin() / parse_output() / extract_retcode()) live together because they co-vary through where the retcode lives — splitting them would let mismatched halves combine.

type_name : --is-rst--:py:data:`~typing.ClassVar`\ \[:py:class:`str`]

Lab-data string for this dialect (e.g. 'bash'). Looked up against _FRAME_CLASSES by the storage factory; unique across frames.

abstract handshake(m)

Readiness-probe payload, echoing SessionMarkers.ready.

Return type:

str

abstract frame(cmd, m)

Full write payload that runs cmd bracketed by the sentinels.

Return type:

str

abstract recover(m)

Re-synchronization payload, echoing SessionMarkers.recover.

Return type:

str

abstract end_pattern(m)

Regex marking the end of a command’s output in the stream.

The session compiles this once per session and uses it both to detect completion in the streaming read loop and to bound parsing.

Return type:

Pattern[str]

abstract marks_begin(data, m)

Return True if data is the chunk carrying the BEGIN sentinel.

Return type:

bool

abstract parse_output(buffer, cmd, m)

Extract the command’s output from the accumulated buffer.

Return type:

str

abstract extract_retcode(buffer, m)

Recover the command’s exit code; -1 when none can be read.

Return type:

int

class otto.host.command_frame.BashFrame

Bases: CommandFrame

POSIX bash dialect: echo brackets and $? baked into the END marker. The default frame for SSH/telnet/local unix sessions.

type_name : --is-rst--:py:data:`~typing.ClassVar`\ \[:py:class:`str`] = 'bash'

Lab-data string for this dialect (e.g. 'bash'). Looked up against _FRAME_CLASSES by the storage factory; unique across frames.

handshake(m)

Readiness-probe payload, echoing SessionMarkers.ready.

Return type:

str

frame(cmd, m)

Full write payload that runs cmd bracketed by the sentinels.

Return type:

str

recover(m)

Re-synchronization payload, echoing SessionMarkers.recover.

Return type:

str

end_pattern(m)

Regex marking the end of a command’s output in the stream.

The session compiles this once per session and uses it both to detect completion in the streaming read loop and to bound parsing.

Return type:

Pattern[str]

marks_begin(data, m)

Return True if data is the chunk carrying the BEGIN sentinel.

Return type:

bool

parse_output(buffer, cmd, m)

Extract the command’s output from the accumulated buffer.

Return type:

str

extract_retcode(buffer, m)

Recover the command’s exit code; -1 when none can be read.

Return type:

int

class otto.host.command_frame.ZephyrFrame

Bases: CommandFrame

Stock Zephyr RTOS shell dialect (3.7 / 4.4 LTS).

The Zephyr shell is not bash: no command substitution, no $?, no generic echo. otto frames each command as four CR-separated lines:

__OTTO_<id>_BEGIN__   rejected -> shell emits `<token>: command not found`
<the real command>    real output appears here
retval                stock Zephyr builtin: prints <cmd>'s exit code
__OTTO_<id>_END__     rejected -> shell emits `<token>: command not found`

The four-line order is load-bearing: retval must run immediately after <cmd> (any command, even an unknown one, overwrites the shell’s stored return value). otto never depends on the prompt text and never modifies the firmware — retval is a default-on builtin and the markers are just rejected input.

Parsing is positional. The shell prints its prompt after every executed line and does not echo input, so between the BEGIN error line and retval’s integer line the slice is exactly [prompt, <output...>, prompt] — dropping the bracketing prompt lines yields the output without ever reading the prompt text. ANSI escapes (the colored prompt) are stripped first.

type_name : --is-rst--:py:data:`~typing.ClassVar`\ \[:py:class:`str`] = 'zephyr'

Lab-data string for this dialect (e.g. 'bash'). Looked up against _FRAME_CLASSES by the storage factory; unique across frames.

handshake(m)

Readiness-probe payload, echoing SessionMarkers.ready.

Return type:

str

frame(cmd, m)

Full write payload that runs cmd bracketed by the sentinels.

Return type:

str

recover(m)

Re-synchronization payload, echoing SessionMarkers.recover.

Return type:

str

end_pattern(m)

Regex marking the end of a command’s output in the stream.

The session compiles this once per session and uses it both to detect completion in the streaming read loop and to bound parsing.

Return type:

Pattern[str]

marks_begin(data, m)

Return True if data is the chunk carrying the BEGIN sentinel.

Return type:

bool

extract_retcode(buffer, m)

Recover the exit code from retval’s output.

retval prints a bare signed integer on its own line just before the END marker. Take the last such line in the region preceding END. Returns -1 if no integer is found.

Return type:

int

parse_output(buffer, cmd, m)

Extract the command’s output from the framed response, positionally.

Between the BEGIN error line and retval’s integer line the shell emitted [prompt, <command output...>, prompt] (a prompt after each of the two executed lines: the rejected BEGIN, then the command). Drop the bracketing prompt lines.

Return type:

str

class otto.host.command_frame.ZephyrSerialFrame

Bases: ZephyrFrame

Zephyr 3.7+ dialect for a serial/UART shell reached over a raw byte bridge (e.g. QEMU -serial telnet:<ip>:<port>,server).

Identical framing and parsing to ZephyrFrame — only the handshake differs. The in-guest SHELL_BACKEND_TELNET honours otto’s IAC DONT ECHO and stops echoing input, which is why the stock ZephyrFrame handshake assumes a non-echoing shell. A UART shell behind a -serial telnet: bridge never sees that IAC (QEMU consumes it), so it keeps echo on; the echoed END marker would then match otto’s read loop before the command’s real output arrives, desyncing every command by one. Disable echo once, up front — shell echo off is a stock builtin. The readiness marker (rejected as an unknown command) still comes back via the shell’s error handler, which is shell output, not input echo, so the probe is unaffected. Mirrors repo1's ZephyrInlineRetcodeFrame for 2.7.

type_name : --is-rst--:py:data:`~typing.ClassVar`\ \[:py:class:`str`] = 'zephyr-serial'

Lab-data string for this dialect (e.g. 'bash'). Looked up against _FRAME_CLASSES by the storage factory; unique across frames.

handshake(m)

Readiness-probe payload, echoing SessionMarkers.ready.

Return type:

str

otto.host.command_frame.register_command_frame(type_name, cls)

Make a custom CommandFrame subclass available to lab data.

Call from an init module listed in .otto/settings.toml — the same pattern otto.host.embedded_filesystem.register_filesystem() follows. Once registered, lab-data entries can reference the subclass by type_name in the command_frame field.

Return type:

None

Raises

ValueError

If type_name doesn’t match cls.type_name (the registry key and the class constant should agree).

otto.host.command_frame.build_command_frame(type_name)

Construct the CommandFrame registered under type_name.

Return type:

CommandFrame

Raises

ValueError

If type_name is not registered. The error lists the registered names so a typo is diagnosable from the message alone.