Skip to content
GitHub

Automations

A rule is three things: something happens, a condition holds, actions run.

Rules can command equipment, so two properties are not negotiable.

Conditions are compiled when a rule is saved, not when it fires. A syntax error is something the author sees while still looking at the editor, rather than a rule that silently never runs, or worse, one that throws at 3am halfway through a sequence that has already half-executed. The editor validates as you type; a rule that does not compile is refused at save.

Conditions are evaluated in a sandbox. expr-lang has no I/O, cannot reach the filesystem or the network, type-checks at compile time, and is guaranteed to terminate. That last property matters most: a condition is evaluated on the path that leads to a write, and an expression language that can loop forever is one that can wedge the engine holding the write lock.

Any one trigger firing runs the rule.

Type Fires when Key fields
tag_change a tag’s value changes deviceId, tagName
threshold a value crosses a limit deviceId, tagName, threshold, direction, hysteresis
state an entity’s state changes, or has held a state for a time entityId, from, to, forSec
schedule a cron expression comes due cron
interval every N seconds intervalSec
alarm an alarm enters a state alarmId, alarmState
device_state a device connects or drops deviceId, toState
webhook an inbound POST arrives webhookPath
mqtt a message lands on a topic topic
startup the process starts n/a
sun dawn, sunrise, solar noon, sunset or dusk at the site, give or take an offset event, offsetSec

threshold fires when a value crosses the limit, not while it sits above it. Firing on “value is above 200” means the rule fires on every scan the value stays high, which for a rule that commands something is a command storm.

hysteresis stops a value hovering on the limit from re-arming on every wobble: once above, the value must fall below threshold - hysteresis before the trigger can fire again.

{
"type": "threshold",
"deviceId": "press-01", "tagName": "Zone1_Temp",
"threshold": 200, "direction": "rising", "hysteresis": 5
}

direction is rising, falling or either. The first reading after startup establishes a baseline and does not fire, otherwise every threshold rule fires once on boot regardless of what the value is.

state fires when an entity’s state string changes: off to on, 212.5 to 213, on to unavailable. from and to narrow it. Leave both out and it fires on every change, including to and from unavailable, which is usually what you want for “tell somebody” and never what you want for “command something”. Say to.

{
"type": "state",
"entityId": "binary_sensor.press_01_run",
"from": "off", "to": "on"
}

It fires on the state and nothing else. An attribute changing, or the quality going bad while the state holds its last good value, is not a change of state.

Here is exactly when it fires, because a rule that commands equipment deserves to know. The trigger follows the entity engine’s own record of what changed, not the tag bus. The engine computes an entity’s state as readings arrive, and once a second recomputes everything as a backstop for a batch it was too slow to take. Each change it computes it reports once, in order, and the trigger sees every one of them. So:

  • It fires once per change. The once-a-second recompute only reports what moved since the engine last looked, so it cannot report a change twice, and a rule reload resets nothing a state trigger depends on.
  • It does not miss a change the engine saw. A slow bus costs a second of latency, not the change.
  • It cannot see a change nobody saw. A coil that goes off, on, off between two polls never changed as far as the plant is concerned. A state trigger is as good as the scan behind it and no better. If you need every edge of a fast signal, latch it in the PLC.
  • Startup is a baseline. Every entity appearing when the Gateway starts, or when one is enabled, is not a change and does not fire.

If rules ever fall 10,000 changes behind (the database too slow to record the runs they start), the oldest are dropped, counted, and logged, and GET /api/diagnostics shows stateChangesPending and stateChangesDropped under automations.

Rename an entity and every rule naming its old entity_id stops firing. That is the cost of names that mean something; rename deliberately, and search the rules first.

“The pump has been running for five minutes” is a state trigger with forSec. The change starts a timer instead of firing the rule; the rule fires when the state it changed to has held for that many seconds.

