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:
host.invoke(control_id, command, params)
# a gate: pulse the relay for half a second
host.invoke(1, "pulse", {"ms": 500})
# a projector: configure once on bind, then write
def on_bind():
host.invoke(1, "configure", {"baud": 9600, "databits": 8, "parity": "none"})
def on_command(binding, cmd, args):
if cmd == "on":
host.invoke(1, "write", {"data": "50 57 52 20 4f 4e 0d"})
Inbound data arrives at on_receive(control_id, notification, params):
def on_receive(control, note, params):
if control == 1 and note == "rx":
buffer.extend(bytes.fromhex(params["data"]))
# framing is yours — this is a byte pipe, not a message pipe
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]] and use host.net_send /
on_receive:
[[transport]]
kind = "tcp"
port = 20060
discovery = "auto" # filled in by discovery, or typed by the installer
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.
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.