Alerts
Settings → Alerts holds a list of alerts you build, stored as alerts.items in settings.json and fed by a small evaluation engine (server/alerts.js). Each item is self-contained: a trigger type from a catalogue plus its parameters, the edges it cares about (notify: { raised, cleared }), an enabled flag, and its own routing (channels.homekit: { enabled, sensorName } and channels.webhook: { enabled, url }). There is no master switch and no shared transport; an alert runs when its own flag is on, and each webhook alert carries its own URL, so two alerts can post to different endpoints. The engine runs on its own 15-second timer reading the shared state object rather than subscribing to the poll; if the gateway drops, the poller stops publishing, so a subscribe-only watcher would go silent exactly when you most want it to speak, whereas a timer that reads state.connected and the age of state.lastUpdated catches both a clean disconnect and a hung socket that never flips the flag.
The trigger catalogue
The trigger catalogue lives in server/triggers.js and is the one source of truth the engine, the validator, and the form all read. Most entries come from a single threshold factory (a signal reader, a comparison, a parameter range, and a hysteresis band), so a battery, grid, solar, home-load, weather, or cost trigger is one declarative entry; only gateway connectivity is bespoke. Each entry carries an isBad predicate plus metadata (group, parameter spec, default notify edges, an availability test). The serialisable slice is published to the UI as alertTriggers (the evaluators stay server-side), with available computed from live capability (weather on, a tariff configured, the gateway reporting SoH) so triggers that have no data to read don't clutter the picker. Per tick the engine builds one context (the live readings plus the derived battery estimate and cost-per-hour from derive.js) and hands it to every predicate.
The edge state machine
Evaluation reuses the pure edge state machine the original two rules used: stepRule (unit-tested in server/tests/alerts.test.js) turns each predicate's boolean into raised and cleared edges, holding the condition for an arm window before raising and a recovery window before clearing so time-debounce on both edges kills flapping. Threshold triggers fold a small hysteresis band into the predicate when active (the generalisation of the old low-battery clear margin), and battery triggers read as null while the gateway is disconnected so a stale reading never trips them. The engine keys its runtime map by item id, prunes entries for deleted alerts, and a disabled or removed alert resolves to clear rather than debouncing down. gatewayOffline notifying on its cleared edge is how "gateway back online" is expressed, with no perpetually-active "online" condition.
Delivery: HomeKit and webhook
The active set rides the shared state as state.alerts (each entry id, name, trigger, message, condition, since), so it reaches the browser over the same SSE stream and /api/state that carries every reading, and the Alerts page reads it for the live per-alert status. Delivery channels hang off the engine. HomeKit is a pull: the bridge's existing two-second push loop calls getActiveAlertIds() and flips one ContactSensor per Apple Home alert (Open is CONTACT_NOT_DETECTED, the active state). The sensors are built at boot in homekit.js from the items whose channels.homekit.enabled is set, each with a stable accessory UUID derived from the item id and named after its sensorName (falling back to the alert name); that keeps Apple Home a restart toggle and keeps the dependency one-way (homekit imports alerts, never the reverse, so there's no import cycle). The webhook is a push, firing only on the edges an alert opts into and only when its own channels.webhook is enabled with a URL: a JSON POST (event, id, name, trigger, message, a condition object holding the watched value, its comparison, threshold, and unit, plus batterySoc, connected, at) to that alert's URL with a five-second timeout, fire-and-forget so a slow endpoint never stalls the engine. Each trigger supplies its own reading (the threshold factory from signal/comparison/unit and the saved threshold; gatewayOffline as minutes-stale against its afterMinutes), so the consumer gets the number that tripped next to the bound you set without parsing the message. The comparison is inclusive and named for it, atOrAbove or atOrBelow, since a trigger raises when the value reaches the threshold (value >= threshold or value <= threshold), so a boundary reading like value 1, atOrAbove, threshold 1 is a true raise rather than a contradiction. POST /api/test/alert reuses the same payloadFor builder to post a simulated raised edge for the alert under test, but takes its message and condition from each trigger's sample (the configured threshold, the value at which a real alert trips) rather than the current healthy reading, alongside the live batterySoc and connected. A real raise only fires once the watched value has reached the threshold, so sampling there is what a real trigger sends; reading live state instead would make a test of an online gateway report a couple of seconds of staleness against a three-minute bound. Covered against a local sink in server/tests/alerts-webhook.test.js.
Validation and upgrades
The section is validated server-side like every other and empty by default. Each item is checked against its trigger's parameter spec from the catalogue (an unknown trigger type or an out-of-range value is a 400, a missing id is generated, and the list is capped). An older build's alerts block is upgraded once on load: the original fixed rules/channels shape, and the interim list-plus-shared-transports shape, both fold into per-alert channels, copying the shared webhook URL onto each routed alert and keeping the two original alerts under their old ids so existing Apple Home pairings survive untouched. The webhook URL is the viewer's own config (it may carry an ntfy topic or a Pushover token), so it's shown like the gateway host rather than write-masked like the Google token; enabling an alert's webhook with no URL turns the field's hint red, expands that alert, and blocks the save in place, with the server rejecting an enabled-but-empty webhook as a backstop for non-UI callers. Adding a trigger or a channel is a new catalogue entry or a new dispatcher, not a rework.
In the UI the alerts are a collapsible, sortable list (the sort preference rides localStorage); a new alert is built in a guided modal that opens trigger-first, so it stays nameless and featureless until you pick one, and only stages into the list (the section's Save bar persists it). Editing is inline expansion of the same shared AlertForm. Each webhook alert's notify edges sit with the webhook as a single-select Starts/Ends/Both, since they only steer its POSTs; the notify: { raised, cleared } storage is unchanged. Deleting an alert that already exists server-side asks first; an unsaved one goes straight away. The (i) help popovers teleport to document.body, so a card's overflow-hidden or the modal can't clip them.