Panio hardware-CI integration — Design¶
Companion to
gap-analysis.md. Defines one ecosystem-agnostic panio device-control API, then two thin adapters (LAVA, Fuchsia/botanist) that consume it. Includes a minimum-viable path and a full path, the exact CLI/socket surface each adapter touches, and a blunt verdict per ecosystem.Guiding constraints (owner-set): (1) the interactive, agent-driven bringup workflow (dashboard, OCR, HID,
tio, JSONL) must not regress — CI is additive; (2) the owner wants agent write-to-serial and a future JTAG backend anyway, so the agnostic API is designed for those, not just CI; (3) panio is the owner's project and may be refactored freely, so we choose the right shape over the timid one where they differ.
External contracts cited inline are verified May 2026; see gap-analysis.md §0 for the two
points where the verified source overrides the original brief.
1. Design principle: verbs backed by per-target backends¶
Both ecosystems, the agent workflow, and future JTAG need the same primitives in different shapes. So model panio's device control as stable verbs whose implementation is a selectable per-target backend, and expose every live primitive as both a discrete CLI verb and a machine-drivable socket off the daemon that already owns that hardware.
┌───────────────────────── panio device-control API ─────────────────────────┐
agent / CLI ───────► │ power {on|off|reset|state} serial {send|attach|log|...} deploy debug │
LAVA adapter ──────► │ │ │ │ │ │
botanist adapter ──► │ power backend serialcap (owns UART) netboot jtag │
│ {script|dtr|pdu|jtag} ├─ JSONL log (tee) (stands backend │
│ ├─ WebSocket /stream (exists) down in {openocd}│
│ ├─ raw TCP listener (NEW) CI) │
│ ├─ PTY device path (NEW) │
│ └─ write arbiter (tio|sock|send) │
└──────────────────────────────────────────────────────────────────────────────┘
The serial daemon is already a fan-out supervisor (read → broadcast + JSONL tee; write via
write_tx). We add two raw egress shapes and a write arbiter — siblings to the existing
WebSocket/JSONL, not replacements. That keeps the interactive workflow intact by construction.
2. The agnostic device-control API (target contract)¶
2.1 Power¶
New discrete verbs (over existing mechanisms; backend selected per target):
| Verb | Semantics | Backend mapping |
|---|---|---|
panio power on |
apply power; DUT must begin booting unattended | PDU script "on" / board-boots-on-power / JTAG reset run |
panio power off |
cut power | PDU script "off" / DTR long-press (≥3 s) |
panio power reset |
off + delay + on (= hard reset) | existing power-cycle script / PDU / JTAG SRST |
panio power state |
report on/off (read-only, exists) | sense signal via daemon /status |
Config (per target) gains an explicit power backend, e.g.:
[power]
backend = "script" # "script" | "dtr" | "pdu" | "jtag"
on_cmd = "pdu --port 3 on" # used by "script"/"pdu"
off_cmd = "pdu --port 3 off"
reset_cmd = "pdu --port 3 cycle" # falls back to off+sleep+on if unset
# dtr backend reuses existing power_serial_interface + pulse durations
Backward-compat: keep power_cycle_cmd working (maps to reset/script). Exit codes stay
0/1.
As shipped (see §8 and power.md): the [power] block landed as plain generic hooks —
cycle_cmd/on_cmd/off_cmd/state_cmd, no backend enum, no power_cycle_cmd alias —
with power on/power off subcommands and the existing top-level power-cycle/power-state
verbs (no separate power reset/power state).
2.2 Serial (the core work)¶
serialcap stays the single exclusive owner of the UART and gains, off the same supervisor:
- Raw TCP listener —
panio serial attach --tcp [--port N](or always-on when configured). A client connecting gets a raw, bidirectional, persistent byte stream: all UART output, and bytes written back are injected via the existingwrite_txpath. This is the ser2net equivalent → LAVA'sconnection_command = telnet <host> <port>. - PTY proxy —
panio serial attach --ptyprints/records a slave device path (e.g./dev/pts/N, optionally a stable symlink under$XDG_RUNTIME_DIR/panio/…). Bytes flow raw both ways. botanist is handed this path asDeviceConfig.serialand opens it like a real UART, while panio keeps owning the physical port (so JSONL/dashboard keep working). panio serial send <bytes|->— one-shot write to the port (owner's wanted feature; agent-friendly; samewrite_txchannel).--enter,--hex, stdin supported.- Write arbitration — because
tio, TCP clients, PTY clients, andsendcan all write, define a policy. Recommended v1: cooperative, last-writer-wins with an advisory lock surfaced in/status(who currently holds write), plus an opt-in--exclusivefor CI attach so the orchestrator isn't fighting a straytio. (Decision flagged below.)
Existing surfaces unchanged: JSONL log + rotation, /stream WebSocket, serial log,
serial dtr/reset, tio via serial connect, dashboard.
2.3 Deploy¶
No new deploy engine. In CI, netboot stands down; the orchestrator owns deploy (LAVA
TFTP; botanist pave). Keep netboot for interactive bringup and non-CI boards. Add a guard so
netboot start warns/refuses when a CI attach is active on the same interface (avoid DHCP/TFTP
contention). A future "panio serves images to LAVA" path is explicitly deferred.
2.4 Boot detect (optional ergonomics)¶
panio serial wait --match <regex> [--timeout ms] [--since <seq>] → exits 0 on match,
non-zero on timeout, prints the matching line. Reads the existing JSONL/stream; not required
by either orchestrator (LAVA matches prompts:, botanist polls summary.json) but valuable for
the agent workflow and the minimum-viable CI smoke path.
2.5 Debug / JTAG (extension point; implement later)¶
Reserve verbs panio debug {halt|resume|reset|gdb} and a [jtag] config block selecting an
openocd backend that exposes the GDB (:3333) / Tcl (:6666) sockets. v1 ships the config
schema + verb stubs as the extension point; the OpenOCD backend is a follow-on.
3. Adapter A — LAVA lab¶
panio becomes the thing a LAVA dispatcher (Debian host) shells out to for one board. LAVA owns deploy/boot/test; panio provides power + raw serial.
Serial: run ser2net (or panio's own raw TCP listener) pointed at panio's serial
endpoint, exposing telnet <host> <port>. Two wiring options:
- Simplest: panio serial attach --tcp --port 7001 --exclusive and set
connection_command = telnet localhost 7001.
- ser2net-classic: point ser2net at the PTY from serial attach --pty. Either yields
the raw bidirectional persistent stream LAVA requires.
Power: a generated device dictionary whose commands shell out to panio:
{% extends "<base-device-type>.jinja2" %}
{% set power_on_command = 'panio power on --target {{ panio_target }}' %}
{% set power_off_command = 'panio power off --target {{ panio_target }}' %}
{% set hard_reset_command = 'panio power reset --target {{ panio_target }}' %}
{% set connection_command = 'telnet localhost {{ serial_tcp_port }}' %}
power_on
must start the boot — ensure the board/PDU wiring honors that.)
Deploy: none in panio — LAVA's dispatcher downloads + serves TFTP. panio netboot is off for LAVA targets.
Host OS: Debian Linux (LAVA workers are Debian-only). Keep macOS for interactive bringup.
Deliverable: a small generator panio lava device-dict --target <t> that emits the
device dictionary + a device-type template snippet, plus a "first-device" doc.
4. Adapter B — Fuchsia / botanist¶
panio registers the control host as a Swarming bot advertising the board's dimensions
(get_dimensions() → device_type:<board>, entry in bots.cfg, matching test_platforms
entry in //build/testing/platforms.gni; the board is RFC-0130 Experimental tier =
self-hosted CI). The Swarming task runs botanist run → testrunner.
Serial: emit a botanist device config whose serial points at panio's PTY:
{ "network": { "nodename": "<board>", "ipv4": "" },
"keys": [],
"serial": "/run/panio/<target>/serial.pty" }
serial.Server, and creates $FUCHSIA_SERIAL_SOCKET for
testrunner (which drives run-test-suite over serial when $FUCHSIA_SSH_KEY is unset). The
socket is botanist's, downstream of panio — panio only supplies the device-file path
(gap-analysis §0 DISCREPANCY 1). panio keeps owning the physical UART, so JSONL/dashboard
stay live during the run.
Power: not a device-config field (DISCREPANCY 2). Provide power at the bot-host /
recipe layer — a wrapper invoked before/around botanist run that calls
panio power {on|reset|off} (and/or implement a botanist/power method that shells to
panio). Confirm against tools/botanist/target/device.go in a checkout before building.
Deploy: botanist owns pave/zedboot + CIPD/CAS. panio netboot off for Fuchsia targets.
Deliverable: panio botanist device-config --target <t> emitter, a bot_config.py
get_dimensions() snippet, and a thin power-wrapper for the recipe/bot-host layer.
5. Minimum-viable vs full¶
Minimum viable (recommended first milestone) — "power + raw serial, orchestrator owns deploy":
- power on/off/reset verbs (§2.1) + raw TCP serial listener (§2.2.1) + PTY (§2.2.2)
+ serial send (§2.2.3) + write arbiter (§2.2.4).
- LAVA device-dict generator; botanist device-config emitter + power wrapper.
- netboot stands down under CI; Linux CI host.
- This is enough to bring a board up under both orchestrators. It is also exactly the
owner's wanted "agents read+write serial" feature.
Full path (later):
- serial wait --match boot-detect helper.
- JTAG backend (reset/deploy/debug + GDB socket).
- Optional: panio-serves-images LAVA deploy method (non-standard; only if a board can't use
LAVA's TFTP).
- Optional: KCIDB emission — only if the owner decides panio should grow a result story
(this is above the device-control line; see verdict).
6. Verdict — is panio suitable?¶
KernelCI via LAVA lab: SUITABLE WITH CHANGES — good fit. panio's model (one host wired to one board, owns power+serial) is exactly LAVA's lab model. Required changes are modest and ranked: (1) discrete power verbs [small], (2) raw TCP serial passthrough [medium], (3) device- dictionary generator [small], (4) run on Debian, netboot off [trivial]. The only "can't" is macOS as a LAVA worker — not supported upstream; use Linux. No blockers.
KernelCI via KCIDB: OUT OF SCOPE (recommended). This path needs a real test-execution + result-collection story and KCIDB schema emission — that's orchestration/results, above the device-control line panio occupies. Building it would turn panio into a mini-LAVA. Recommend no, unless the owner explicitly wants panio to grow upward. (Decision flagged.)
Fuchsia / Swarming+botanist: SUITABLE WITH CHANGES — fit is good but seam is subtler.
panio can be the bot's device-control layer. The serial seam is a PTY (not a socket
panio serves — DISCREPANCY 1), and power is a recipe/bot-host hook (not a device-config
field — DISCREPANCY 2). Both are tractable: PTY proxy [medium], power wrapper [small], device-
config emitter [small], bot registration [small + upstream coordination for bots.cfg/
platforms.gni]. Biggest non-code dependency: a community board is RFC-0130 Experimental
tier, so this is self-hosted CI, which is fine for the stated goal. One thing to verify in a
real Fuchsia checkout before coding: the exact DeviceConfig/power plumbing (pkg.go.dev hides
unexported fields).
Net: panio is a genuinely good device-control layer for both, with one shared core change (raw serial passthrough in two shapes — TCP + PTY) that also delivers the owner's agent-write feature, plus small per-ecosystem adapters. It is not and should not become a test orchestrator or result producer.
7. Decisions (owner-confirmed 2026-05-29)¶
- KCIDB scope — OUT OF SCOPE. panio stays a pure device-control layer; KernelCI is targeted via the LAVA-lab path only. LAVA/Maestro produce results.
- Fuchsia serial port ownership — PTY proxy (panio keeps the physical port and its JSONL/dashboard during CI; hands botanist a PTY device-file path).
- Serial write arbitration — cooperative, last-writer-wins + advisory lock surfaced in
/status, plus opt-in--exclusivefor CI attach. - JTAG in v1 — extension point only: ship the
[jtag]/[debug]config schema + verb stubs now; OpenOCD backend is a follow-on. - CI host OS — Linux-only for the CI control host; macOS remains a first-class interactive-bringup host.
- First milestone — partially landed (via the Rust control-plane port):
panio serial send, the[power]hook block (cycle_cmd/on_cmd/off_cmd/state_cmd), andpower on/offare shipped. The PTY proxy, write arbiter/--exclusive, raw TCP listener, CI stand-down guard, and[jtag]stubs remain (see the slicing in §8).
8. Implementation decisions (resolved 2026-05-29)¶
- Native TCP listener. serialcap (Rust) grows a built-in raw bidirectional TCP listener
(
serial attach --tcp); ser2net-on-PTY remains documented as the LAVA-blessed fallback. Keeps panio self-contained and gives agents a trivial raw endpoint. - PTY/TCP listeners live in the daemon. serialcap owns the port, so both endpoints are added
to the Rust daemon off the existing read-broadcast /
write_txchannels — a Python wrapper can't see the bytes. - Serial write lock auto-releases on disconnect. The
--exclusivehold is tied to the client connection (drops when the socket closes), with an optional--lock-timeoutsafety net; the current holder is shown in/status. [power]block is a breaking change (accepted). Clean[power]block, nopower_cycle_cmdalias;AGENTS.mdguidance is updated so the agent reconfigures targets on redeploy.
Milestone slicing of M1 (smallest reversible steps, each with tests)¶
Current focus: the owner is doing a Fuchsia port with an agent, so M1 leads with the Fuchsia-critical path (PTY + power); the LAVA TCP listener follows in the same milestone but need not block first hardware bring-up of the Fuchsia target.
- serialcap: PTY proxy (SER-2) +
serial attach --pty— FuchsiaDeviceConfig.serial. - ~~
panio serial send(SER-4)~~ — shipped (agent write-to-serial viaPOST /input). - ~~
[power]config block +power on/offverbs (PWR-1..6) +AGENTS.mdupdate (PWR-7)~~ — shipped (the cycle verb stayedpower-cycle; no separatepower reset). - Write arbiter: advisory lock,
/statusholder,--exclusive+ auto-release (SER-5). - serialcap: raw TCP listener (SER-1) +
serial attach --tcp— LAVA; can trail. - netboot CI stand-down guard (DEP-1);
[jtag]/debugverb stubs (JTAG-1).
Then Adapter B first (FX-1 device-config emitter, FX-2 power wrapper, FX-4 verify against a real
Fuchsia checkout), since that's the active use case; Adapter A (LAVA) after.
Regression guard throughout: JSONL, /stream, tio, dashboard, serial log/dtr/reset (SER-7, NF-1).