Triggers
A trigger defines what event causes a rule to be evaluated. Every rule has exactly one trigger.
Trigger reference
DeviceStateChanged
Fires when any MQTT state publish arrives for a device.
[trigger]
type = "device_state_changed"
device = "entryway.front_door"
# Optional: only fire when this attribute changes
attribute = "open"
# Optional: only fire when the attribute changes TO this specific value
to = true
| Field | Required | Description |
|---|---|---|
device | yes¹ | Preferred device reference: canonical name, unique display name, or raw device ID. |
device_id | yes¹ | Backward-compatible alias for device. |
device_ids (alias devices) | no | Additional device IDs — fires when any of these changes. When non-empty the primary device is also included in the set. |
attribute | no | Narrow to one attribute (e.g. "on", "open", "temperature"). |
to | no | Only fire when the new value equals this. |
from | no | Only fire when the previous value equals this. |
not_to | no | Only fire when the new value is not this. |
not_from | no | Only fire when the previous value is not this. |
for_duration_secs | no | "Sticky" trigger: only fire after the new value has held for this many seconds. |
change_kind | no | Provenance filter: homecore, physical, external, or unknown. |
change_source | no | Exact-match filter on the change source label (plugin id or rule id). |
¹ One of device or device_ids must be set.
If you use a display name and more than one device matches it, HomeCore marks the rule invalid with an ambiguity error.
Examples:
# Fire on any state change for a device
[trigger]
type = "device_state_changed"
device = "hallway.dimmer"
# Fire only when the "on" attribute changes (either direction)
[trigger]
type = "device_state_changed"
device = "living_room.floor_lamp"
attribute = "on"
# Fire only when a door opens (open = true)
[trigger]
type = "device_state_changed"
device = "garage.main_door"
attribute = "open"
to = true
# Multi-device: any of three contact sensors going open
[trigger]
type = "device_state_changed"
device_ids = ["sensor_front", "sensor_back", "sensor_garage"]
attribute = "open"
to = true
# Only fire when the change came from a physical actor (not from a rule)
[trigger]
type = "device_state_changed"
device = "living_room.lamp"
attribute = "on"
change_kind = "physical"
# Sticky: motion has held "true" for at least 30 seconds
[trigger]
type = "device_state_changed"
device = "motion_hallway"
attribute = "motion"
to = true
for_duration_secs = 30
TimeOfDay
Fires at a specific time on specified days of the week. Computed in local wall-clock time.
[trigger]
type = "time_of_day"
time = "07:30" # HH:MM
days = ["Mon", "Tue", "Wed", "Thu", "Fri"]
Valid day names: Mon, Tue, Wed, Thu, Fri, Sat, Sun
Catch-up on restart: If HomeCore restarts after a TimeOfDay trigger's scheduled time, the scheduler fires it immediately if the missed time falls within catchup_window_minutes (default 15).
SunEvent
Fires at sunrise or sunset, computed locally from your latitude/longitude in config.
[trigger]
type = "sun_event"
event = "sunset" # "sunrise" | "sunset"
offset_minutes = -30 # minutes before (-) or after (+) the event
Examples:
# At sunrise
[trigger]
type = "sun_event"
event = "sunrise"
# 30 minutes before sunset
[trigger]
type = "sun_event"
event = "sunset"
offset_minutes = -30
# 15 minutes after sunrise
[trigger]
type = "sun_event"
event = "sunrise"
offset_minutes = 15
Cron
Fires on a repeating cron schedule. Uses 6-field expressions (second, minute, hour, day-of-month, month, day-of-week) evaluated in local wall-clock time.
[trigger]
type = "cron"
expression = "0 30 7 * * Mon-Fri"
# ^ ^ ^ ^ ^ ^
# s m h dom mo dow
| Expression | Meaning |
|---|---|
0 0 7 * * * | 7:00 AM every day |
0 30 7 * * Mon-Fri | 7:30 AM weekdays only |
0 */15 * * * * | Every 15 minutes |
0 0 */2 * * * | Every 2 hours |
0 0 8 1 * * | 8:00 AM on the 1st of every month |
0 0 20 * * Sat,Sun | 8:00 PM weekends only |
Rules with invalid cron expressions are automatically disabled at startup with an error field set.
# Check for invalid cron rules
curl -s http://localhost:8080/api/v1/automations \
-H "Authorization: Bearer $TOKEN" \
| jq '[.[] | select(.error | strings | contains("cron")) | {name, error}]'
WebhookReceived
Fires when an HTTP POST arrives at /api/v1/webhooks/{path}. No authentication required. The path acts as the shared secret.
[trigger]
type = "webhook_received"
path = "front-door-bell-a3f9c2"
The webhook URL: POST http://homecore.local/api/v1/webhooks/front-door-bell-a3f9c2
The request body (if valid JSON) is forwarded as the body field in the event payload and accessible in ScriptExpression conditions:
# Condition using webhook body
[[conditions]]
type = "script_expression"
script = 'event.body["pin"] == "1234"'
Example — trigger from a cloud service:
# Fire the trigger from any HTTP client — a script, a scheduler, a physical button, etc.
curl -X POST http://homecore.local/api/v1/webhooks/front-door-bell-a3f9c2 \
-H "Content-Type: application/json" \
-d '{"source": "doorbell", "action": "pressed"}'
CustomEvent
Fires when a FireEvent action emits the matching event_type. Enables clean rule chaining — one rule fires an event, other rules react to it in the same process with no MQTT round-trip.
[trigger]
type = "custom_event"
event_type = "morning_routine_started"
This is the primary mechanism for fan-out (one event → many parallel reactions) and pipeline patterns (chain of rules that each do one thing).
Example chain:
# Rule 1: motion sensor → start morning routine
[trigger]
type = "device_state_changed"
device_id = "motion_bedroom"
attribute = "motion"
to = true
[[actions]]
type = "fire_event"
event_type = "morning_routine_started"
payload = {}
# Rule 2: morning routine → turn on coffee
[trigger]
type = "custom_event"
event_type = "morning_routine_started"
[[actions]]
type = "set_device_state"
device_id = "smart_plug_coffee"
state = { on = true }
# Rule 3: morning routine → set thermostat
[trigger]
type = "custom_event"
event_type = "morning_routine_started"
[[actions]]
type = "set_device_state"
device_id = "thermostat_main"
state = { target_temp = 70 }
SystemStarted
Fires once, immediately after the rule engine finishes pre-populating its device cache on startup. Use to catch state that may have changed while HomeCore was offline.
[trigger]
type = "system_started"
Always pair with DeviceState conditions to guard the action:
# Alert if garage door was left open across a restart
[trigger]
type = "system_started"
[[conditions]]
type = "device_state"
device_id = "yolink_garage_door"
attribute = "open"
op = "Eq"
value = true
[[actions]]
type = "notify"
channel = "telegram"
message = "Garage door is OPEN (detected on startup)"
DeviceAvailabilityChanged
Fires when a device comes online or goes offline.
[trigger]
type = "device_availability_changed"
device_id = "hue_001788fffe6841b3_1"
# Optional: only fire in one direction
to = false # only when device goes offline
# Optional: sticky guard — only fire after the new state has held this long
for_duration_secs = 60
| Field | Required | Description |
|---|---|---|
device_id (alias device) | yes | Device to monitor. |
to | no | true = online only, false = offline only, omitted = both. |
for_duration_secs | no | Only fire after the new availability state has held for this many seconds (debounces flapping devices). |
MqttMessage
Fires when a raw MQTT message arrives on a matching topic. Supports MQTT wildcards.
[trigger]
type = "mqtt_message"
topic_pattern = "homecore/devices/+/state" # + = one level
# topic_pattern = "homecore/devices/#" # # = rest of path
Use this for non-standard integrations or when you need to react to any MQTT traffic, not just typed device events.
ManualTrigger
Never fires automatically. Only activated via the /test dry-run endpoint.
[trigger]
type = "manual_trigger"
Useful for rules that should only ever run when explicitly tested.
ModeChanged
Fires when a named mode transitions on or off.
[trigger]
type = "mode_changed"
mode_id = "mode_night" # optional — omit to fire on any mode change
to = true # optional — only when turning on (false = only off)
| Field | Required | Description |
|---|---|---|
mode_id | no | Mode device id (e.g. "mode_night"). Omit to match any mode. |
to | no | true = only when the mode turns on, false = only when it turns off, omitted = both. |
ButtonEvent
Fires when a physical button device (e.g. a Pico remote, keypad, or smart switch) reports a button press.
[trigger]
type = "button_event"
device_id = "lutron_pico_bedroom"
button_number = 2 # optional: specific button number
event = "Pushed" # "Pushed" | "Held" | "DoubleTapped" | "Released"
| Field | Required | Description |
|---|---|---|
device_id (alias device) | yes | Button device. |
button_number | no | Specific button number. Omit to match any button on the device. |
event | yes | One of Pushed, Held, DoubleTapped, Released. |
Implementation note: button events arrive as
DeviceStateChangedevents with attribute name matching the event type (pushed,held,double_tapped,released) and the button number as the value. TheButtonEventtrigger is a typed wrapper that handles the routing for you.
NumericThreshold
Fires when a numeric attribute crosses a threshold value. Unlike
DeviceStateChanged + to, this trigger fires only on the crossing
edge (the transition itself), not on every change while the value
remains past the threshold.
[trigger]
type = "numeric_threshold"
device_id = "sensor_basement_temp"
attribute = "temperature"
op = "CrossesAbove" # crossing edge only
value = 80.0
for_duration_secs = 60 # optional: debounce
| Field | Required | Description |
|---|---|---|
device_id (alias device) | yes | Device to monitor. |
attribute | yes | Numeric attribute name. |
op | yes | "Above" / "Below" / "CrossesAbove" / "CrossesBelow" (see below). |
value | yes | Threshold value. |
for_duration_secs | no | Only fire if the threshold condition has held for at least this many seconds (debounce). |
op | Fires when |
|---|---|
Above | The new value is > value (every change while above). |
Below | The new value is < value (every change while below). |
CrossesAbove | The previous value was ≤ value and the new value is > value (one-shot edge). |
CrossesBelow | The previous value was ≥ value and the new value is < value (one-shot edge). |
For one-shot alerting (e.g. "fire once when temp goes above 80"), use
CrossesAbove / CrossesBelow. For "any sample while we're past the
threshold", use Above / Below.
HubVariableChanged
Fires when a hub variable is written via SetHubVariable action or
API. The trigger does not filter on values — combine with a
HubVariable condition for value-based filtering.
[trigger]
type = "hub_variable_changed"
name = "alarm_armed" # optional — omit to watch all hub vars
| Field | Required | Description |
|---|---|---|
name | no | Specific variable name. Omit to fire on any hub variable change. |
Periodic
Fires at a fixed interval. Simpler than Cron for basic repeating
tasks.
[trigger]
type = "periodic"
every_n = 15
unit = "Minutes" # "Minutes" | "Hours" | "Days" | "Weeks"
| Field | Required | Description |
|---|---|---|
every_n | yes | Interval count. |
unit | yes | Minutes, Hours, Days, or Weeks. |
CalendarEvent
Fires based on .ics calendar events. See Advanced: Calendar triggers for full configuration.
[trigger]
type = "calendar_event"
calendar_id = "us_holidays" # optional — stem of the .ics filename
title_contains = "Holiday" # optional — case-insensitive substring
offset_minutes = -30 # fire 30 min before event start
| Field | Required | Description |
|---|---|---|
calendar_id | no | Stem of the .ics filename (e.g. "us_holidays" for us_holidays.ics). Omit to match any loaded calendar. |
title_contains | no | Case-insensitive substring match on the event title. Omit to match any event. |
offset_minutes | no | Negative = before event start, positive = after, default 0. |
DeviceBatteryLow
Fires once when a device's battery percentage crosses the configured low
threshold. Hysteresis is enforced by the watcher (latches at
threshold_pct, clears at threshold_pct + recover_band_pct), so the
trigger fires only on the crossing edge — not on every battery report.
device_id is optional: omit it to match any battery-powered device.
[trigger]
type = "device_battery_low"
# device_id = "yolink_d88b4c0400064299" # optional — omit to match any device
Thresholds live in [battery] in homecore.toml. See Battery monitoring for the full feature, including how to identify which device fired the trigger inside an action.
DeviceBatteryRecovered
Counterpart to DeviceBatteryLow. Fires once when a previously-low
device's battery climbs back above threshold_pct + recover_band_pct.
[trigger]
type = "device_battery_recovered"
# device_id = "yolink_d88b4c0400064299" # optional
Trigger context in conditions
For WebhookReceived triggers, the request body is available in ScriptExpression conditions as event.body. For DeviceStateChanged, event.device_id, event.attribute, and event.value are available.
For DeviceBatteryLow and DeviceBatteryRecovered, the firing device's id is exposed to Rhai via trigger_device(). Pair it with device_state(id) inside a RunScript action to read the device name and current battery level.