lager (Rust)
First-class Rust access to Lager nets, so embedded
developers can write their entire hardware-in-the-loop test suite in Rust
and run it with cargo test — no Python required.
The crate is a pure HTTP/JSON client of the Lager box's API (port 9000):
power supplies, battery simulators, e-loads, solar simulators, GPIO, ADC,
DAC, thermocouples, watt meters, energy analyzers, SPI, I2C, USB hub ports,
robot arms, webcams, routers, and streaming UART — plus the box-level
capabilities (its own BLE adapter, WiFi interface, BluFi ESP32 provisioning,
USB bus enumeration, box-side dfu-util flashing, and box lock/reservation).
Debug-probe nets (flash / erase / reset / memory reads / RTT) talk to the
box's debug service on port 8765.
The package publishes as
lager-net(the barelagername is taken on crates.io), but the library target is namedlager, so your code readsuse lager::LagerBox;.
Quickstart
# Cargo.toml
[]
= { = "lager-net", = "0.5" }
// tests/boot.rs
use ;
LAGER_BOX_HOST=192.168.1.42
LagerBox::connect("hostname-or-ip") also works, with an optional
host:port or full URL.
Net types
| Handle | Constructor | Highlights |
|---|---|---|
Supply |
lager.supply(name) |
set_voltage, set_current, enable/disable, OCP/OVP, state() |
Battery |
lager.battery(name) |
set_soc, set_voc, model/capacity/mode, state() |
Eload |
lager.eload(name) |
set(EloadMode::Cc, 0.5), setpoint, state() |
Solar |
lager.solar(name) |
set/stop PV simulation, irradiance/set_irradiance, voc, mpp_voltage/mpp_current, resistance, temperature |
Gpio |
lager.gpio(name) |
input, output, toggle, wait_for_level (hardware-timed) |
Adc / Dac |
lager.adc(name) / lager.dac(name) |
read(); set(volts) |
Thermocouple |
lager.thermocouple(name) |
read() in °C |
WattMeter |
lager.watt_meter(name) |
power/current/voltage/all(duration) |
EnergyAnalyzer |
lager.energy_analyzer(name) |
read_energy, read_stats |
Spi |
lager.spi(name) |
configure, read, write, read_write, transfer |
I2c |
lager.i2c(name) |
configure, scan, read, write, write_read |
UsbPort |
lager.usb(name) |
enable/disable/toggle/state, cycle (off-wait-on, reports re-enumeration), recover |
Arm |
lager.arm(name) |
position, move_to/move_by, go_home, motor enable/disable, set_acceleration |
Webcam |
lager.webcam(name) |
start/stop MJPEG stream, url, status |
Router |
lager.router(name) |
system_info, interfaces/clients/leases, block_internet, generic command(action, params) |
DebugNet |
lager.debug(name) |
connect, flash, erase, reset, read_memory, info/status, rtt (blocking), rtt_interactive (feature rtt) |
Uart |
lager.uart(name)? (feature uart) |
streaming read, non-blocking try_read, write, wait_for(b"boot ok", ...) |
Box-level capabilities (the box's own hardware, no net name):
| Handle | Constructor | Highlights |
|---|---|---|
Ble |
lager.ble() |
scan/scan_named, info/connect (GATT enumeration), disconnect |
Wifi |
lager.wifi() |
status, scan, connect(ssid, password), delete |
Blufi |
lager.blufi() |
scan, connect, provision(device, ssid, password), wifi_scan, status, version |
Dfu |
lager.dfu() |
box-side dfu-util: list, download(firmware, opts), detach |
USB bus enumeration: lager.usb_devices() (or usb_devices_matching with
vid/pid/serial filters) returns every USB device on the box's bus straight
from sysfs — vid, pid, iSerial, product, manufacturer, bus/dev numbers, and
speed. It takes a few milliseconds with no exclusive device access, so it is
safe to poll while waiting for a DUT to re-enumerate.
Box locking (the same /lock endpoints lager boxes lock uses):
lager.lock(user) / lock_with(user, holder_type, ttl) /
lock_heartbeat(user) / unlock(user) / lock_status(), plus a
lager.lock_guard(user) RAII guard (blocking client) that releases on drop.
Locks with a TTL auto-expire when heartbeats stop, so a crashed CI runner
cannot wedge the box.
Discovery and box health: lager.nets(), lager.health(), lager.status()
(status().capabilities.net_command tells you the box image is new enough;
net_command_roles / ble_command / wifi_command / blufi_command report
the newer arm/webcam/router roles and box-level endpoints), and
lager.nets_state() — brief live state for every saved net under the box's
shared probe budget, with a reason on every net it could not read, so one
wedged instrument neither fails the request nor hides the healthy ones.
Minimum box version
Most of the API works on any box that serves POST /net/command. A few
newer surfaces need a newer box image and fail with
Error::UnsupportedByBox (naming the required version) on older ones:
| Crate API | Requires box |
|---|---|
UsbPort::state() |
>= 0.29.0 |
usb_devices() / usb_devices_matching() |
>= 0.33.0 |
dfu() (list/download/detach) |
>= 0.33.0 (plus dfu-util installed: lager box-config apt add dfu-util) |
lock() / unlock() / lock_status() / lock_heartbeat() |
any box serving /lock on port 9000 |
nets_state() |
>= 0.34.0 |
set_safety_limits() / clear_safety_limits() / safety_limits() |
>= 0.35.0 (status().capabilities.safety_limits) |
DebugNet::rtt_interactive() (feature rtt) |
>= 0.35.0 |
UsbPort::cycle() / recover() |
>= 0.39.0 |
ConnectOptions::halt honored on the OpenOCD backend |
>= 0.43.0 |
Features
| Feature | Default | What you get |
|---|---|---|
blocking |
yes | LagerBox on ureq — tiny dependency tree, no tokio |
async |
no | AsyncLagerBox on reqwest/tokio; same methods, .awaited |
uart |
no | Uart streaming sessions over the box's Socket.IO /uart namespace |
rtt |
no | bi-directional RttSession over the box's Socket.IO /rtt namespace (box >= 0.35.0) |
Both clients execute the exact same request builders and response parsers
(the wire module), so the two transports cannot drift apart.
= { = "lager-net", = "0.5", = ["async"] }
Parallel tests and instrument safety
The box serializes access per physical instrument: every net command runs
under a per-device lock in the box's single-owner hardware service, so
parallel cargo test threads can never interleave I/O on one instrument
(e.g. a LabJack shared across GPIO/ADC/SPI nets, or a Keithley shared by
supply and battery roles). Tests sharing a net still observe each other's
state changes — partition nets across tests, or run
cargo test -- --test-threads=1 when that matters.
Timeout budgets mirror the Lager CLI: quick commands use 10 s; watt/energy
integration windows and wait_for_level widen (or drop) the client timeout
automatically so a healthy long measurement is never aborted mid-flight.
Boxes behind an authenticating gateway
Boxes fronted by an authenticating reverse proxy reject unauthenticated
traffic with 401 + an X-Gateway-Auth-Url header (the same contract the
Lager CLI speaks). The crate handles this transparently:
-
CLI session reuse (zero config): after
lager login <auth_url>, the crate picks up the session from the CLI's token store (~/.lager_gateway_auth, orLAGER_GATEWAY_AUTH_FILE), attachesAuthorization: Bearerto every request — including debug-service and UART Socket.IO traffic — and refreshes expired access tokens automatically. The box→auth-server link is learned from the gateway's discovery header on first contact and the denied request is retried within the same call. -
Pinned token (CI): supply a token directly when there is no CLI login on the machine:
#or set the
LAGER_GATEWAY_TOKENenvironment variable.
Plain (ungated) boxes are unaffected: no header is sent and none of this
code runs. When a gateway asks for auth and no usable credential exists,
calls fail with Error::AuthRequired naming the auth server to log into.
Errors
Everything returns lager::Result<T> with a single Error enum:
Connection— box unreachable (network/Tailscale/box offline)Timeout— the box stalled past the (already widened) budgetBox { status, message }— the box refused or the hardware failedUnsupportedByBox— the box image predates this endpoint (HTTP 501, a missing route, or a pre-0.29.0staterejection); the message names the box version requiredAuthRequired— the box's gateway wants a bearer token and none is available: runlager login <auth_url>or setLAGER_GATEWAY_TOKENNotSupportedByBox— the net type is a documented stub (see below)
Firmware, flashing, and RTT
DebugNet drives a J-Link/OpenOCD debug probe through the box debug service
(port 8765, published on the box host):
let debug = lager.debug;
debug.connect?;
debug.erase?;
debug.flash?; // .hex/.elf/.bin inferred from extension
debug.reset?;
let head = debug.read_memory?;
// Stream RTT logs (blocking client) and assert on target output:
use ;
let mut lines = new.lines;
assert!;
With the rtt feature (box >= 0.35.0), the session can also write to
the target's RTT down-channel, so firmware with an RTT console can be
driven from the test:
use Duration;
let debug = lager.debug;
debug.connect?; // gdbserver must be up first
let mut rtt = debug.rtt_interactive?;
rtt.write_str?;
let out = rtt.wait_for?;
rtt.stop?;
Two prerequisites worth knowing before debugging a "silent" session: the
box refuses start_rtt without a connected gdbserver (call
debug.connect() first), and writing needs a firmware-declared RTT
down buffer on the channel — defmt-rtt alone only provides the up
buffer, and a target without a down buffer silently discards what it is
sent. Bytes are raw in both directions: defmt output stays compressed
binary (pipe it through defmt-print -e <elf> to read it), while
plain-text consoles work with wait_for directly.
connect_with(&ConnectOptions { .. }) takes the probe options: speed,
force, halt (reset-then-halt; honored on the OpenOCD backend by box >=
0.43.0), and per-connection jlink_script / openocd_config contents that
override a script saved on the net (an OpenOCD override must be a complete
cfg that selects the adapter driver). Halt-in-place — OpenOCD's bare halt,
no nRESET pulse — is not reachable over the debug service's HTTP API; see
MISSING_ENDPOINTS.md.
If the debug service is reached through an SSH tunnel, point the crate at it
with LagerBox::builder(host).debug_service_url("http://127.0.0.1:8765") or
the LAGER_DEBUG_SERVICE_URL env var.
Per-net safety limits
Boxes >= 0.35.0 enforce per-net voltage/current ceilings in their hardware
service, out of reach of test scripts — a setpoint (or inline ovp=/ocp=
trip) above a ceiling is refused before it touches the instrument:
use SafetyLimits;
lager.set_safety_limits?;
let limits = lager.safety_limits?; // rides on /nets/list
lager.clear_safety_limits?; // back to unrestricted
A set call replaces the net's whole limits record (unset fields are
removed, not preserved), allow_destructive: Some(false) makes the box
refuse erase/flash on the net, and there is deliberately no max_power —
the box refuses the key rather than storing a limit nothing enforces.
Not yet on the HTTP API
Oscilloscope / logic-analyzer workflows are not exposed on any box HTTP API
yet. Scope ships as a documented stub whose methods return
Error::NotSupportedByBox; see
MISSING_ENDPOINTS.md for the endpoint sketch.
Hardware smoke tests
The crate's own test suite is hermetic (cargo test runs against a mock
box). To exercise a real box:
LAGER_BOX_HOST=192.168.1.42
# opt into net-specific tests:
LAGER_BOX_HOST=... LAGER_TEST_SUPPLY_NET=supply1
There is also a runnable example:
LAGER_BOX_HOST=192.168.1.42
Requirements
- A Lager box with software new enough to serve
POST /net/command(checklager.status()?.capabilities.net_command). - Rust 1.75+.
License
Apache-2.0