{
"type": "state",
"entityId": "binary_sensor.press_01_run",
"from": "off", "to": "on",
"forSec": 300
}
  • Any change of state calls it off. On to off and back to on inside the five minutes is two changes: the first ends the hold, the second starts a new one from zero. It does not add up time. A drop to unavailable is a change of state too, so a device that goes quiet mid-hold calls it off.
  • Only the state counts. An attribute changing, or the quality going bad while the state holds, does not call it off, the same as it does not fire a plain state trigger. If a hold should not survive a bad reading, guard the rule’s condition on qualities.
  • It fires once per hold. Holding for an hour fires a five-minute rule once, at five minutes, not every five minutes.
  • Each rule has its own timer, one per trigger. Two rules on the same entity with different forSec each fire at their own time.
  • The trace says it. A run a hold fired reads binary_sensor.press_01_run changed from off to on and held for 300s, and fromState, toState and entity are those of the change that started the hold.
  • A hold does not survive a restart. It is kept in memory and nothing re-arms it: the minutes a press had held before the restart count for nothing after it. What the process sees as it comes back is every entity appearing (a baseline, not a change), usually as unknown, and then the device’s first reading moving it to what it reads. With "from": "off" that is not a match, so a press that was running before the restart starts no hold, and the rule fires the next time the press goes from off to on and runs for five minutes. Without from it depends on a race you do not control. If the first reading lands after the rules are listening, it is a change, unknown to on, and it starts a hold from zero: the rule fires five minutes after that, and its trace says changed from unknown to on. If the device answers faster than the Gateway comes up, the press is simply on from the start, nothing changed, and nothing fires until it next does. Say from when a restart must not count as a start, and don’t write a rule that needs one to. Editing, disarming or deleting the rule drops its holds too; saving some other rule does not.
  • An entity that goes away mid-hold (disabled, or deleted with its device) never fires it.

forSec is at most a day (86400), and only a state trigger takes it. It is checked at save like everything else.

Yard lights, heat trace, a parking lot, anything that cares whether it’s dark outside. sun fires at dawn, sunrise, noon, sunset or dusk at this site, moved by offsetSec: negative is before, so this is half an hour before sunset.

{"type": "sun", "event": "sunset", "offsetSec": -1800}
  • It’s worked out on the box. From the site’s latitude and longitude (HOTLOOP_SITE_LATITUDE, HOTLOOP_SITE_LONGITUDE), with NOAA’s solar equations, no service and no internet. A plant that lost its uplink still turns the lights on at dusk. See SUN.md.
  • No location, not armed. A rule with a sun trigger on a site with no latitude and longitude isn’t armed, and the system log says which two variables to set. It used to be the kind of thing that sits there armed and silent forever, and nobody finds out until the yard is dark.
  • It fires once per day’s event, at that day’s time. October’s sunsets come a minute or so earlier every evening and the trigger follows them.
  • Dawn and dusk are civil twilight, the sun’s center 6 degrees below the horizon. That’s the “it’s dark enough for lights” line outdoor lighting is written around.
  • Under the midnight sun there’s no sunset to fire on, so a sunset trigger waits for the next one, however many weeks away, and fires then.
  • offsetSec is at most six hours either side (21600). Further than that is a schedule.

The trace says what fired it and when: 30m before sunset at 2026-10-01T22:38:00Z, and trigger is sun in the condition.

An expression returning a boolean. Empty means unconditional.

What an expression can see:

Name Meaning
value, string, quality the reading that fired the rule
previous the reading before it
tag, device what fired it
tags["device/tag"] every current numeric reading
strings["device/tag"] every current textual reading
qualities["device/tag"] every current quality
states["sensor.x"] every enabled entity’s state, as a string
attrs["sensor.x"]["unit_of_measurement"] every enabled entity’s attributes
qualities["sensor.x"] every entity’s quality, next to the tags’ (a tag key has a /, an entity_id never does)
entity, fromState, toState the state change that fired the rule
alarm, alarmState the alarm that fired the rule
deviceState the device state that fired it
now, hour, minute, weekday the clock; weekday 0 is Sunday
trigger which trigger fired
vars values bound by earlier steps
value > 200
value > 200 and quality == 'good'
tags['press-01/Zone1_Temp'] > 200 and tags['press-01/Pressure'] < 50
hour >= 6 and hour < 18 and weekday != 0 and weekday != 6
qualities['press-01/Zone1_Temp'] == 'good'
strings['cnc-01/Program'] == 'PART_4471.NC'
value > previous
states['switch.press_01_pump'] == 'off' and toState == 'on'
float(states['sensor.press_01_zone1_temp']) > 200 and qualities['sensor.press_01_zone1_temp'] == 'good'
attrs['switch.press_01_pump']['armed'] == true

Guard on quality. value > 200 is true for a bad reading of 250, and a bad reading is one the system is telling you it does not know. On anything that commands equipment, write value > 200 and quality == 'good'. The same goes for entities: a bad entity keeps showing its last good value, and qualities['sensor.x'] is how a rule tells.

A state is a string. It is "212.5", "on" or "unavailable", never a number, so states['sensor.x'] > 200 is refused at save as a type error rather than saved as a comparison that is quietly always false. Say float(states['sensor.x']) > 200, and guard it: float('unavailable') is an error, and a condition that errors fails the run.

string is the reading that fired the rule, which means it is not also expr-lang’s string() function. To put a number in a string, use toJSON(value).

A condition must return a boolean. One returning a number or a string is rejected at save rather than coerced into a truthiness nobody intended.

Actions run in order. A failed step stops the rule unless it sets continueOnError, a step that was supposed to close a valve and did not must not be followed by steps that assume the valve is closed.

{
"type": "write_tag",
"params": {
"deviceId": "press-01", "tagName": "Cooling",
"value": true
}
}

Or computed:

{
"type": "write_tag",
"params": {
"deviceId": "press-01", "tagName": "Setpoint",
"expression": "tags['press-01/Zone1_Temp'] - 10"
}
}

This goes through the same gate as everything else. An automation gets no more privilege than a person: writes must be enabled, the tag must be marked writable, the value must be in range, and the attempt is audited as automation:<rule-id>.

Commands an entity by its service, the way POST /api/services/... and the MCP call_service tool do. See ENTITIES.md for what each domain takes.

{
"type": "call_service",
"params": {
"entityId": "switch.press_01_pump",
"service": "switch.turn_on"
}
}

With data, literal or computed:

{
"type": "call_service",
"params": {
"entityId": "number.press_01_zone1_sp",
"service": "number.set_value",
"dataExpressions": {"value": "float(states['sensor.press_01_zone1_temp']) - 10"}
}
}

service is domain.service and its domain must be the entity’s, so a rule that names a number with a switch’s service is refused at save. So is a service the domain does not take, and the list of what each domain takes comes from the entity layer itself: when a domain gains a service there, rules can use it with no change here. data is the literal service data; dataExpressions are evaluated at run time and merged over it.

It is write_tag with a name on it. The call becomes tag writes through the same gate, audited as automation:<rule-id> with the entity, the service and the call id on every row. A refusal (writes off, the tag disarmed, a value off the entity’s step) fails the step and the run, with the refusal in the trace, exactly as a refused write_tag does, and every write the call made or had refused is in the step’s output. A call is never rolled back: a composite that got two writes into three stops there and says so.

A condition in the middle of a sequence. When it is false the sequence ends there, and the run succeeded: the rule did what it was written to do, which was to stop. The step’s output says false; the steps after this one did not run.

{
"type": "condition",
"params": {
"expression": "states['binary_sensor.press_01_run'] == 'on' and qualities['binary_sensor.press_01_run'] == 'good'"
}
}

