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.
Triggers
Section titled “Triggers”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 |
Thresholds fire on the crossing
Section titled “Thresholds fire on the crossing”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 triggers follow entities
Section titled “State triggers follow entities”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.
Holding a state: forSec
Section titled “Holding a state: forSec”“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
unavailableis 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
forSeceach 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, andfromState,toStateandentityare 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. Withoutfromit depends on a race you do not control. If the first reading lands after the rules are listening, it is a change,unknowntoon, and it starts a hold from zero: the rule fires five minutes after that, and its trace sayschanged from unknown to on. If the device answers faster than the Gateway comes up, the press is simplyonfrom the start, nothing changed, and nothing fires until it next does. Sayfromwhen 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.
Sun triggers
Section titled “Sun triggers”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.
offsetSecis at most six hours either side (21600). Further than that is aschedule.
The trace says what fired it and when: 30m before sunset at 2026-10-01T22:38:00Z, and trigger is sun in the condition.
Conditions
Section titled “Conditions”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 > 200value > 200 and quality == 'good'tags['press-01/Zone1_Temp'] > 200 and tags['press-01/Pressure'] < 50hour >= 6 and hour < 18 and weekday != 0 and weekday != 6qualities['press-01/Zone1_Temp'] == 'good'strings['cnc-01/Program'] == 'PART_4471.NC'value > previousstates['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'] == trueGuard 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
Section titled “Actions”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.
write_tag
Section titled “write_tag”{ "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>.
call_service
Section titled “call_service”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.
condition
Section titled “condition”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.
call_script
Section titled “call_script”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.
apply_recipe
Section titled “apply_recipe”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>.
The rest
Section titled “The rest”| 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" }}Run modes
Section titled “Run modes”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.
Waits, delays and run modes
Section titled “Waits, delays and run modes”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 itsforSecholds 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.
Run history
Section titled “Run history”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.
A worked example
Section titled “A worked example”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.
Webhooks
Section titled “Webhooks”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.
When a rule will not arm
Section titled “When a rule will not arm”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.