Skip to content

Updating from 3.17 or earlier? Read the upgrade guide first.

Crop Steering
Browse the documents

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

LayerOwnsNever does
IntegrationThe 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 itPush anything nobody ticked (except the fallback below)
Controller appRaising alerts (unchanged) and the new events; handing each push to crop_steering.notify; the fallback to its old notify_service optionDecide who gets what
DashboardSettings › Notifications: the checkbox grid, each phone's rooms, a test push per phone, the watering-stopped thresholdCall 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.

IdCheckboxWhat it covers
emergencyEmergenciesEvery 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)
hardwareHardware lockoutsCS-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)
sensorsSensors and driftCS-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)
wateringWatering stoppedCS-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)
phasesPhase changesA zone moving P0 → P1 → P2 → P3 (new event, no card)
vitalsVitalsThe 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
stockStock tanksCS-608, CS-807
dosingDosingCS-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)
jevAI assistantCS-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)
setupSetup and settingsCS-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's mobile_app_notification_action event, as the phone's own user, and goes through overlay_keep or overlay_undo exactly 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 whose sensor.crop_steering_notify_config says overlay_actions: true.
  • Vitals (event: "vitals"): the controller's report on every zone, every notify_min minutes, key vitals, title " vitals". Under each zone, while the room's notify_predictions switch 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 the f2_vitals card and sensor.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 every idle_hours while 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

ServiceWhoDataResponse
crop_steering.notify_getany signed-in usernone{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_saveadmin: 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_testadmin, or the phone's own userservice, or services (a list of up to 20 phones), not both{sent: true} or error; with services, {results: [{service, sent, error}]}
crop_steering.notifyadmin (the controller)key, code?, event?, room?, zone?, title, message, urgent?{sent_to[], error}, with muted: true or held[] (below)
  • notify works out the push's kinds from code (the catalog) or event, then sends to every recipient that ticks one of them and whose rooms is empty or includes room, once per service. A push about no room in particular (no room: 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 with urgent_high_priority: Android {"priority": "high", "channel": "Crop Steering urgent"}, iOS {"push": {"interruption-level": "time-sensitive"}}, both in the same data (each app ignores the other's keys; data is sent to mobile_app services only). Every push carries data.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 (url for iOS, clickAction for Android, both /crop-steering).
  • A muted room (mutes, until its time) gets no push but an emergency: a push for it that is not urgent answers {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 digest gets what is not urgent as one push, digest_hours after 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), with data.tag = "crop_steering_digest". A repeat of the same key replaces the one waiting. The answer lists those rows in held. 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, notify sends nothing and says why in error ("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 and error says why.
  • An emergency always reaches someone. An urgent push that no row covers for its room goes to every row that ticks emergency, 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 its notify_service option.
  • sent_to lists 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_get answers anyone, as every read in this integration does (can_edit_all is 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_get and notify_save answer error too (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 and digest change. They may not change idle_hours, digest_hours or mutes, which are the site's.
  • notify_test sends at normal priority, with data.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 notify when 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_hours on a recipient row, HH:MM to HH:MM in 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 with notify_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 routing picks the kind of alert it is pushed as, and only routing: emergency is urgent (Alert rules).
  • The alerts inbox: sensor.crop_steering_<prefix>alerts_open counts each room's open alerts, and crop_steering.alert_ack acknowledges one. Any signed-in user can acknowledge (The alerts inbox).

The controller

  • _alert still 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, attribute recipients = the count, kinds = the kinds a row can tick), it calls crop_steering.notify and 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_service option 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.
  • A room muted in the sensor's muted pushes nothing but a critical code until its time, through crop_steering.notify or to the notify_service option 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.notify only (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_config at 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.
  • urgent is set for the codes written out in the controller's CRITICAL_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_service always 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 an error; 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_min minutes) goes through crop_steering.notify as the vitals kind once at least one recipient is saved and the integration lists vitals among the sensor's kinds; 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 to notify_service as 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 to notify_service. A zone's phase change on its own is key phase_<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 key phase_<room>, title "Room A: phase changes" and a line per zone ("Zone 1: P2 → P3, lights-off -> P3"). The AI assistant's move is key jev_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_hours is 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 emergency shows a warning: "Nobody gets emergencies for Room B", and says where they go instead: every phone that gets emergencies, or the controller app's notify_service option when none does.