The host subsystem¶
otto.host is the largest package: it turns a lab-data entry into a live
object you can run commands on, move files to, elevate privileges on, and
power-cycle — over SSH, Telnet, a serial console, or docker exec.
Class hierarchy¶
The concrete host classes and every base between them and
BaseHost — generated from the live classes at build
time, so this diagram tracks the code (each node links to its API page):

What each layer adds:
Class |
Adds |
|---|---|
|
the structural contract; shared verb logic, dry-run + log gates |
lazy-connect |
|
SSH/Telnet sessions, file ops, privilege, kernel modules, toolchains |
|
console-only oneshot semantics, binary load/unload, on-device filesystems |
|
Zephyr RTOS defaults |
|
subprocess on the machine otto runs on |
|
|
LocalHost exists so instructions can mix local
build steps with remote deployment through one interface; every lab gets a
built-in local host, excluded from fleet iteration by default
(The command lifecycle). DockerContainerHost
delegates everything to a parent UnixHost rather than duplicating the
transport stack — that design has its own page: Design: Docker container hosts.
Sessions: persistent run vs stateless oneshot¶
run() executes on the host’s persistent shell
session (HostSession): working directory,
environment, and elevation state survive across calls, and the session’s
expect/send primitives are available for interactive flows. Named
sessions can be opened explicitly (open_session) for parallel stateful
streams.
oneshot() runs each call independently of the
persistent session and of other concurrent oneshot calls, which is what
makes asyncio.gather() fan-out safe. Embedded hosts are oneshot-only —
a serial console has no multiplexed channels to hold a session on.
Two pieces of per-session state matter architecturally:
current_userand elevation. Privilege changes (su,sudo,switch_user) are session state, tracked per session rather than per host — two sessions on one host can run as different users. The host’scurrent_userproperty reports its default session’s effective user.Command framing. How a command is wrapped, echoed, and its completion detected is a shell-dialect concern, factored into
CommandFrame— a small stateless value object the session holds rather than is (BashFramefor POSIX shells,ZephyrFramefor the Zephyr shell). Per-session sentinels are passed in as values, keeping frames pure and unit-testable without a live session.
Connections, terms, and hops¶
ConnectionManager owns a host’s network
resources and builds them lazily — constructing a host object opens nothing;
the first verb that needs a connection does. The term (interactive
transport: ssh or telnet) is pluggable through the TERM_BACKENDS
registry, with TermContext — a frozen dataclass of construction inputs —
as the public seam a custom backend implements against.
Hops are first-class: a host whose hop field names another lab host is
reached by tunneling through that host’s SSH connection
(HopTransport), and hops chain for multi-hop
paths. The hop chain lives below the term/transfer layer, so every backend
— including file transfers and netcat streams — works through hops without
special-casing.
File transfer and capability resolution¶
All transfer backends live in otto.host.transfer, one class per selector,
sharing BaseFileTransfer. Each backend
declares which host_families it serves (sftp/scp/ftp/nc for Unix
hosts; console/tftp for embedded targets).
Which backend a host actually uses is resolved from three inputs:
The host’s menu —
valid_transfers/valid_termsin lab data declare what the machine supports.Preferences —
[host_preferences]tables (from lab data and product repos) rank capabilities per host selector; the first preference present in the menu wins, and product preferences take precedence over lab preferences.Per-invocation overrides —
--transfer/--termon the CLI, or keyword overrides onget_host(), have the final word.
The same mechanism resolves per-protocol option tables (e.g. ssh_options),
so “prefer netcat on this board family, with these ports” is data, not code.
See Host configuration (hosts.json) for the user-facing rules.
From lab data to a host object¶
Host construction is a boundary crossing, described fully in
Data at the boundary. In brief: the os_type field selects an
OsProfile — a named bundle of field defaults
over a base family (unix, embedded) — the profile picks the host class
and its pydantic spec, defaults and host fields are merged (host fields win),
the spec validates, and to_host() builds the runtime object. Custom host
classes and profiles register through register_host_class /
register_os_profile (OS Profiles & Custom Host Classes).
Profiles are the data half of otto’s customization split: they name a
bundle of defaults many hosts share. The code half is products —
Product bundles stage/install/uninstall behavior,
and product repos attach products to hosts by registering a provider
function (register_product_provider) that otto applies to each host at
ingest. Declaring products in lab data is deliberately not supported: lab
data stays product-agnostic and the two evolve independently.
Embedded strategies¶
Embedded hosts compose three more stateless strategy objects, each with its own registry (Registries and the pluggable CLI):
CommandFrame— the shell dialect.BinaryLoader— how a binary payload gets onto the target and verified (llext-hexdrives Zephyr’s LLEXT loader over the console).EmbeddedFileSystem— what on-device filesystem (if any) transfers and file ops may assume: FAT on a RAM disk, LittleFS, or none, with graceful degradation.
Power control (PowerController) and privilege
escalation (otto.host.privilege) follow the same pattern: an abstract
strategy, a registry, and per-host selection from lab data.