Runtimes
Three ways to write a driver, one host ABI behind all of them. Take the first that works.
Declarative
No code. A commands.toml that maps proxy commands to bytes or to control invocations.
Covers IR, RS-232, relays, contacts, and simple HTTP — 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.
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 Python.
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.
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 Python; it is a small file and you will not have wasted much.
Python
A subprocess speaking the host ABI over stdio (length-prefixed msgpack). Out of process on purpose: the GIL and your dependency tree stay outside core.
from juno import Driver, host
class Bravia(Driver):
def on_bind(self):
self.buf = bytearray()
host.set_timer(1, 10_000, repeat=True)
def on_command(self, binding, cmd, args):
if cmd == "on":
host.net_send(0, b"*SCPOWR0000000000000001\n")
elif cmd == "set_input":
host.net_send(0, f"*SCINPT{self.inputs[args['connection']]}\n".encode())
elif cmd == "set_volume":
host.net_send(0, f"*SCVOLU{args['level']:010d}\n".encode())
def on_receive(self, source, note, params):
self.buf.extend(bytes.fromhex(params["data"]))
while b"\n" in self.buf:
line, _, rest = self.buf.partition(b"\n")
self.buf = bytearray(rest)
self.handle(line)
def handle(self, line):
if line.startswith(b"*SNPOWR"):
host.notify(1, "power_changed", {"on": line[7:] != b"0" * 16})
def on_timer(self, tid):
host.net_send(0, b"*SEPOWR################\n")
requirements.txt in the package is installed into a per-driver venv on install. Keep it
short — every dependency is a thing that breaks on a controller you cannot log into.
WASM
Compiled to wasm32-unknown-unknown, run in wasmtime, one instance per driver, fuel-metered
and hot-reloadable. Use it for hot paths, real parsing, and real state machines — and for
anything you want sandboxed, which is the actual reason this is WASM and not a native library.
use juno_driver::{Driver, Host, Value, export_driver};
#[derive(Default)]
struct Bravia { buf: Vec<u8> }
impl Driver for Bravia {
fn on_command(&mut self, h: &Host, binding: u32, cmd: &str, args: &Value) {
match cmd {
"on" => h.net_send(0, b"*SCPOWR0000000000000001\n"),
"off" => h.net_send(0, b"*SCPOWR0000000000000000\n"),
"set_volume" => {
let level = args["level"].as_u64().unwrap_or(0);
h.net_send(0, format!("*SCVOLU{level:010}\n").as_bytes());
}
_ => h.log_warn(&format!("unhandled {cmd}")),
}
}
fn on_receive(&mut self, h: &Host, _src: u32, _note: &str, data: &[u8]) {
self.buf.extend_from_slice(data);
while let Some(i) = self.buf.iter().position(|&b| b == b'\n') {
let line: Vec<u8> = self.buf.drain(..=i).collect();
if line.starts_with(b"*SNPOWR") {
h.notify(1, "power_changed", &json!({ "on": line[7] != b'0' }));
}
}
}
}
export_driver!(Bravia);
A driver that traps, loops, or exhausts its fuel is unloaded and reported. It cannot take the house down — that is the whole bargain.
Host ABI
The same calls from all three runtimes.
| Call | What it does |
|---|---|
invoke(control, cmd, params) | Command a bound control connection — relay, IR, serial. Routed by core. |
net_send(socket, data) | Bytes to a [[transport]] socket |
notify(binding, name, params) | Emit a proxy notification. Validated against your declared capabilities. |
set_state(binding, key, value) | Update core's state store without emitting an event |
get_property(name) | Read an installer-set property |
set_timer(id, ms, repeat) / clear_timer(id) | |
persist(key, value) / restore(key) | Survives restart. For learned device state, not config. |
log(level, msg) | Shown in the driver's log pane |
http(request) -> response | Async HTTP, so you do not ship a client |
now() | Monotonic timestamp |
Your side:
| Callback | When it fires |
|---|---|
on_bind() / on_unbind() | Set up and tear down. Bind is where you configure a serial port. |
on_command(binding, cmd, args) | Args are pre-validated — the command exists, the params typecheck and are in range |
on_receive(source, note, params) | From a control connection or a socket |
on_timer(id) | |
on_property_changed(name) |
Two things core does for you
Commands arriving at on_command 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 notify 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.