Runtimes
How your driver's code gets run. Four of these work today; one is named in the manifest format and is not runnable yet, and this page says which is which rather than leaving you to find out at install time.
runtime | Status | Take it when |
|---|---|---|
declarative | Runs | The device speaks fixed strings or codes: IR, RS-232, relay, contact |
wasm | Runs | Anything with real parsing, real state, or a real protocol |
native | Runs | You are building the controller itself, or measuring against it |
adapter | Runs | A whole protocol stack already exists as a program |
python | Not yet | — |
Take the first one that works. Most AV rack gear is declarative and stays that way for its
whole life. Everything else is wasm.
pythonis accepted by the manifest format and refused by the controller with "declares runtimePython, which this controller cannot run yet". It is in the format because the package layout and the ABI were designed around it; nothing runs it today.
Declarative
No code. A commands.toml that maps proxy commands to bytes or to control invocations. Covers
IR, RS-232, relays and contacts — realistically most of an AV rack.
Sending
Three forms, depending on what your control connection is:
# Bytes out a serial port or socket
command.on = { tx = "PWR ON\r", expect = ":", timeout_ms = 3000 }
command.off = { tx = "PWR OFF\r", expect = ":" }
# A command on a control connection — relay, IR, anything
command.open_gate = { control = 1, invoke = "pulse", args = { ms = 500 } }
# An IR code by name from the [ir] table below
command.volume_up = { control = 2, invoke = "send", args = { code = "VOL_UP" } }
Parameters from the proxy command interpolate into tx with format specs:
command.set_input = { tx = "SOURCE {input:02X}\r" }
command.set_volume = { tx = "VOL{level:03d}\r" }
command.set_level = { tx = "@LED,{level},{ramp_ms:d}\r" }
{name} plain · {name:02X} hex, padded · {name:03d} decimal, padded · {name:.1f} fixed
point. A parameter the proxy marked optional must have a default here or be absent from tx.
Serial setup
[transport]
kind = "serial"
baud = 9600
databits = 8
parity = "none"
stopbits = 1
Core issues configure on your bound serial control connection from this block. You do not
call it yourself. serial is the only kind this block accepts — a network device does its
addressing in the manifest's [[transport]] instead.
Receiving
Bytes in become notifications out. delimiter frames the stream; each frame is matched
against every parse in order until one hits.
[receive]
delimiter = "\r"
parse = [
{ regex = "PWR=(\\d+)", notify = "power_changed", args = { on = "$1 == 1" } },
{ regex = "VOL=(\\d+)", notify = "volume_changed", args = { level = "$1" } },
{ regex = "SRC=(\\d+)", notify = "input_changed", args = { input = "$1 + 1000" } },
]
$1, $2… are capture groups. The tiny expression language is + - * /, comparisons, and
&&/|| — enough to map a device's numbering onto your connection ids and no more. Anything
harder means you want a native driver.
Polling
For devices that never volunteer anything:
[poll.power]
tx = "PWR?\r"
every_ms = 5000
[poll.volume]
tx = "VOL?\r"
every_ms = 2000
only_when = "power_changed.on" # skip while the device is off
Responses go through [receive] like any other inbound data. This is the one runtime with a
polling loop of its own — see below for what the others do instead.
Following a control connection
When your device is the control connection — a fireplace that is just a relay — mirror the provider's state as your own:
[[follow]]
control = 1
on = "relay_changed"
notify = "switch_changed"
args = { on = "$closed" }
IR codes
[ir]
format = "pronto"
codes.POWER_ON = "0000 006D 0022 0002 0155 00AA ..."
codes.POWER_OFF = "0000 006D 0022 0002 0155 00AA ..."
codes.VOL_UP = "0000 006D 0020 0000 0155 00AA ..."
Reference them from command.* with invoke = "send". Core hands the payload to whatever
ir_out you are bound to — you never emit a waveform yourself.
When to stop
Declarative runs out when you need to keep state between messages, parse anything nested, or make a decision. At that point rewrite as native; it is a small file and you will not have wasted much.
WASM
A Rust library the controller loads into a sandbox. This is what every certified driver is.
The code is the same code — same DriverModule, same export_driver!, same Cargo.toml. The
only difference is what you build it for:
rustup target add wasm32-wasip1
cargo build --release --target wasm32-wasip1
Everything under Native about writing the driver applies unchanged; read that section for the API. What follows is only what the sandbox adds.
What your driver cannot do
It cannot open a file, open a socket, read the clock, read an environment variable, spawn a thread, or see another driver. Not by policy — by construction. A WebAssembly module can only call what the host hands it, and the controller hands it five functions: enough for Rust's std to start up, report a panic, and ask for entropy.
This is less of a change than it sounds, because a driver could never usefully do those things
anyway. Every side effect a driver has is a HostCall it returns and the controller
performs — that is what makes drivers synchronous, pure, and testable without hardware. If you
find yourself wanting the network, you want HostCall::Http or HostCall::Tx.
The one thing to watch is a dependency that reaches for those behind your back. Pure computation is fine and crypto is fine — the Apple TV driver does SRP, HKDF and ChaCha20-Poly1305 inside the sandbox. Anything that wants a socket or a wall clock is not, and you will find out at build time or at first instantiation, not in somebody's house.
What it costs, and what it saves
Roughly 1.2–2× native on compute, against devices that answer over a network in milliseconds — so, nothing you can measure. The controller compiles your module to machine code when the package is installed and caches it, so start-up is a mapping rather than a compile.
What it saves is a whole class of packaging problem. A native driver has to be built for every
machine a controller might be — macOS on ARM, Linux on x86-64, Linux on ARM — and even then a
library built against one glibc will not load against an older one. driver.wasm has no
architecture, no libc and no symbol versioning. One build, one artifact, every controller:
| Native, one platform | Native, three platforms | WASM | |
|---|---|---|---|
roku-1.0.0.junodrv | 264 KB | ~750 KB | 99 KB |
A runaway driver is stopped
A dispatch that does not return within five seconds is cut off and reported as a warning on that device. A driver that panics traps, is reported, and its module is rebuilt before the next call — a driver holds no state, so nothing is lost. Neither is possible with a native plugin, which shares the controller's address space and takes the house with it.
Native
The same Rust library, loaded directly into the controller with its full privileges and no
sandbox. Prefer wasm: this is here for developing against a local core, and for
measuring what the sandbox costs.
A native package is refused by the registry unless our own CI built it, for the obvious reason. If you are writing a driver for somebody else to install, it is
wasm.
Add the SDK, and build a cdylib:
# Cargo.toml
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
driver-sdk = { git = "https://github.com/junohouse/driver-sdk", branch = "main" }
# A driver is downloaded per project and spends its life waiting on a device rather than in
# its own code, so build it for size. Worth 30–35% with no source change.
[profile.release]
opt-level = "z"
codegen-units = 1
lto = true
strip = true
Track
main. driver-sdk carries no tags, deliberately: a pin that has to be bumped by hand is a pin that ends up years behind the contracts it claims to check.
One type implementing DriverModule, and one line of glue:
use driver_sdk::*;
#[derive(Default)]
struct Bravia;
impl DriverModule for Bravia {
fn on_command(
&self,
inst: &mut Instance,
proxy: LocalId,
cmd: &str,
args: &Args,
) -> Vec<HostCall> {
match cmd {
"on" => vec![HostCall::Tx { control: 0, data: b"*SCPOWR0000000000000001\n".to_vec() }],
"off" => vec![HostCall::Tx { control: 0, data: b"*SCPOWR0000000000000000\n".to_vec() }],
"set_volume" => {
let level = args.get("level").and_then(|v| v.as_u64()).unwrap_or(0);
vec![HostCall::Tx {
control: 0,
data: format!("*SCVOLU{level:010}\n").into_bytes(),
}]
}
other => vec![HostCall::warn(format!("unhandled `{other}`"))],
}
}
fn on_event(
&self,
inst: &mut Instance,
control: LocalId,
note: &str,
args: &Args,
) -> Vec<HostCall> {
// Frame the stream yourself — this is a byte pipe, not a message pipe.
// Partial frames belong in `inst.scratch`, not in `self`.
…
}
}
export_driver!(Bravia);
Three things about that signature are deliberate, and each one is a mistake people make once:
&self, not&mut self. The module is loaded code, shared by every device using the driver. Forty Hue bulbs are one module and fortyInstances. Per-device state that lived inselfwould be one bulb's buffer shared by all forty.- Per-device state goes in
inst.scratch, aBTreeMap<String, Value>that survives a restart. Installer-set properties areinst.properties, read withinst.property("PSK"). - A callback returns work; it does not perform it. You hand back a
Vec<HostCall>and core runs them. That is what makes a driver testable without a controller: callon_commandand assert on what comes back.
A panicking driver is caught at the boundary and reported as a warning rather than taking the controller down. That is a safety net, not a strategy.
Native code is not sandboxed. It runs in-process with the controller's privileges, which is acceptable for drivers built by our own CI and is exactly why anything else is marked third-party. When the WASM runtime lands it will use this same request/response shape, so drivers will not have to change.
Host calls
What a callback can return.
| Call | What it does |
|---|---|
Invoke { control, cmd, args } | Command a bound control connection — relay, IR, serial. Core resolves it to whatever the installer wired it to. |
Tx { control, data } | Raw bytes at a control connection. control: 0 is the driver's own network transport. |
Http(HttpRequest) | Core owns the client, so timeouts, retries and TLS are enforced in one place and no driver ships its own. |
Publish { topic, payload } / Subscribe { topic } | Core is an MQTT client on this device's behalf. One connection per device, no broker. Subscriptions are re-asked after a reconnect. |
Notify { proxy, name, args } | Emit a proxy notification. Validated against your declared capabilities. |
SetState { proxy, key, value } | Write state directly, for a value no single notification parameter implies |
Present { nodes } | Everything behind this device, as it currently is. See below. |
ForNode { node, calls } | Aim the calls inside at one of those nodes rather than at yourself |
Log { level, msg } | Shown in the driver's log pane. HostCall::warn(..) is the shorthand. |
Callbacks
| Callback | When it fires |
|---|---|
on_command(inst, proxy, cmd, args) | A proxy command. Args are pre-validated. |
on_event(inst, control, note, args) | A provider you are bound to said something — bytes back from a serial port, a relay changed |
on_bind(inst) | The device was bound. Where you configure a port or authenticate. |
on_action(inst, action, args) | A driver-declared [[action]] |
on_node_command(inst, node, kind, cmd, args) | A command for a node you presented — delivered to you, the parent |
unsupported() | Features your manifest asks for that this build does not implement yet. Surfaced at install rather than left as silence. |
discover(...) / setup(...) | Finding the hardware, and stepping an installer through configuring it |
Only on_command is required. Everything else has a default, which is what lets the trait
grow without every driver needing a rebuild.
There is no timer
There is no tick and no timer callback. A native driver acts when something calls it: a command, an event from a provider, or bytes arriving on a socket core is holding open for you.
For a device that only speaks when spoken to, that means keepalive = true on the transport
and a device that pushes — which is most of them. A device that genuinely has to be polled and
cannot push is a device the declarative runtime handles better; that is what [poll] is for.
Devices you find at runtime
A hub does not know its own shape until it has asked. Declare what you are allowed to find with
[children], then report an inventory with Present.
Each node carries a stable id of your choosing, a name, the proxy contract it satisfies, and the capabilities that one device has — so two zones of the same class can resolve to different command sets, which is the whole reason capabilities are per node rather than per driver.
A snapshot, never a delta, because a delta protocol needs a resync path and a resync path is a snapshot protocol with extra steps: after a reconnect, say what you have now. Nodes that stop appearing stop being offered; ones already adopted keep their bindings and their history.
Presenting a node does not create a device. It becomes an offer, and an installer adopts it — a hub listing something is not consent to add it to a house.
on_node_command arrives at the parent, with the parent's properties and the parent's
transport, because that is where the connection is. A node is an address within a socket you
own.
Adapter
A protocol stack in its own process, spoken to over a pipe. Zigbee and Z-Wave are this: the stack already exists as a program, and reimplementing it in-process would be a year of work to end up somewhere worse.
[driver]
id = "zigbee.coordinator"
name = "Zigbee"
version = "1.0.0"
runtime = "adapter"
[adapter]
exec = "node"
args = ["zigbee/main.js"]
The package is the tree the child process runs — it is unpacked whole rather than loaded, and
exec is resolved against it. A manifest saying adapter with no [adapter] table describes
nothing that can start.
The wire protocol is newline-delimited JSON on stdin and stdout, defined in driver-sdk so
both ends are the same definition rather than two copies one rename apart. The adapter sends
Hello first, then Present snapshots and Push events; core sends Open, Command and
Action back.
Two properties worth knowing:
- Unknown frames are logged and dropped, not fatal — the opposite of a manifest, where a typo should fail loudly at install. This is written by a program across a version boundary, and a newer adapter adding a field must not brick an older controller in somebody's house.
- The protocol version is declared independently at both ends, and a controller accepts the current one and the one before it. That is what makes an adapter upgrade and a controller upgrade two maintenance windows instead of one.
Two things core does for you, whichever you pick
Commands arriving at your driver are already valid. Core checked the command exists in your resolved contract, that every required parameter is present, that types match, that numbers are in range, and that enums are members. You do not revalidate.
Notifications leaving your driver are checked too. Emitting a notification your declared capabilities do not include is a driver bug and is logged as one. This is deliberate: it catches an over-declared capability at development time rather than in someone's living room.