Skip to main content

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.

runtimeStatusTake it when
declarativeRunsThe device speaks fixed strings or codes: IR, RS-232, relay, contact
wasmRunsAnything with real parsing, real state, or a real protocol
nativeRunsYou are building the controller itself, or measuring against it
adapterRunsA whole protocol stack already exists as a program
pythonNot 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.

python is accepted by the manifest format and refused by the controller with "declares runtime Python, 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 platformNative, three platformsWASM
roku-1.0.0.junodrv264 KB~750 KB99 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 forty Instances. Per-device state that lived in self would be one bulb's buffer shared by all forty.
  • Per-device state goes in inst.scratch, a BTreeMap<String, Value> that survives a restart. Installer-set properties are inst.properties, read with inst.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: call on_command and 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.

CallWhat 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​

CallbackWhen 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.