Connections
How a driver reaches its device, and how signal moves between devices. Two separate graphs that share one idea: a driver declares what it needs, and the installer binds it to something that provides it.
1. Control connections
A driver never owns its transport.
An RS-232 projector is the clearest case. The same projector, in three houses, might be wired to:
- a DB9 on the controller,
- a Global Caché iTach IP2SL across the LAN,
- a USB-to-serial dongle.
There is one projector driver, and it is byte-identical in all three. It declares "I need a serial port"; the installer draws a line to whichever one exists. The driver never learns which, and no driver author ever writes iTach support.
The provider on the other end of that line is an ordinary driver exposing an ordinary
proxy. A relay board is a driver with eight relay bindings. An IR
distribution block is a driver with six ir_out bindings. Nothing about
them is special-cased in core.
Classes
kind | Provider proxy | What you call on it |
|---|---|---|
relay | relay | close · open · toggle · pulse |
contact | contact | (read-only — you receive contact_changed) |
ir_out | ir_out | send · start_repeat · stop_repeat |
serial | serial_port | configure · write, receive rx |
network | — | core-owned; see below |
Declaring what you need
[[control]]
id = 1
kind = "relay"
name = "Open"
required = true
[[control]]
id = 2
kind = "relay"
name = "Close"
required = true
[[control]]
id = 3
kind = "contact"
name = "Fully open sensor"
required = false
id is yours and must be stable across versions — it is how existing projects remember what
you were bound to. name is what the installer sees next to the line they are drawing, so
name it after the function, not the wire: "Open", not "Relay 1".
required = false means the driver works without it. Declare optional inputs generously; a
blind driver that can use a limit switch when one exists, and estimate from travel time when
one does not, is worth the twenty lines.
Using it
Every control connection is invoked the same way, whatever is on the far end — you return an
Invoke naming your own connection id, and core routes it to whatever the installer wired it
to:
// a gate: pulse the relay for half a second
vec![HostCall::Invoke {
control: 1,
cmd: "pulse".into(),
args: Args::from([("ms".into(), json!(500))]),
}]
// a projector: configure once on bind, then write bytes
fn on_bind(&self, _inst: &mut Instance) -> Vec<HostCall> {
vec![HostCall::Invoke {
control: 1,
cmd: "configure".into(),
args: Args::from([
("baud".into(), json!(9600)),
("databits".into(), json!(8)),
("parity".into(), json!("none")),
]),
}]
}
Inbound data arrives at on_event, with the id of the connection it came from:
fn on_event(&self, inst: &mut Instance, control: LocalId, note: &str, args: &Args)
-> Vec<HostCall>
{
if control == 1 && note == "rx" {
// framing is yours — this is a byte pipe, not a message pipe, and a partial
// frame belongs in `inst.scratch` rather than in `self`
}
Vec::new()
}
A declarative driver writes the same thing as
command.open_gate = { control = 1, invoke = "pulse", args = { ms = 500 } } and never sees
this code. See Runtimes.
Providing connections
If your device has relays, IR ports, contacts, or serial ports, expose one proxy binding per physical port:
[[proxy]]
id = 1
type = "relay"
name = "Relay 1"
capabilities = { supports_pulse = true, max_pulse_ms = 30000 }
[[proxy]]
id = 2
type = "relay"
name = "Relay 2"
capabilities = { supports_pulse = true, max_pulse_ms = 30000 }
That is all. You now provide relays to the whole project, and a fireplace driver written by someone who has never heard of your hardware will work with it.
One binding per port, always. Do not model eight relays as one binding with an index parameter — an automation must be able to trigger on relay 6 alone, and the installer must be able to draw a line to relay 6 alone.
Network is different
network is the one class core owns directly, because there is no physical port to bind and no
second driver in the path. Declare it in [[transport]]:
[[transport]]
kind = "network"
port = 20060
discovery = "auto" # filled in by discovery, or typed by the installer
keepalive = true # hold it open, and redial if it drops
Then write to it as control connection 0, the reserved id for your own transport:
vec![HostCall::Tx { control: 0, data: b"PWR ON\r".to_vec() }]
Bytes back arrive at on_event the same way a serial port's do, so a driver that moves from a
DB9 to a socket changes its manifest and not its parsing.
2. Signal connections
The AV graph — how audio and video physically flow. Separate from control: knowing how to talk to a receiver tells you nothing about what is plugged into it.
Every endpoint is a provider (an output) or a consumer (an input), typed by class:
HDMI · HDMI_ARC · STEREO · DIGITAL_OPTICAL · DIGITAL_COAX · COMPONENT ·
COMPOSITE · RF_CABLE · SPEAKER · DANTE
[[connection]]
id = 1001
proxy = 1
dir = "consumer"
class = "HDMI"
name = "HDMI 1"
[[connection]]
id = 1002
proxy = 1
dir = "consumer"
class = "HDMI"
name = "HDMI 2"
[[connection]]
id = 1003
proxy = 1
dir = "consumer"
class = "HDMI"
name = "HDMI 3"
[[connection]]
id = 4001
proxy = 1
dir = "provider"
class = "DIGITAL_OPTICAL"
name = "Optical Out"
The installer draws provider → consumer edges once — that is the wiring of the real house.
What this buys you
Core solves paths across that graph. When a room is told to watch(apple_tv), it finds the
route from the Apple TV's HDMI output to the room's display, powers on everything along the
way in order, and sets each input. Nobody programs a button sequence, and you do not
write any of that logic.
Your side of the bargain is three things:
- Declare every input and output, even the ones nobody uses. An undeclared input is an input the router cannot reach.
- Handle
set_inputby connection id — the id from your own manifest, not a vendor index. Map it internally. - Declare
warmup_mshonestly if the device ignores commands while it powers on. This is the single most common cause of "the projector came on but the input is wrong", and core will wait for you if you tell it to.
When the manifest is a guess: reporting what this unit actually has
A manifest describes a product line. [[connection]] is therefore right for no particular
unit: declare four HDMI ports and a three-port set in somebody's living room gains a phantom
input, which the pathfinder will happily route a room through. Declare three and a five-port
set hides two.
If your driver can ask the hardware, answer with Connections and core replaces the
manifest's list for that one device:
// The set told us its real source list. Replace the guess.
let connections: Vec<ConnectionDecl> = sources
.iter()
.filter_map(|s| {
let name = s.get("sourcename")?.as_str()?;
Some(ConnectionDecl {
id: connection_id(name)?,
proxy: TV,
dir: Direction::Consumer,
class: signal_class(name).into(),
name: name.trim().to_string(),
})
})
.collect();
vec![HostCall::Connections { connections }]
Four things about it, each of which is the reason it works:
- It is a snapshot, never a delta — the same bargain
Presentmakes. After a reconnect you say what the device has now and core reconciles. There is no resync path to get wrong because there is no resync. - An empty list means this device has no signal connections. That is a different statement from never having sent one, which means "use the manifest's" — so a driver that cannot reach its hardware yet must stay quiet rather than report nothing.
- Ids must be stable across restarts and firmware updates. A project remembers what an installer wired by this number. Derive them from the device's own identifiers, never from the order a list happened to arrive in — renumbering inputs moves somebody's cabling.
- The answer is persisted with the project. A controller that restarts while the television is unplugged keeps the last good answer instead of falling back to the manifest's guess and quietly re-routing the room.
Anything reading the graph asks core for the resolved list rather than the manifest, so a driver that reports gets this everywhere at once — the pathfinder, the wiring UI, exports — with no second place to update.
Switching devices
If your device can route internally, say so with capabilities and core derives the internal edges:
[[proxy]]
id = 1
type = "av_switch"
capabilities = { video_consumer_count = 8, video_provider_count = 8, is_matrix = true, switch_delay_ms = 250 }
is_matrix = true means any input can reach any output, and you must implement
set_output_input. false means all outputs mirror one selected input, and set_input is
enough.
If audio and video must be routed separately — a receiver that sends video straight through
while switching audio independently — declare requires_separate_switching = true and core
will solve two paths instead of one.