Alerts: rules, the inbox and quiet hours
Applies to release 3.18.1 · View source at the tag · Markdown
On this page
Bottom line: you can set your own alert rules, such as "tell me when Room A tank level stays below 30% for
10 min". A rule only sends a notification. It never starts, stops or holds a shot. Before you save a rule
you can replay it over yesterday's readings to see how often it would have fired. Each room's open alerts,
from your rules and from the controller, appear in one inbox, and any signed-in user can acknowledge
them. Each phone can have quiet hours. During quiet hours, pushes that are not critical wait and arrive
as one digest when the quiet hours end. Urgent pushes, including rules routed to emergency, always come through.
Who gets which alert on which phone: Notifications.
What still protects the crop is unchanged. Your irrigation pH and EC limits
(number.crop_steering_<prefix>irrigation_ph_min/max, irrigation_ec_min/max), the kill switch and the
controller's holds stop watering exactly as before. An alert rule adds a message and nothing else.
- Alert rules
- Preview: replaying a rule over history
- The alerts inbox and acknowledging
- Quiet hours
- Services reference
Alert rules

Find them under Settings › Alert rules. This is the default page of the Settings section.
What a rule is
A rule watches one source in one room. It fires when the value stays above or below a threshold
for at least min_duration_s seconds, inside its time window. The page shows each rule as a sentence:
Tell when Room A tank level stays below 30% for 10 min during lights-on.
| Source | What it reads | Unit | Extra field needed |
|---|---|---|---|
zone.vwc | sensor.crop_steering_<prefix>vwc_zone_<n> | % | zones |
zone.ec | sensor.crop_steering_<prefix>ec_zone_<n> | mS/cm | zones |
zone.temperature | The zone's mapped substrate temperature sensor | °C | zones |
zone.flow_ratio | sensor.crop_steering_<prefix>zone_<n>_flow_ratio, shown as % | % | zones |
feed.ph | sensor.crop_steering_<prefix>feed_ph_corrected once a calibration is applied, otherwise the sensor named as feed_ph_sensor in the engine config | pH | none |
feed.ec | sensor.crop_steering_<prefix>feed_ec_corrected once a calibration is applied, otherwise the sensor named as feed_ec_sensor in the engine config | mS/cm | none |
tank.level | sensor.crop_steering_<prefix>tank_level | % | none |
stock.pct | A stock tank's level as a share of its capacity | % | tank_id |
stock.litres | A stock tank's level | L | tank_id |
controller.heartbeat | Seconds since sensor.crop_steering_<prefix>ai_heartbeat last updated | s | none |
probe.silence_s | Seconds since a probe last gave a number | s | entity_id |
If no calibration is applied and the engine config names no feed sensor, a feed.ph or feed.ec rule
reads nothing and stays armed.
Climate sources (room temperature, humidity, VPD, CO2) cannot have rules. A rule using a room. or
climate. source is refused.
When a rule watches several zones, it uses the worst reading: the lowest for a below rule and the
highest for an above rule.
Rule fields
| Field | Values | Default |
|---|---|---|
room_id | A room such as room:f2_ | required |
source | One of the sources above | required |
zones | List of zone numbers, for zone sources | [] |
op | above or below | required |
threshold | A number, in the source's unit | required |
min_duration_s | 0 to 86400 whole seconds | 0 |
window | all, lights_on, lights_off, or {kind: custom, from: "HH:MM", to: "HH:MM"} | all |
severity | info, warning, emergency | warning |
routing | A notification kind: emergency, hardware, sensors, watering, phases, vitals, stock, dosing, jev, setup | sensors |
active | true or false (the On switch) | true |
lights_on and lights_off follow the room's lights-on and lights-off hours. A custom window may cross
midnight, but its start and end must differ.
Severity is only a label. It is stored and shown, and nothing uses it to decide anything. How loud the push is depends on routing:
routing: emergencyis pushed as urgent. Room mutes, digests and quiet hours never hold it.- Any other routing is a normal push to the phones that tick that kind under Settings › Notifications. Room mutes, digests and quiet hours apply to it.
How a rule behaves
| State | Meaning |
|---|---|
draft | Switched off (active: false) |
armed | Watching. The value is in range, or outside the window |
pending | Breaching, waiting out min_duration_s |
firing | Has fired. The alert is open |
acknowledged | Fired and acknowledged, still breaching |
muted | The room is muted and the rule is not routed to emergency |
- Rules are checked whenever a watched entity changes, whenever stock changes, and every minute. Ages such as heartbeat and probe silence grow without a state change, so the every-minute check matters for them.
- A reading that is missing or
unavailablenever fires a rule and never clears one. - When a rule fires, it writes
alert.raisedto the room's journal and sends one push. When the value comes back in range, it writesalert.cleared. A rule that has fired keeps firing until the value comes back or the rule is switched off, even if the time window ends or the room is muted. Switching off a firing or acknowledged rule (active: false) also writesalert.clearedand closes its alert. - A mute does more than silence a rule's push: while the room is muted, a non-emergency rule does not fire at all.
- Rule states are kept in memory. After a Home Assistant restart every rule starts again at
armed. If the value is still out of range, the rule fires again oncemin_duration_shas passed.
The rule's entity
Each rule is a binary sensor named after it, for example Room A tank level below 30%:
- entity
binary_sensor.crop_steering_rule_<id in lower case>(unique_idcrop_steering_rule_<id>, which keeps the rule id as stored, an upper-case ULID) - on while the rule is
firingoracknowledged, and off otherwise - attributes:
rule_id,room_id,source,op,threshold,unit,state,firing_since
Use it in your own automations. Acknowledging does not turn it off; it stays on until the value comes back or the rule is switched off. Deleting a rule removes its entity.
Saving, switching, deleting
Saving a rule needs an administrator, and so do deleting it and sending a test push. Listing, previewing and testing without sending are open to any signed-in user.
On the page, a save is read back before it is shown. Turning the On switch and deleting take effect
straight away and offer Undo. Each change is written to the journal as config.changed (section
alert_rules), so it appears under History › Changes and can be put back from there.
All rules on the site share one revision. alert_rule_set and alert_rule_delete take
expected_revision (from alert_rule_list). If someone changed the rules in the meantime, the save is
refused with "Alert rules changed elsewhere. Reload before saving."
Preview: replaying a rule over history
Before you save, the page replays the draft over yesterday's recorded readings. It shows the spans where the rule would have fired, and you can drag the dashed threshold line to try another value. Arrow Up and Arrow Down move it one step; hold Shift to move ten steps. The replay uses the same breach, duration and window logic as the live rule, but it does not know about past room mutes. A preview can show fires that the live rule would not have sent because the room was muted at the time.
The answer gives a sentence such as:
- "This rule would have fired 2 times yesterday — 03:10, 14:45."
- "It wouldn't have fired in the last 24 h. Lowest was 34%."
- "That's 18 messages a day. Try a longer wait or a lower threshold." (shown when it would fire more than 10 times)
- "There's no history for this in the last 24 h."
It also lists ignored spans: breaches that would not have fired, each with a reason:
shorter than min_duration: the value came back too soon.outside window: the breach was outside the rule's window.
The replay reads Home Assistant's recorder. With no recorder there is no history, and the preview says so.
hours can be 1 to 168 (a week); the default is 24. A long series is cut down to 1440 points, and each
point keeps the worst value.
Test a rule with alert_rule_test. Without send it only replays. With send: true, which needs
an administrator, it also sends one push titled Test: <rule name> through the normal routing. The
answer's delivery lists each phone with ok and an error. A phone in quiet hours shows "held for
the phone's digest or quiet hours", and a muted room shows "The room is muted".
The alerts inbox and acknowledging
Each room has one sensor listing its open alerts:
sensor.crop_steering_<prefix>alerts_open. Its state is the number of open alerts.- Its
alertsattribute lists them newest first, each withevent_id,ts,code,title,zone,entity_idand, for a rule,rule_id.
The open alerts also show beside the devices they concern on Settings › System status, each with an Acknowledge button.
When an alert is open
The inbox includes alerts from your rules and from the controller. An alert opens with its
alert.raised and stays open until one of these happens:
- It clears (
alert.cleared): for a rule, when the value comes back in range or the rule is switched off. - Someone acknowledges it (
alert.acknowledged). - For a controller alert, its Home Assistant notification (
f2_<key>) is dismissed.
A raise that repeats while the alert is already open does not add a second entry; the first one stays.
After a restart, stale alerts stay open. Home Assistant keeps notifications only in memory. After a restart the inbox cannot tell whether a notification was dismissed, so a controller alert stays open until its notification is seen again and then dismissed, or until it is acknowledged. A stale alert is safer than one that drops silently.
The sensor updates on every stored alert.* event and every 5 minutes. If the journal cannot be read,
the sensor shows unavailable. Nothing in the inbox touches irrigation.
Acknowledging
Any signed-in user can acknowledge an alert, not only an administrator, so staff on the floor can acknowledge alarms.
action: crop_steering.alert_ack
data:
room_id: "room:f2_"
alert_event_id: "01JB7Q3M2V8K5N0R4T6W9XYZAB" # event_id from the alerts attribute- The journal records an
alert.acknowledgedevent that says who acknowledged it, and the alert leaves the inbox. - For a rule, the rule's state becomes
acknowledged. Its binary sensor stays on until the value comes back, and the rule does not push again for the same breach. - An alert that is not in that room's journal is refused. So is one that is already closed ("The alert … is already closed"). If the journal is down the acknowledgement is refused and nothing changes; try again.
Quiet hours