It is not the rule’s condition. That one is checked once, before anything runs, against the plant as it was when the trigger fired, and a false one records the run as skipped. A condition step reads the plant as it is when the step runs, which is the point of one after a delay or a wait: “is the press still running, thirty seconds later?” entity, toState, value and the rest of what fired the rule stay what they were, and so do vars.

A condition that cannot be evaluated (float('unavailable')) fails the step and the run, the same as the rule’s condition. continueOnError is refused on a condition at save: carrying on past one would mean it never stops anything.

Holds the sequence until an expression is true, for at most timeoutSec.

{
"type": "wait",
"params": {
"expression": "states['binary_sensor.press_01_valve_closed'] == 'on'",
"timeoutSec": 30
}
}

It looks at once, again whenever an entity the expression names changes (its state, its quality or an attribute), and once a second regardless. The entities it watches are the ones written as literals in states['...'], attrs['...'] and qualities['...']; a tag reading, the clock, or an entity named some other way is caught by the once-a-second look. A wait on a Modbus coil is therefore as quick as the poll behind it, not a second late.

When it comes true the next step runs, and the steps after it see the plant as the wait last saw it. When the timeout runs out first the step fails, and the run with it, saying still false after 30s, unless the wait says to carry on:

{
"type": "wait",
"params": {
"expression": "float(states['sensor.press_01_zone1_temp']) < 60",
"timeoutSec": 240,
"continueOnTimeout": true
}
}

Either way vars.wait.completed is true or false afterwards, and vars.wait.waitedSec is how long it waited, so a condition step after a carry-on wait can decide: vars.wait.completed == true.

timeoutSec is required and at most an hour. A wait with no end holds its run slot until the run’s budget takes it away, which under skip means the rule stops firing. It must also fit in the run’s budget (maxRuntimeSec, five minutes by default): a ten-minute wait needs "maxRuntimeSec": 900 or so on its rule, and without it the rule is refused at save rather than timing out as a run. An expression that errors while it waits fails the step.

Ends the sequence. reason is required, and takes {{ expression }} substitution; the trace shows it.

{
"type": "stop",
"params": {"reason": "pump already running, nothing to do"}
}

Without error the run succeeded. With "error": true the run failed, with the reason as its error, and it is logged and counted as a failure like any other:

{
"type": "stop",
"params": {"reason": "valve did not close in time", "error": true}
}

continueOnError is refused on a stop at save.

Runs a script: a named sequence somebody wrote once so every rule that needs a CIP cycle or a line purge calls the same one instead of carrying its own copy. fields are its inputs, fieldExpressions are evaluated when the step runs and merged over them, and the script checks the lot against its own fields exactly as it does a run from the screen. A value it refuses fails this step; it is never moved to fit.

{
"type": "call_script",
"params": {
"scriptId": "cip_cycle",
"fields": {"circuit": "tank_2"},
"fieldExpressions": {"minutes": "states['input_number.cip_minutes']"},
"wait": true
}
}

With "wait": true the step holds until the script’s run has ended and takes its outcome: a script that fails fails the step, and the rule stops there unless the step says continueOnError. The script’s run is part of this one: if this run is cancelled or runs out of its budget, so is the script. Without wait, the script is started and the rule carries on; the script finishes on its own.

The script runs as script:<id>, with this rule’s source, automation, on every write it makes, and its run says automation_run:<id> was its parent. A script that is busy and whose mode refuses another run fails a waiting step (script busy) and is noted, not failed, by one that does not wait. Scripts calling scripts go five deep at most, and a script that would call itself is refused when it is saved.

Applies a recipe: a set of setpoints that go out together, a product grade or a changeover. params are its parameters and paramExpressions are evaluated when the step runs and merged over them; version applies an older version on purpose, and left out means the current one.

{
"type": "apply_recipe",
"params": {
"recipeId": "grade_a",
"params": {"line_speed": 120},
"paramExpressions": {"batch": "states['input_number.next_batch']"}
}
}

