Notifications: who gets which alerts
Applies to release 3.18.1 · View source at the tag · Markdown
On this page
Every alert still becomes a Home Assistant notification card, as before. What this adds is who gets a phone push for what: each phone has a row of checkboxes (stock tanks, emergencies, sensors and drift, phase changes, hardware lockouts, watering stopped, and so on), and a push goes only to the phones whose row ticks that kind of alert. It also adds the events a person can now subscribe to that were never alerts: phase changes, the AI assistant's setting changes, and "no watering for a while".
Alert rules, the inbox and quiet hours: Alerts.
This document is the contract between the three layers. Nothing in it names a site's phones or people.
Layers
| Layer | Owns | Never does |
|---|---|---|
| Integration | The site's notification setup (one store for the whole site, not per room); the list of phones; the services to read, save, test and send; routing each push to the phones that want it | Push anything nobody ticked (except the fallback below) |
| Controller app | Raising alerts (unchanged) and the new events; handing each push to crop_steering.notify; the fallback to its old notify_service option | Decide who gets what |
| Dashboard | Settings › Notifications: the checkbox grid, each phone's rooms, a test push per phone, the watering-stopped threshold | Call a notify service itself |
The kinds of alert (the checkboxes)
A code can be in more than one kind (CS-301 is an emergency and a hardware lockout); a phone that ticks either gets it once.
| Id | Checkbox | What it covers |
|---|---|---|
emergency | Emergencies | Every alert whose severity is critical in docs/error-codes.json (CS-201 to 204, 207, 211, 212, 301, 308, 310, 311, 315, 402, 601, 606, 801, 809, 811, 901, 902) |
hardware | Hardware lockouts | CS-301 to CS-309 (a pump or valve that did not do what it was told, a hardware hold, a shot cut), CS-310 (moisture rising with no water: a table not draining), CS-311 (the sump pump has not run), CS-312 (a zone that keeps hitting its longest allowed shot), CS-313 (a valve or pump the controller can't switch), CS-314 (the sump water balance does not close), CS-315 (rescue shots that do not raise the moisture), CS-316 (a waste valve that is not closed for a shot), CS-317 (a pump that runs with every valve shut), CS-214 (a zone that needs more than its pump; it waters anyway), CS-318 (a flow meter that saw less water than a shot gave), CS-319 (a flow meter that counts water with no zone on), CS-701 (water isn't reaching a zone) and CS-710 (water may be bypassing the substrate or the probe) |
sensors | Sensors and drift | CS-101 to CS-104, CS-502 (a zone's probes disagree), CS-503 (a moisture probe set aside for its unit), CS-504 (a zone steering on bulk EC: no pore EC probe reporting), CS-505 (a zone's primary probe is offline), CS-603, CS-604, CS-703, CS-704, CS-907 (a room with no feed EC, so no EC flush) |
watering | Watering stopped | CS-202 to CS-208, CS-602, CS-609 (another controller on the room), CS-209 (new: no watering for a while), CS-211 (a zone's shots held for too long), CS-212 (a room skipped after a controller error), and CS-901 to CS-904 (the tank at its minimum, its level unknown, a refill due before the next round of shots, a fill stopped over the full mark), CS-316 (a waste valve that holds the shots), CS-905 (a low estimated tank level) and CS-906 (a batch tank to refill) |
phases | Phase changes | A zone moving P0 → P1 → P2 → P3 (new event, no card) |
vitals | Vitals | The controller's regular report on every zone (event vitals, no new card). Not ticked on any row until someone ticks it, an upgraded row included |
stock | Stock tanks | CS-608, CS-807 |
dosing | Dosing | CS-801 to CS-806, CS-808, CS-809 (the batch tank's pH not corrected), CS-810 and CS-811 (the light dimmed on dry substrate) |
jev | AI assistant | CS-501, CS-702, CS-705, CS-706, CS-707, CS-404, CS-708 (the AI assistant's budget is spent until lights-on), CS-709 (its key was refused or it is not answering), CS-711 (a proposal to change the feed, for a person to decide), CS-712 (the options name the removed first AI route), CS-713 (the room not running its grow plan as written), and the AI assistant moving a setting (new event, no card) |
setup | Setup and settings | CS-201, CS-401 to CS-405 (except 404), CS-601, CS-605, CS-606, CS-607, CS-610, CS-611, CS-613 (a deprecated service or select in use), CS-614 (a retired entity an automation or script still uses), CS-713 (the room not running its grow plan as written: also under jev) |
The mapping lives in one place in the integration (notify_catalog.py) and is returned by notify_get,
so the page shows exactly what each checkbox covers. A code the catalog does not know goes to
emergency when its severity is critical, else to setup; a test fails when a code in
docs/error-codes.json is in no kind at all.
New events
- Phase change (
event: "phase"): the controller sends one when a zone's phase changes, with the room, zone, from and to (for example "Room A · Zone 2: P1 → P2, ramp target reached"). No card; a push only. - AI assistant setting change (
event: "jev_setpoint"): when the AI assistant moves a zone's P2 shot size or re-water point (docs/AI_ASSISTANT.md). No card; a push only. The move is an AI overlay that goes back to your own value by itself at lights-on, and on a phone with the Home Assistant app the push carries two buttons: Keep keeps the AI's value as your own, Undo puts your own value back now. The controller names the numbers it wrote (overlays, their entity ids) and the integration adds the buttons; a tap comes back as the app'smobile_app_notification_actionevent, as the phone's own user, and goes throughoverlay_keeporoverlay_undoexactly as the page does, so it needs a Home Assistant administrator. A setting whose overlay has already run out or ended has nothing left to keep or undo. The controller names them only to an integration whosesensor.crop_steering_notify_configsaysoverlay_actions: true. - Vitals (
event: "vitals"): the controller's report on every zone, everynotify_minminutes, keyvitals, title " vitals". Under each zone, while the room'snotify_predictionsswitch is on (it is until someone switches it off), a "Next:" line says what the zone does next, in the words of the dashboard's Next: line. It is still thef2_vitalscard andsensor.f2_control_vitals, as before. - CS-209 No watering for a while (a card and a push): a room that is active, with lights on, has watered
nothing for
idle_hours(default 3, set on the page, 1 to 12): no shot fired in any of its zones, whether because watering is switched off, a hold, or nothing called for water. Raised once per stretch and again everyidle_hourswhile it lasts; cleared by the next shot, or once the room leaves the condition (lights off, no zone in P1 or P2, the room switched off). A zone in P0 (the morning dry-back) or in P3 does not start the clock; the clock runs only while at least one zone is in P1 or P2.
Configuration (integration store crop_steering.notify, one for the site)
{
"revision": 3,
"idle_hours": 3,
"recipients": [
{
"service": "notify.mobile_app_pixel_7", // any notify.* service; the page offers the site's phones first
"name": "Grower's phone", // shown on the page; defaults to the device name
"user_id": "7f21…", // the Home Assistant user the phone belongs to, when known
"kinds": ["stock", "dosing"], // ids from the table above
"rooms": [], // room prefixes ("" = the default room, "f1_"); empty = every room
"urgent_high_priority": true, // emergencies go out as high-priority / time-sensitive pushes
"digest": false, // true: what is not an emergency waits for the digest
"quiet_hours": {"from": "22:00", "to": "06:00"} // optional; null or absent = none (see Quiet hours)
}
],
"digest_hours": 4, // a digest phone gets one push this often (1 to 24)
"mutes": {"f1_": "2026-10-15T08:00:00+00:00"} // room prefix -> when its mute ends (UTC)
}A phone is a notify.mobile_app_* service. The integration lists them with their device name and the
Home Assistant user that registered the app (the mobile_app config entry's user), so each row shows whose
phone it is. Other notify services (a group, Supernotify) can be added as rows by their service name.
A save checks every row: service is notify.<name>, each service once, at most 20 rows; a phone added
now must be a notify service Home Assistant has, while one saved before may be away (an app not registered
again since a restart never blocks a save); kinds are ids from the table; rooms are room prefixes;
urgent_high_priority is true when left out; idle_hours is 1 to 12; digest_hours is a whole number of
hours from 1 to 24; mutes maps a room prefix to an ISO time with its offset, at most 30 days ahead, kept in
UTC, and one already past is dropped. A row sent without digest, or a save without digest_hours or
mutes (a page from before them), keeps what is stored. A store saved before them loads with no digest,
digest_hours 4 and nothing muted. A row an administrator saves
without a name or a user_id takes them from the phone's mobile_app registration.
sensor.crop_steering_notify_config is written by the integration with the first room that loads and
removed with the last (it is not a registry entity): state the revision, attributes recipients (how many
rows), idle_hours, and muted (the mutes, only while there are any). While the store can't be read it is unavailable, with error, and the
controller pushes as with no recipients; Home Assistant's log says so at start-up. A kind a row names that
this version does not know (saved by a newer one, before a rollback) is dropped when the store is read,
not the whole setup.
Services
| Service | Who | Data | Response |
|---|---|---|---|
crop_steering.notify_get | any signed-in user | none | {schema_version: 1, config, kinds[{id, name, detail, codes[], events[]}], phones[{service, name, user_id, user_name}], can_edit_all, user_id} |
crop_steering.notify_save | admin: any row; a non-admin: only rows whose user_id is their own (they cannot add or remove rows) | expected_revision, recipients, idle_hours?, digest_hours?, mutes? | as notify_get, or error ("revision", "not allowed: …", a readable message) |
crop_steering.notify_test | admin, or the phone's own user | service, or services (a list of up to 20 phones), not both | {sent: true} or error; with services, {results: [{service, sent, error}]} |
crop_steering.notify | admin (the controller) | key, code?, event?, room?, zone?, title, message, urgent? | {sent_to[], error}, with muted: true or held[] (below) |
notifyworks out the push's kinds fromcode(the catalog) orevent, then sends to every recipient that ticks one of them and whoseroomsis empty or includesroom, once per service. A push about no room in particular (noroom: the controller's dosing as a whole, its clock) goes to every recipient that ticks one of its kinds.urgent(the controller sets it for critical codes) adds the high-priority data for a recipient withurgent_high_priority: Android{"priority": "high", "channel": "Crop Steering urgent"}, iOS{"push": {"interruption-level": "time-sensitive"}}, both in the samedata(each app ignores the other's keys;datais sent to mobile_app services only). Every push carriesdata.tag = key, so a repeat replaces the earlier one on the phone instead of piling up, and the link that opens the Crop Steering panel when it is tapped (urlfor iOS,clickActionfor Android, both/crop-steering).- A muted room (
mutes, until its time) gets no push but an emergency: a push for it that is noturgentanswers{sent_to: [], error: null, muted: true}(to nobody, on purpose). A push about no room in particular is never muted. - A digest. A row with
digestgets what is noturgentas one push,digest_hoursafter the first one it holds (checked every minute): title "Crop Steering: 3 pushes held", and the held pushes' titles under "Warnings", "For your information" (the info codes of docs/error-codes.json,notify_catalog.INFO) and "Updates" (events), withdata.tag = "crop_steering_digest". A repeat of the same key replaces the one waiting. The answer lists those rows inheld. An emergency never waits. What waits is held in memory: a restart of Home Assistant drops it (the cards stay). - A failed send to one phone never stops the others; failures are listed in the response and logged. A phone that has not answered in 8 s counts as failed.
- With no recipients saved, or a store that can't be read,
notifysends nothing and says why inerror("no phone is set up for notifications"). A push nobody ticked answers{sent_to: [], error: null}: it went to nobody on purpose. The controller falls back only when nothing was sent anderrorsays why. - An emergency always reaches someone. An
urgentpush that no row covers for its room goes to every row that ticksemergency, whatever its rooms (logged as a warning); with no such row at all it answers{sent_to: [], error: "no phone takes emergencies"}, and the controller pushes it to itsnotify_serviceoption. sent_tolists the phones Home Assistant's notify service accepted the push for. It is not proof of delivery: the mobile_app relay's own failures (rate limit, relay errors) are only in Home Assistant's log.- Who, in detail:
notify_getanswers anyone, as every read in this integration does (can_edit_allis false without a signed-in administrator); a call with no user (an automation) saves and tests as an administrator, as for every service that changes something; a user id Home Assistant does not know may neither save nor test.notify_getandnotify_saveanswererrortoo (null when all is well). - A non-administrator's save is matched to the stored rows by
service, in any order. For a row that is not theirs, its kinds, rooms and high-priority flag must come back unchanged; for their own, only those three anddigestchange. They may not changeidle_hours,digest_hoursormutes, which are the site's. notify_testsends at normal priority, withdata.tag = "crop_steering_test", and says what the phone's row ticks.- The integration's own Repairs cards (CS-601 to CS-609) go out through
notifywhen a card is created, with key = its issue id, its room, and the card's own title and text; not when a card still raised is updated, or raised again after Home Assistant restarts, and not again within 30 minutes when a card clears and comes back (a probe going on and off line), as the controller repeats an alert. The 30 minutes start only once a phone has the push: one that reached nobody is pushed again when the card comes back.
Quiet hours, alert rules and the alerts inbox
These three are described in full in Alerts:
- Quiet hours (
quiet_hourson a recipient row,HH:MMtoHH:MMin Home Assistant's time zone, may cross midnight). Known non-critical pushes are held and sent as one push when quiet hours end; an urgent push always comes through. The Notifications page does not edit them yet: set them withnotify_save, and a save from the page keeps them (Quiet hours). - Alert rules: your own threshold rules on a zone, feed, tank, stock tank, probe or controller
reading. A rule only notifies and never touches watering. Its
routingpicks the kind of alert it is pushed as, and onlyrouting: emergencyis urgent (Alert rules). - The alerts inbox:
sensor.crop_steering_<prefix>alerts_opencounts each room's open alerts, andcrop_steering.alert_ackacknowledges one. Any signed-in user can acknowledge (The alerts inbox).
The controller
_alertstill creates the card first, exactly as now, and the code's severity in docs/error-codes.json decides whether a repeat pushes at all (below). When it pushes:- with at least one recipient saved (the integration publishes
sensor.crop_steering_notify_config, state = the revision, attributerecipients= the count,kinds= the kinds a row can tick), it callscrop_steering.notifyand sends nothing itself; - with no recipients saved, when that call fails, or when an emergency reached no phone through it, it
pushes to its
notify_serviceoption as it always has, so an install that never opens the page behaves exactly as before, and a push is never lost. With that option empty too, the controller's log says the push went to no phone.
- with at least one recipient saved (the integration publishes
- A room muted in the sensor's
mutedpushes nothing but a critical code until its time, throughcrop_steering.notifyor to thenotify_serviceoption alike (the card is still raised). An integration from before mutes publishes none. - Phase changes and the AI assistant's setting changes are sent through
crop_steering.notifyonly (never the fallback: they are not alerts). - CS-209 is raised by the controller like any alert (key
idle_<room>), with its code written out.
In detail:
- A standing alert's card is updated every 30 minutes; its push decays by severity, the same with the AI
assistant on or off. A critical code, or a warning that means a zone is not being watered (the controller's
NOT_WATERING_CODES: CS-206, CS-208, CS-209, CS-602), pushes every 30 minutes for as long as it lasts. Any other warning pushes after 15 minutes, then 1, 2 and 4 hours, then every 8 hours, each with the card's first update at or after it. An info code pushes once. The schedule starts again with each episode: the alert cleared and raised again, raised under another code, or back after an hour without being raised. It is held in memory only, so a restart pushes once more. A repeat it holds goes to nobody. - The controller's loop reads
sensor.crop_steering_notify_configat most once a minute, so a change on the page reaches it within a minute; an alert goes on the last reading and never waits on the read. No sensor (an integration from before this) counts as no recipients. urgentis set for the codes written out in the controller'sCRITICAL_CODES, which a test keeps equal to the critical codes of docs/error-codes.json.- The call asks for the service's answer and waits up to 12 s, as a push to
notify_servicealways could. It counts as failed when Home Assistant can't be reached or does not answer in time, answers 4xx or 5xx, or answers that nothing was sent with anerror; a push nobody ticked is not a failure, unless it is an emergency (a critical code): that one counts as failed whenever it reached no phone. - The vitals report (every
notify_minminutes) goes throughcrop_steering.notifyas thevitalskind once at least one recipient is saved and the integration listsvitalsamong the sensor'skinds; so, with phones set up, it reaches only the phones that tick Vitals. With nobody set up, with an integration that does not list the kind (older than this), or when that call fails, it goes tonotify_serviceas it always did. - Events are queued during a loop and sent after its shots, so a slow Home Assistant never holds one up: one
push per room and kind (lights-off moves every zone at once), all of them within 10 s
(
EVENTS_BUDGET_S). An event that fails, or is left when the time is up, is dropped, never retried and never sent tonotify_service. A zone's phase change on its own is keyphase_<room>_z<zone>, title "Room A · Zone 2: P1 → P2" and message why: the engine's own reason ("lights-off -> P3"), "set by hand", "the AI assistant's ramp judge: …", or "lights-off (no moisture reading)" for a zone without a probe. Several in one loop are keyphase_<room>, title "Room A: phase changes" and a line per zone ("Zone 1: P2 → P3, lights-off -> P3"). The AI assistant's move is keyjev_setpoint_<room>_z<zone>, title "Room A · Zone 1: the AI assistant changed P2 shot 5% -> 4.5%" and message the change in words with its answer and probability; several are "Room A: settings the AI assistant changed", a line per zone. - An alert raised while hardware may still be on is raised after it is switched off (a pump whose turn_on errored, CS-302; a batch's mix pump that did not start, CS-805), as its push can take a while.
- CS-209's clock starts, as far as can be told, after the last loop that saw the room not meeting the
condition, after today's lights-on, and after the first zone now in P1 or P2 moved there; a shot in any
zone restarts it. After a restart that is what the saved state says, so a restart does not reset it.
idle_hoursis kept inside 1 to 12, and is 3 when the integration publishes none: CS-209 is raised on every install. The message says why when it can: watering switched off (the room's engine switch), a hold (each zone's reason from the controller's gates), or nothing called for water.
The page (Settings › Notifications)
- A grid: a row per phone (the owner's name and the device), a column per kind with a checkbox, the rooms it covers (all, or ticked rooms), high priority for emergencies, and Send a test. On a phone the grid becomes a card per phone with its checkboxes.
- Each column's heading opens what it covers: its codes with their titles, and its events.
- Add a phone (admins): from the site's phones not yet listed, or a notify service by name.
- The watering-stopped threshold: "Tell me when a room has watered nothing for [3] hours with the lights on".
- A non-admin sees every row but can tick only their own phones'; an admin edits everything. Changes are a draft until Save, with a review of what changes, like the other pages.
- A room of the site with no row ticking
emergencyshows a warning: "Nobody gets emergencies for Room B", and says where they go instead: every phone that gets emergencies, or the controller app'snotify_serviceoption when none does.