Automations — the node graph
Sources fire, logic shapes, sinks act. That is the whole model.
On this page
- The idea
- Node types
- Signals: what a node points at
- Recipes
- One button, one chime
- Ring at most once every 10 seconds
- Three buttons, one chime
- Both buttons, together
- Outside bell on, inside bell off — from Home Assistant
- Relay a code you hear as a different code
- Everything to Home Assistant, including unknown remotes
- A wired doorbell button that also rings the wireless chimes
- Press a button in Home Assistant, ring the chime
- Limits and guard rails
- Editing the graph
The idea
Klingelbox does not have a list of “if this then that” rules. It has a small directed graph of nodes connected by links. An event enters at a source, flows along the links, and whatever sinks it reaches do something.
graph LR
subgraph Sources
A["signal.rx"]
B[source.gpio]
C[source.virtual]
D[source.any_rf]
end
subgraph Logic
E[logic.group]
F[logic.throttle]
end
subgraph Sinks
G["signal.tx"]
H[sink.mqtt]
end
A --> F --> G
B --> F
C --> G
D --> H
A --> E --> H
Three properties make this worth the extra concept over a rule list:
- An event fans out. One press can reach several sinks in one traversal — ring a chime and tell Home Assistant.
- Logic composes. A throttle in front of a group is a different thing from a group in front of a throttle, and both are things people actually want.
- Sources are interchangeable. Nothing downstream of a source knows or cares whether the event came from a radio burst, a wired button or an MQTT message.
Everything persists immediately. A doorbell that forgets its wiring on power loss is worse than useless.
Node types
There are eleven, and they are all in GET /api/graph.
Sources — things that start a chain
signal.rx — Signal receiver
Fires when a stored signal is recognised on the air. This is the normal “someone pressed my doorbell button” node: it carries signal_id, pointing at a signal you learned or created.
Output only. It never transmits, on any path — not when a burst matches it, not when you fire it by hand. Firing it (POST /api/graph/nodes/{id}/fire, or ▶ on the map) means “pretend this code was just heard”: the output fires, the chain after it runs, and nothing goes on air. Sending a code is signal.tx’s job.
source.gpio
Fires when a wired button on a GPIO is pressed. Configured entirely in the web UI:
| Field | Meaning |
|---|---|
gpio_pin | The GPIO number. -1 means unset. GET /api/gpio/available lists what you may pick. |
gpio_active_low | true by default — a button to GND with the internal pull-up on. An unconnected pin then reads “not pressed” rather than floating. |
gpio_debounce_ms | 50 ms by default. Mechanical buttons bounce; without this one press fires the chain several times. |
source.virtual — MQTT button
A button Home Assistant can press. With MQTT discovery on it appears in your dashboard by itself — no YAML, no automation — and pressing it starts whatever is wired after it. The same button answers to any MQTT client, to ▶ in the web UI, and to POST /api/graph/nodes/{id}/fire.
It is subscribed as <base>/trigger/<topic>, and any message there fires it: the payload is never inspected, so mosquitto_pub -n is a press. An empty topic does not mean “no MQTT” — it falls back to a slug of the node’s name, so a button called “Ring the chime” answers on ring_the_chime with nothing typed. Use mqtt_enabled: false to keep one off the broker.
Several of these may share one topic on purpose: one message fires all of them, which is how a single “ring everything” button drives several chains with no special node type.
It is called
source.virtualon the wire and in the API, and “Virtual trigger” in the UI up to 0.5 — which is why people kept asking for an MQTT-triggerable button that had been on the palette all along. The wire name is on flash and does not change.
source.any_rf
A wildcard: it fires on every received burst, including ones matching no stored signal at all.
Wire one to a sink.mqtt and Home Assistant sees every press on the band — registered or not. It is the “proxy everything” primitive, and it is also how you discover what is actually out there before you learn anything.
source.any_rffires in addition to any matchingsignal.rx. A recognised burst legitimately drives both the specific chain and the wildcard chain in a single traversal. That is intended, not double-firing — but if you wire both to the same chime, it will ring twice.
Logic — things that shape a chain
logic.group
Two modes that do genuinely different things. They share a node type because they share a shape — several links in, one out — not because they are variations on one rule, and only one of them uses window_s.
any — a merge point. Anything arriving is passed straight on, immediately. It does not wait, does not compare inputs and never looks at the window at all. What it buys is structural rather than logical: several sources meet at one node, so the chain hanging off it exists in exactly one place. Front door, back door and the garden gate all ring the upstairs chime — and when you want to add a rate limit to all three, you add it once.
all — a coincidence detector. Nothing passes until every inbound link has carried an event inside window_s. When that happens it fires once, forgets all of them and re-arms, so it will not fire again on the next event from any single input. Both buttons within 10 seconds means something specific. The window is what makes “all of them” usable at all — nobody presses two buttons in the same instant. An all group with no inbound links can never be satisfied and never fires.
window_sapplies toallonly. Sending one withgroup_mode: "any"is accepted and stored, but changes nothing — the web UI hides the field in that mode for exactly that reason.
logic.throttle
A leading-edge throttle — a cooldown, or what Node-RED calls a rate limit.
The first event passes through immediately. Everything within
window_safter it is dropped.
That ordering is the entire point. A doorbell must ring on the first press and then go quiet; the alternative (wait out the window, then act) would make a visitor stand there while nothing happens.
With window_s = 10: the chime rings on the first press and stays silent for ten seconds no matter how many times a child leans on the button.
It is source-agnostic: an RF remote, a wired GPIO button and an MQTT trigger are all limited identically, because it limits whatever is linked into it rather than inspecting where the event came from.
Time windows are in seconds (
window_s, 1–6000) — forlogic.groupandlogic.throttlealike. The API also emitswindow_msand still accepts it on write, butwindow_swins when both are sent.
logic.switch
A switch in the wire. Every other node type is a source, a transform or a sink; this one sits in a link and decides whether it conducts.
ON — events pass through untouched. OFF — nothing gets past it. Everything wired after it is dead until it is switched back on.
That is the node that makes “turn the inside bell off tonight” a toggle. Before it, the only way to stop a path was to delete the link — destructive, easy to forget to put back, and impossible to automate.
Three equivalent ways to move it, and they are genuinely the same switch:
| from | how |
|---|---|
| the web UI | the ON/OFF button on the node’s card, in its editor, or the I / O badge on the map |
| the REST API | POST /api/graph/nodes/{id}/switch {"on":false} |
| MQTT | publish to <base>/switch/<topic>/set |
Give it a topic and it appears in Home Assistant as a native switch entity on the Klingelbox device: a real toggle in the dashboard, usable in automations like any other switch. The set topic accepts ON / OFF / 1 / 0 / true / false in any case, and {"state":"ON"} for good measure. The box answers on <base>/switch/<topic>/state, retained, on every change and on every connect — so the toggle is right after a Home Assistant restart, after a broker restart, and after the box itself reboots.
mosquitto_pub -t klingelbox/switch/outside_bell/set -m OFF
mosquitto_sub -t klingelbox/switch/outside_bell/state # -> OFF
Several switch nodes may share one topic. That is the point, not a clash: one Home Assistant toggle then gates every one of them at once, the way a single wall switch feeds several lamps. A set moves them all; the reported state is ON if any of them is conducting. Home Assistant sees one entity per topic, not one per node.
Leave the topic empty and the switch still works perfectly — from the UI and the REST API. Nothing subscribes to it and no entity appears.
A switch’s position is the node’s
enabledflag, soGET /api/graphreports it there. Prefer the/switchroute over a plain node update for anything automated: it takes effect immediately but does not rewrite the graph blob on every toggle, which is what keeps an automation flapping a switch from wearing the flash out. The box comes back from a reboot in the last settled position and republishes it retained, so nothing is ever shown a position the box is not in.
Sinks — things that act
signal.tx — Signal sender
Transmits a stored signal (signal_id) on 433 MHz whenever something reaches it. Carries its own repeats and gap_us, defaulting to the global radio policy (6 repeats, 8000 µs apart).
Input only. It never listens. Hearing its own code on air does not start it — that is what stops the box echoing back everything it hears, structurally rather than by a special case. Firing it by hand transmits, exactly as an inbound link would.
Repeats are not cosmetic. Real receivers integrate several consistent copies of a frame before they act, because the original remote transmits for as long as the button is held. A single replay is routinely ignored.
signal.rxandsignal.txdraw on the same pool of stored signals, so a code you both listen for and send is two nodes pointing at onesignal_id. That is the point: each node does one thing, the ports show which way events travel, and a relay reads plainly assignal.rx (A) → … → signal.tx (B).
sink.mqtt
Publishes to <base>/<topic> when it fires, carrying what caused it — signal id, label, fingerprint, RSSI, repeat count and the decode, if any. See MQTT & Home Assistant for the payload.
For an unregistered burst arriving via source.any_rf, signal_id is 0 and decoded may be null. That is the normal case behind a proxy, not an error.
The topic’s first level must be your own: status, button, trigger, switch, unknown, event and radio are the levels the box itself publishes and listens under <base>/, and a node topic starting with one of them is refused — trigger/x would publish onto the box’s own subscription and fire itself forever. See the reserved-levels note.
sink.monitor
A sink that acts on nothing. Reaching it records a timestamp — no transmit, no publish, no pin — and the web UI turns that into a lamp that lights on every hit plus a rolling ten-minute timeline. It is the debugging tool for the graph itself: drop one beside a signal.tx and you can prove a chain fires without ringing anything in the house.
window_s (1–60 s, default 3) is how long the indicator stays lit per hit. The hits live in RAM only, capped at 64 per node and pruned to a 600 s window, and are read back from GET /api/monitor. Nothing is written to flash and nothing survives a reboot.
Paired with a source.virtual, whose ▶ button fires it from the node card, the editor or straight off the canvas, it makes any chain verifiable with no transmitter and nothing audible:
source.virtual ──▶ (whatever you are testing) ──▶ sink.monitor
Signals: what a node points at
A signal is a stored waveform, not a decoded code. Three ways to get one:
| Origin | How |
|---|---|
| Captured | A listening session. Press your remote a few times; the box ranks what it heard and you keep the one that rings your bell. |
| Virtual | POST /api/signals/virtual synthesises an EV1527 frame with a random or chosen address — a code the box invented, that you then teach your chime. |
| Undecodable | Also captured. If no decoder recognises it, it is stored as raw timings and works exactly like any other signal. |
Listening sessions
The receiver always listens; a session only decides whether an unrecognised burst is kept and offered to you. There is no /api/learn any more — it required two repeats and 65 % normalization confidence before it would show you anything, and both numbers were measured on EV1527 remotes. That made it a mode for one protocol family: a remote of any other shape produced no candidate at all, and no explanation either.
Detection is now permissive and protocol-agnostic — everything the radio hands up is kept — and the box ranks instead of filtering:
curl -X POST -H 'Content-Type: application/json' \
http://klingelbox.local/api/raw/start -d '{"seconds":30}'
# press the remote several times, then:
curl http://klingelbox.local/api/raw/candidates
# try the top one at the bell, without storing anything:
curl -X POST -H 'Content-Type: application/json' \
http://klingelbox.local/api/raw/candidates/1/transmit -d '{}'
# keep it:
curl -X POST -H 'Content-Type: application/json' \
http://klingelbox.local/api/raw/candidates/1/save -d '{"name":"Front door"}'
Repetition dominates the ranking. A real remote sends the same word several times and band noise never does, so “seen 5 times” is the strongest evidence of authenticity available — and collecting it needs no knowledge of any protocol. A decoded protocol and a high confidence raise a candidate further, but only ever as a tie-break: an undecoded candidate can rank first and is fully usable. Each one carries a why string ("seen 5 times, decoded ev1527, 92% confidence") so the ranking is checkable rather than magic.
If one press arrives as several dissimilar pieces, the frame boundary fired mid- transmission. The session reports that in fragmentation, suggests a longer idle_us derived from the widest gap it actually measured, and — where the pieces fit back together — offers the rejoined whole as a merged candidate, stitched with the measured silence rather than a guessed one. See the API reference.
Virtual signals — pairing your own chimes
If you want a chime you own to ring, but you don’t want it tied to a specific doorbell remote, invent a signal:
curl -X POST http://klingelbox.local/api/signals/virtual \
-H 'Content-Type: application/json' \
-d '{"name":"Upstairs chime","button":8,"base_us":350}'
Put the chime into its own pairing mode, test-fire the new signal from the UI, and the chime learns the box as its remote. Now anything in your graph can ring it.
Recipes
One button, one chime
signal.rx (front door) ──▶ signal.tx (chime)
The base case, and the shape everything else is built from: a receiver for the code you hear, a sender for the code you want to go out. Register the button, register (or invent) the chime signal, link them.
Ring at most once every 10 seconds
signal.rx (front door) ──▶ logic.throttle (window_s: 10) ──▶ signal.tx (chime)
Rings immediately on the first press, then ignores the button for ten seconds.
Three buttons, one chime
signal.rx (front) ─┐
signal.rx (back) ─┼─▶ logic.group (any) ──▶ signal.tx (chime)
signal.rx (gate) ─┘
No special node type needed. logic.group in any mode is a merge point — each receiver is passed straight through — so the single chain after it is written once.
Both buttons, together
signal.rx (front) ─┬─▶ logic.group (all, window_s: 10) ──▶ signal.tx (chime)
signal.rx (back) ─┘
The same node in its other mode, and the only one where window_s does anything: nothing passes until both receivers have fired within ten seconds of each other.
Outside bell on, inside bell off — from Home Assistant
┌─▶ logic.switch "Outside bell" ──▶ signal.tx (outside chime)
signal.rx (front door) ────┤ topic: outside_bell
└─▶ logic.switch "Inside bell" ──▶ signal.tx (inside chime)
topic: inside_bell
One press fans out to both branches, and each branch has its own switch in it. Two toggles appear in Home Assistant. Turn Inside bell off and the outside chime still rings while the inside one stays silent — on the map its wire is drawn broken, so you can see which path is dead. Nothing is deleted and nothing has to be re-wired to put it back.
mosquitto_pub -t klingelbox/switch/inside_bell/set -m OFF # quiet indoors
mosquitto_pub -t klingelbox/switch/inside_bell/set -m ON # back to normal
Give both switches the same topic instead and they become one toggle that silences the whole doorbell — a “do not disturb” switch, with no extra node type.
Relay a code you hear as a different code
signal.rx (neighbour's remote) ──▶ logic.throttle ──▶ signal.tx (your chime)
Two nodes, so the chain reads left to right and anything can be put in the middle. This is the shape that used to be awkward: when one node stood for a code in both directions, a relay meant wiring a node into itself and reasoning about which of its two jobs it would do. Now the ports say it.
The same signal_id on both ends is legal too. The radio ignores what the box itself just transmitted for about a second afterwards, so a sender does not feed its own receiver and go round again.
Everything to Home Assistant, including unknown remotes
source.any_rf ──▶ sink.mqtt (topic: "doorbell")
Every burst on the band is published, registered or not. Combine with the retained <base>/unknown topic to see the code of a remote before you register it.
A wired doorbell button that also rings the wireless chimes
source.gpio (pin 6) ──▶ logic.throttle ──▶ signal.tx (chime)
└──▶ sink.mqtt
The wired switch behaves exactly like an RF button. Nothing downstream knows the difference.
Press a button in Home Assistant, ring the chime
source.virtual "Ring the chime" ──▶ signal.tx (chime)
Two nodes, and nothing else to fill in. With discovery on, a button entity for it appears on the Klingelbox device in Home Assistant by itself; pressing it puts the chime’s code on air. The topic follows the node’s name unless you type one, so this button answers on klingelbox/trigger/ring_the_chime:
mosquitto_pub -t klingelbox/trigger/ring_the_chime -m ''
Any message on that topic fires the node. Payload content is irrelevant — arriving is the press. Point a second source.virtual at the same topic and one press fires both chains.
Limits and guard rails
| Limit | Value | Why |
|---|---|---|
| Nodes per graph | 24 | Comfortably more than a house needs; keeps traversal bounded. |
| Traversal depth | 8 | Bounds the blast radius of a mis-wired graph — a cycle cannot run away. |
| Node name | 32 characters | |
| Topic suffix | 47 characters | The 48-byte field includes the terminator. |
| Window | 1–6000 seconds |
Every node has an enabled flag. Turning a node off is the right way to test a hypothesis about a misbehaving graph — deleting it also deletes its links.
Editing the graph
Everything above is exposed over REST, so the web UI and any script you write see the same surface:
| Endpoint | Does |
|---|---|
GET /api/graph | The whole graph — nodes and links. |
POST /api/graph/nodes | Create a node (a node object without id). |
POST /api/graph/nodes/{id} | Partial update. |
DELETE /api/graph/nodes/{id} | Delete it, and its links. |
POST /api/graph/nodes/{id}/fire | Test-fire — also how you trigger a source.virtual. |
POST /api/graph/links | {"from":1,"to":2} |
DELETE /api/graph/links | {"from":1,"to":2} |
Full details, including every field on a node object, are in the REST API reference.
GET /api/events is the debugging tool when a chain does not do what you expect: it shows what fired, in order, with the reason — rf_unmatched, button_press, wired_press, node_fired, transmit, learn, system.