# Notifications: who gets which alerts

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](https://cropsteering.growlabs.nz/docs/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'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 "<name> 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)

```jsonc
{
  "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) |

- `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](https://cropsteering.growlabs.nz/docs/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](https://cropsteering.growlabs.nz/docs/alerts#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](https://cropsteering.growlabs.nz/docs/alerts#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](https://cropsteering.growlabs.nz/docs/alerts#the-alerts-inbox-and-acknowledging)).

## 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.