The recipe does what it does from the Recipes screen, with this rule as the one applying it. It is checked whole first, and if any target would be refused nothing is written and the step fails with every reason. Then its targets go out in order under one call id, as automation:<id> with the source automation, and the first that fails stops the rest and fails the step; what was written stays written. The step succeeds only when every target was written. A value that went out and was not read back from the device is not a failure, because it did go out, but the trace says NOT confirmed by read-back, and so does the application. The application’s parent is this run, automation_run:<id>.

Action Does
mqtt_publish publishes on an MQTT device’s existing connection
http_request calls an endpoint; bindTo stores the body in vars
raise_alarm / clear_alarm drives an alarm the engine cannot see for itself
log_event writes to the system log
set_variable binds a value for later steps
delay pauses, up to an hour
mcp_call invokes a tool on a configured MCP server

mqtt_publish, http_request, log_event and stop support {{ expression }} substitution in their strings.

{
"type": "mcp_call",
"params": {
"server": "maintenance",
"tool": "create_work_order",
"args": {"priority": "high"},
"argExpressions": {"note": "'Zone 1 reached ' + toJSON(value)"},
"bindTo": "workOrder"
}
}

What happens when a rule is triggered while a previous run is still going.

Mode Behavior
skip drop the new trigger, the default, and the right answer near a device
queue run it after, at most one deep
restart abandon the current run and start again
parallel run both

queue is capped at one pending run on purpose. A rule that cannot keep up would otherwise build a backlog of commands that all fire against a plant whose state has moved on.

minIntervalSec rate-limits regardless of trigger rate, a backstop against a chattering tag driving a device into the ground. A manual run from the UI or MCP bypasses the rate limit (somebody meant it) but not the run mode (two concurrent runs are equally dangerous whoever started them).

maxRuntimeSec bounds a whole run; the default is five minutes. Without it a rule hung on an unreachable endpoint holds its run slot forever, and under skip that means it never fires again. A run that runs out of it is recorded as timed out, whether it was between steps or in the middle of a delay or a wait.

A run sitting in a wait or a delay is a run in progress, and the run mode decides what a trigger does to it:

Mode A trigger while a run waits
skip is dropped and recorded as skipped. The waiting run carries on.
queue runs once the waiting run has finished, however it finished.
restart cancels the waiting run where it is and starts a new one. The old run is recorded as cancelled, a newer trigger restarted the rule. This is the “motion light” pattern: every trigger pushes the end back.
parallel starts another run, which waits on its own.

A run is cancelled where it is, a wait or a delay included, and recorded as cancelled with the reason, when:

  • its rule is disarmed or deleted: the rule was disarmed or deleted. Its queued run and its forSec holds are dropped too.
  • its rule is edited: the rule was edited. A run waiting on a condition the author has just changed does not carry on as if nothing happened; the edited rule starts clean. Saving some other rule touches nothing: runs, holds, queued runs, the run slot and interval timers of every unchanged rule carry on as they were.
  • the process stops: the process is stopping. Shutdown does not wait out a wait. Nothing resumes the run on the next start; a run the process could not record as cancelled (a crash, a power cut) is marked cancelled on the next start instead.

A step that was cancelled or ran out of budget stops the run even when it has continueOnError: that flag is for a step that failed, not for a run somebody stopped. Whatever the step had already done stays done: a write that went out before the cancel is not undone, and it is in the trace.

Every run is recorded with a per-step trace: the resolved inputs, the outputs, durations and errors. Not the templates, when a rule misfires at 3am, wrote 212.5 to press-01/Setpoint is the thing worth knowing and wrote {{expression}} is not.

Expand a run on the Automations screen to see its steps. A run that was still going when the process stopped is cancelled as it stops, and one the process never got to record (a crash) is marked cancelled on the next start, rather than appearing to have been executing since last Tuesday.

Start cooling when a zone runs hot during a shift, once every ten minutes at most, and tell somebody.

