Skip to main content

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.

CallWhat 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) -> responseAsync HTTP, so you do not ship a client
now()Monotonic timestamp

Your side:

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