Each phone (a notification recipient) can have quiet hours, {from: "HH:MM", to: "HH:MM"}. They use
Home Assistant's time zone. They may cross midnight (22:00 to 06:00). The start time is
included and the end time is not. Start and end must differ. Every phone starts with no quiet hours.
What waits and what comes through:
| Push | During the phone's quiet hours |
|---|---|
| An urgent push | Sent at once |
| A code in the Emergencies kind (such as CS-601, CS-801) that is not marked urgent | Not held by quiet hours, but a phone with digest on still holds it |
A rule routed to emergency | Sent at once |
| A known non-critical code, event or rule push | Held |
| The controller's non-critical "not watering" warnings (e.g. CS-206, CS-316) | Held |
| A code or event this version does not know | Not held by quiet hours, but a phone with digest on still holds it |
| An AI setpoint push with Keep / Undo, to a phone without digest | Sent at once (a digest has no buttons) |
Held pushes join that phone's digest queue, even if the phone does not normally take a digest. They go out as one push as soon as quiet hours end. The queue is checked every minute. A later push with the same key replaces the one waiting. The digest groups items by severity.
How quiet hours combine with other settings:
- Digest: a digest that falls due during quiet hours waits until they end.
- Room mute: a mute comes first. While a room is muted, only an urgent push is sent for it, so nothing is held for later either. An Emergencies-kind code that is not marked urgent is dropped too.
- Only an urgent push gets past every hold: quiet hours, digest and room mute.
- Restart: the queue is kept in memory only. A restart drops what is waiting, just as it does for a digest.
Setting quiet hours
The Notifications page does not edit quiet hours yet; set them with crop_steering.notify_save. Saving
from the page keeps each phone's quiet hours as they are. First read the current setup:
action: crop_steering.notify_get
response_variable: notifyThen send every row back, adding quiet_hours where you want them:
action: crop_steering.notify_save
data:
expected_revision: 7 # revision from notify_get
recipients:
- service: notify.mobile_app_grower_phone
kinds: [emergency, hardware, sensors, watering, stock]
rooms: ["f2_"]
quiet_hours: {from: "22:00", to: "06:00"}
- service: notify.mobile_app_night_staff
kinds: [emergency, hardware]
quiet_hours: null # no quiet hoursrooms takes room prefixes: "" for the default room, or for example "f1_". An automation, like an
administrator, can save every row. A signed-in user who is not an administrator can save the kinds,
rooms, high-priority flag, digest and quiet hours of their own phone's row only.
Testing several phones at once
notify_test takes one phone (service) or a list of up to 20 phones (services), but not both:
action: crop_steering.notify_test
data:
services: [notify.mobile_app_grower_phone, notify.mobile_app_night_staff]The answer is {results: [{service, sent, error}]}, with one row per phone. Anyone can test their own
phone; testing someone else's phone needs an administrator.
Services reference
| Service | Who | Fields | Answer |
|---|---|---|---|
crop_steering.alert_rule_list | any signed-in user | room_id (optional) | {rules: [rule + summary_sentence, state, last_fired, firing_since, current_value], revision} |
crop_steering.alert_rule_preview | any signed-in user | rule, hours (1–168, default 24) | {summary_sentence, series, fires: [{start, fired_at, end}], ignored: [{start, end, reason}], count, min, max, sentence} |
crop_steering.alert_rule_test | any signed-in user; send: true needs admin | id or rule, hours, send | {summary_sentence, replay, delivery?: [{service, ok, error}]} |
crop_steering.alert_rule_set | administrator | rule (with id to change an existing rule), expected_revision | {rule, summary_sentence, revision} |
crop_steering.alert_rule_delete | administrator | id, expected_revision | {id, revision} |
crop_steering.alert_ack | any signed-in user | room_id, alert_event_id | {room_id, alert_event_id, event_id} |
crop_steering.notify_save | administrator or automation; own row for others | expected_revision, recipients (rows may carry quiet_hours), digest_hours, mutes, idle_hours | the stored setup |
crop_steering.notify_test | administrator or the phone's own user | service or services | {sent, error} or {results: […]} |
The alert services also accept the common API fields api, client and proposal_token (see
Configure Crop Steering through MCP for the api: 2 answer format). With api: 2, a test push that a phone did not
receive comes back as an unavailable error that names the phone.
Example: preview a rule for a stock tank running low over two days.
action: crop_steering.alert_rule_preview
data:
hours: 48
rule:
room_id: "room:f2_"
source: stock.pct
tank_id: "bloom_a"
op: below
threshold: 15
min_duration_s: 600
routing: stockThen save it, using the revision from alert_rule_list:
action: crop_steering.alert_rule_set
data:
expected_revision: 3
rule:
room_id: "room:f2_"
source: stock.pct
tank_id: "bloom_a"
op: below
threshold: 15
min_duration_s: 600
routing: stockJournal events to watch in automations (crop_steering_event on the bus):
alert.raised, with datarule_id,value,thresholdandtitlefor a rule, orkeyfor a controller alertalert.clearedalert.acknowledged, with dataalert_event_idplusrule_idorkey
Related: Notifications: who gets which alerts (who gets which kind), Error codes (what each CS code means), 3.18 upgrade report, line by line (the upgrade report row).