{
"id": "cool-zone-1",
"name": "Cool zone 1 when hot",
"enabled": true,
"mode": "skip",
"minIntervalSec": 600,
"triggers": [{
"type": "threshold",
"deviceId": "press-01", "tagName": "Zone1_Temp",
"threshold": 200, "direction": "rising", "hysteresis": 5
}],
"condition": "quality == 'good' and hour >= 6 and hour < 18",
"actions": [
{"type": "write_tag", "params": {
"deviceId": "press-01", "tagName": "Cooling", "value": true}},
{"type": "delay", "params": {"seconds": 30}},
{"type": "log_event", "params": {
"severity": "warning",
"message": "Cooling started, zone 1 at {{ value }}"}}
]
}

Read the guards in order: it fires on the crossing rather than continuously; hysteresis stops it re-arming on a wobble; the condition refuses to act on a reading it cannot trust and only runs during the shift; and the rate limit means that even if everything else is wrong it cannot command the plant more than once every ten minutes.

The same idea with entities: start the extraction fan when the press starts running, but only if nobody has disarmed the fan.

{
"id": "fan-with-press",
"name": "Extraction fan follows the press",
"enabled": true,
"mode": "skip",
"triggers": [{
"type": "state",
"entityId": "binary_sensor.press_01_run",
"from": "off", "to": "on"
}],
"condition": "qualities['binary_sensor.press_01_run'] == 'good' and attrs['switch.press_01_fan']['armed'] == true",
"actions": [
{"type": "call_service", "params": {
"entityId": "switch.press_01_fan", "service": "switch.turn_on"}},
{"type": "log_event", "params": {
"message": "{{ entity }} went {{ toState }}, fan started"}}
]
}

from and to mean a reconnect (unavailable to on) does not start the fan on its own; the condition means a disarmed fan is a skipped run rather than a refused write in the audit trail every time the press starts.

And one that uses the sequence steps: once the press has run for ten minutes, open the cooling valve if it is not already open, wait for its limit switch, and fail loudly if it never makes.

{
"id": "cool-after-warmup",
"name": "Open cooling after ten minutes of running",
"enabled": true,
"mode": "restart",
"maxRuntimeSec": 120,
"triggers": [{
"type": "state",
"entityId": "binary_sensor.press_01_run",
"from": "off", "to": "on",
"forSec": 600
}],
"actions": [
{"type": "condition", "params": {
"expression": "states['valve.press_01_cooling'] != 'open'"}},
{"type": "call_service", "params": {
"entityId": "valve.press_01_cooling", "service": "valve.open"}},
{"type": "wait", "params": {
"expression": "states['valve.press_01_cooling'] == 'open'",
"timeoutSec": 60, "continueOnTimeout": true}},
{"type": "condition", "params": {
"expression": "vars.wait.completed == false"}},
{"type": "stop", "params": {
"reason": "cooling valve did not open within 60s, it is {{ states['valve.press_01_cooling'] }}",
"error": true}}
]
}

Read it top to bottom: the hold means a press that trips after two minutes never opens the valve, and from means a restart of the Gateway is not a press start; the first condition makes an already-open valve a successful run that commands nothing; the wait wakes the moment the valve entity changes; and when it never opens, the second condition lets the run go on to a stop that fails it, with the valve’s actual state in the reason and a warning in the system log. When the valve does open, that same condition is false and the run ends there, succeeded.

A webhook trigger registers POST /api/webhook/<path>. The body is available as vars.body. A path claimed by no rule returns 404 rather than accepting silently, a webhook posting into nothing looks exactly like one that works.

Two rules cannot claim the same path; the second is refused at load with a log line naming both.

A rule that is enabled but did not compile shows as NOT ARMED on the Automations screen, with a red dot and an explanation, and an error in the system log. This is the state worth being loudest about: the author believes it is running.

One bad rule does not disarm the others. Reload drops the rule that failed and arms everything else.