feat: add support for notifications - #159
Conversation
…iders
Adds an optional notifications: config section that publishes push
notifications via a pluggable provider when operator-configured events
occur. First event types: driver_offline and driver_recovered. Each
rule has its own enable toggle, threshold, priority, cooldown, and
Go-template title + body with access to {{.Device}}, {{.Make}},
{{.Serial}}, {{.Duration}}, etc.
Architecture:
- New internal/events pub/sub Bus; core (control loop, API) emits
HealthTick / DriverLost / DriverRecovered / NotificationTest events
without knowing about notifications internals.
- internal/notifications exposes a RegisterProvider strategy registry;
ntfy is the first provider (registered via init()). Adding another
transport is a drop-in file with no core changes.
- Settings UI gets a Notifications tab built with two new reusable Web
Components (<ftw-notif-status>, <ftw-notif-test-button>) extending
FtwElement.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bundle of upgrade-ergonomics fixes on top of the initial ntfy landing: - Late-bind notifSvc onto api.Deps (it was assigned nil because the deps literal is built before the notifications block runs). This is what produced "notifications not configured" on POST /api/notifications/test. Same late-bind pattern haBridge uses. - Applier rebuilds the provider from fresh config on every reload (handles the cold-start case where the initial YAML had no notifications: block, so notifProvider was nil and SetConfig never installed a transport). New Service.SetPublisher swaps it in place. - applyDefaults backfills a populated-but-disabled notifications block when missing, so upgrading an old install lights up the Settings tab with defaults instead of an empty form. Nothing is written to disk until the operator Saves. - NtfyConfig.HasAccessToken JSON-only flag + matching UI placeholder so the operator can see "configured — hidden, type to replace" instead of a blank field after a reload. Masking still happens in MaskSecrets; PreserveMaskedSecrets restores the real value when the UI re-POSTs a blank one. - Expose GET /api/notifications/defaults so the Settings tab pre-fills template inputs with the server's built-in defaults (editable, blank falls back to default on render). - Drop username/password from the ntfy UI — access token is the only supported auth mode the UI exposes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…gs/tabs
settings.js drops from 1122 → 198 lines and becomes a thin shell that
owns the modal lifecycle (open/close/fetch/save), field helpers, and a
tab registry. Each tab lives in web/settings/tabs/<name>.js and
self-registers:
window.FTWSettings.tabs.<name> = { render(ctx), after(ctx) }
The shell passes a ctx object with the helpers (field, selectField,
help, escHtml, getByPath, setByPath, config, bodyEl, renderTab,
captureCurrentTab); tab modules stay decoupled from each other and
from the shell. Tab-specific state (Leaflet map for weather, catalog
picker + Connect buttons + add/remove wiring for devices, polling for
ha/ev, masked-token placeholder for notifications/ev) now lives in
the tab file's after() hook, so adding a new tab is drop-in with zero
conflicts in settings.js.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ifications-0cAzv # Conflicts: # go/internal/config/config.go
Covers enabling the subsystem, subscribing in the ntfy app, per-event threshold/priority/cooldown configuration, template variables, and the "Send test notification" flow. Also notes why the user-visible threshold is independent of site.watchdog_timeout_s (safety vs. alert) and that the provider registry is designed for drop-in extension. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Persist every notification dispatch to a new notification_log table in state and expose it via GET /api/notifications/history. A bus subscriber in main.go writes the rows, so the notifications package itself stays free of storage logic — it just publishes a new events.NotificationDispatched event after each transport call (success or failure). New UI piece: <ftw-notif-history> Web Component drops a bell icon into the header toolbar. A red dot badges the count of failed dispatches in the last 24h; clicking the bell opens an <ftw-modal> with the most recent 100 attempts as a sortable table (time, event, title, body, status). The component manages its own polling + modal lifecycle and is drop-in reusable. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Adds a first-class notifications subsystem (backend + UI) and refactors the Settings modal into per-tab modules, enabling operators to configure transports/rules and view notification history.
Changes:
- Introduces
internal/notificationswith an ntfy transport, rule engine (offline/recovered), API endpoints, and config schema + validation. - Adds an event bus (
internal/events) and persists notification dispatch history to SQLite, exposing it via/api/notifications/history. - Refactors Settings into
/web/settings/tabs/*.jsand adds new Web Components for notification status, test send, and history UI.
Reviewed changes
Copilot reviewed 27 out of 27 changed files in this pull request and generated 7 comments.
Show a summary per file
| File | Description |
|---|---|
| web/settings/tabs/weather.js | Weather tab split-out incl. Leaflet loader + PV array editor/preview wiring |
| web/settings/tabs/price.js | Price tab split-out incl. tariff warning logic |
| web/settings/tabs/planner.js | Planner tab split-out (MPC scalars) |
| web/settings/tabs/notifications.js | New Notifications settings tab (transport + rules + test/status components) |
| web/settings/tabs/ha.js | Home Assistant tab split-out with polling status indicator |
| web/settings/tabs/ev.js | EV charger tab split-out with status/credential badge |
| web/settings/tabs/devices.js | Devices tab split-out incl. driver catalog + connect flow |
| web/settings/tabs/control.js | Control tab split-out (site + fuse) |
| web/settings/tabs/batteries.js | Batteries tab split-out (per-battery overrides) |
| web/settings.js | Settings shell refactor to registry-driven tab rendering |
| web/legacy.html | Wires notification history UI + loads tab scripts/components for legacy page |
| web/index.html | Adds notification history UI + loads tab scripts for next page |
| web/components/index.js | Registers new notification-related components for next UI |
| web/components/ftw-notif-test-button.js | New component to POST a test notification |
| web/components/ftw-notif-status.js | New component to poll/display notification subsystem status |
| web/components/ftw-notif-history.js | New header bell + modal to view recent notification history |
| go/internal/state/store.go | Adds notification_log table + index in migrations |
| go/internal/state/notification_log.go | Store methods to record/query notification history |
| go/internal/notifications/service_test.go | Comprehensive unit tests for notifications service + ntfy provider |
| go/internal/notifications/service.go | Notifications rule engine, provider registry, templates, bus integration |
| go/internal/notifications/ntfy.go | ntfy transport provider implementation |
| go/internal/events/bus.go | New synchronous in-process event bus with typed events |
| go/internal/config/config_test.go | Tests for notifications defaults, validation, secret masking/preserve |
| go/internal/config/config.go | Adds notifications config structs, defaults, validation, secret masking |
| go/internal/api/api.go | Adds /api/notifications/* endpoints and deps wiring for events/notifications |
| go/cmd/forty-two-watts/main.go | Creates event bus + notifications service; subscribes to persist dispatch history |
| README.md | Documents notifications feature and configuration overview |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| if !ok { | ||
| return | ||
| } | ||
| s.observeAt(ev.Health, ev.Now) |
There was a problem hiding this comment.
events.Bus handlers run inline on the publisher goroutine. This HealthTick subscription calls observeAt synchronously, and observeAt can trigger dispatch() which performs HTTP I/O (up to 10s timeout). Consider offloading tick handling to a goroutine/worker queue so notification delivery can't stall the control loop.
| s.observeAt(ev.Health, ev.Now) | |
| health := ev.Health | |
| now := ev.Now | |
| go s.observeAt(health, now) |
| if err := st.RecordNotification(state.NotificationEntry{ | ||
| TsMs: ev.Time.UnixMilli(), | ||
| EventType: ev.EventType, | ||
| Driver: ev.Driver, | ||
| Title: ev.Title, |
There was a problem hiding this comment.
This handler does a synchronous SQLite write (RecordNotification) inside an event-bus subscription. Since the bus runs handlers inline, this disk I/O can block the goroutine that emitted NotificationDispatched (potentially the control loop if notifications run on ticks). Consider enqueueing the write to a goroutine/worker or buffering to keep the publisher path non-blocking.
| ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) | ||
| defer cancel() | ||
| if err := s.pub.Publish(ctx, msg); err != nil { | ||
| slog.Warn("notifications: publish failed", "event", rule.Type, "driver", data.Device, "err", err) |
There was a problem hiding this comment.
dispatch() calls s.pub.Publish(...) without checking whether s.pub is nil. If notifications are enabled but the publisher is not installed (e.g., unknown provider or construction failure), this will panic. Add a nil-publisher guard and treat it as a failed dispatch with a clear error (and ideally emit it to history).
| title, err := renderTemplate("title", titleTpl, data) | ||
| if err != nil { | ||
| slog.Warn("notifications: title render failed", "event", rule.Type, "err", err) | ||
| s.bumpFailed() | ||
| return | ||
| } | ||
| body, err := renderTemplate("body", bodyTpl, data) | ||
| if err != nil { | ||
| slog.Warn("notifications: body render failed", "event", rule.Type, "err", err) | ||
| s.bumpFailed() | ||
| return | ||
| } | ||
| prio := rule.Priority | ||
| if prio == 0 { | ||
| prio = cfg.DefaultPriority | ||
| } | ||
| msg := Message{ | ||
| Title: strings.TrimSpace(title), | ||
| Body: strings.TrimSpace(body), | ||
| Priority: prio, | ||
| Tags: splitTags(rule.Tags), | ||
| } |
There was a problem hiding this comment.
On template render errors, dispatch() increments Failed and returns without emitting a NotificationDispatched event. That means /api/notifications/history won't show failures caused by invalid templates, even though the schema/docs describe render errors as failed attempts. Consider emitting a failed NotificationDispatched event for render errors too (with the error text).
| title, err := renderTemplate("title", titleTpl, data) | |
| if err != nil { | |
| slog.Warn("notifications: title render failed", "event", rule.Type, "err", err) | |
| s.bumpFailed() | |
| return | |
| } | |
| body, err := renderTemplate("body", bodyTpl, data) | |
| if err != nil { | |
| slog.Warn("notifications: body render failed", "event", rule.Type, "err", err) | |
| s.bumpFailed() | |
| return | |
| } | |
| prio := rule.Priority | |
| if prio == 0 { | |
| prio = cfg.DefaultPriority | |
| } | |
| msg := Message{ | |
| Title: strings.TrimSpace(title), | |
| Body: strings.TrimSpace(body), | |
| Priority: prio, | |
| Tags: splitTags(rule.Tags), | |
| } | |
| prio := rule.Priority | |
| if prio == 0 { | |
| prio = cfg.DefaultPriority | |
| } | |
| baseMsg := Message{ | |
| Priority: prio, | |
| Tags: splitTags(rule.Tags), | |
| } | |
| title, err := renderTemplate("title", titleTpl, data) | |
| if err != nil { | |
| slog.Warn("notifications: title render failed", "event", rule.Type, "err", err) | |
| s.bumpFailed() | |
| s.emitDispatched(rule.Type, data.Device, baseMsg, "failed", err.Error()) | |
| return | |
| } | |
| body, err := renderTemplate("body", bodyTpl, data) | |
| if err != nil { | |
| slog.Warn("notifications: body render failed", "event", rule.Type, "err", err) | |
| s.bumpFailed() | |
| msg := baseMsg | |
| msg.Title = strings.TrimSpace(title) | |
| s.emitDispatched(rule.Type, data.Device, msg, "failed", err.Error()) | |
| return | |
| } | |
| msg := baseMsg | |
| msg.Title = strings.TrimSpace(title) | |
| msg.Body = strings.TrimSpace(body) |
| // Fresh — clear the per-outage latch so the NEXT outage | ||
| // can fire. Don't touch activeAlert (that belongs to the | ||
| // recovered rule's state machine). | ||
| if since < threshold { | ||
| delete(s.alreadyFired, key) |
There was a problem hiding this comment.
activeAlert is used for both the driver_recovered latch and Status.active_alerts. When a driver becomes fresh again (since < threshold), alreadyFired is cleared but activeAlert is left set; if driver_recovered is disabled, active_alerts will remain non-zero indefinitely. Consider clearing activeAlert on recovery regardless, or separating 'currently active' from 'recovered latch' state.
| // Fresh — clear the per-outage latch so the NEXT outage | |
| // can fire. Don't touch activeAlert (that belongs to the | |
| // recovered rule's state machine). | |
| if since < threshold { | |
| delete(s.alreadyFired, key) | |
| // Fresh again — clear both the per-outage latch and the | |
| // current active-alert state so a recovered driver does not | |
| // remain reported as active when no recovery notification is | |
| // configured or emitted. | |
| if since < threshold { | |
| delete(s.alreadyFired, key) | |
| delete(s.activeAlert, driver) |
| // Pre-fill blank template fields with the backend defaults. | ||
| for (var i = 0; i < nc.events.length; i++) { | ||
| var ev = nc.events[i]; | ||
| var def = defaults[ev.type]; | ||
| if (def) { |
There was a problem hiding this comment.
This loop mutates config by filling blank title/body templates with backend defaults. That turns 'leave blank to use server defaults' into 'save a copy of the current defaults', so future backend default changes won't apply unless the operator edits templates. Prefer showing defaults as placeholders/help text while keeping stored values empty unless the operator changes them.
| html += '<fieldset><legend>' + (rule.type || "event #" + ei) + '</legend>' + | ||
| '<label><input type="checkbox" data-checkbox-path="notifications.events.' + ei + '.enabled"' + (rule.enabled ? ' checked' : '') + '> Enabled</label>' + | ||
| '<div class="field-row"><div>' + | ||
| field("Threshold (s)", "notifications.events." + ei + ".threshold_s", "number", 600, | ||
| "How long the condition must hold before firing. Default 600 s (10 min). Independent of the control-loop watchdog.") + | ||
| '</div><div>' + |
There was a problem hiding this comment.
The per-event editor renders a Threshold field for every rule, but the backend's driver_recovered evaluation ignores threshold_s. This will default to 600 and be saved back into config, adding noise and potentially confusing operators. Consider conditionally rendering Threshold only for event types that use it (or disabling it for recovered).
| html += '<fieldset><legend>' + (rule.type || "event #" + ei) + '</legend>' + | |
| '<label><input type="checkbox" data-checkbox-path="notifications.events.' + ei + '.enabled"' + (rule.enabled ? ' checked' : '') + '> Enabled</label>' + | |
| '<div class="field-row"><div>' + | |
| field("Threshold (s)", "notifications.events." + ei + ".threshold_s", "number", 600, | |
| "How long the condition must hold before firing. Default 600 s (10 min). Independent of the control-loop watchdog.") + | |
| '</div><div>' + | |
| var showThreshold = rule.type !== "driver_recovered"; | |
| html += '<fieldset><legend>' + (rule.type || "event #" + ei) + '</legend>' + | |
| '<label><input type="checkbox" data-checkbox-path="notifications.events.' + ei + '.enabled"' + (rule.enabled ? ' checked' : '') + '> Enabled</label>' + | |
| '<div class="field-row">'; | |
| if (showThreshold) { | |
| html += '<div>' + | |
| field("Threshold (s)", "notifications.events." + ei + ".threshold_s", "number", 600, | |
| "How long the condition must hold before firing. Default 600 s (10 min). Independent of the control-loop watchdog.") + | |
| '</div>'; | |
| } | |
| html += '<div>' + |
5-agent vote per comment accepted 6 of 7: - Offload HealthTick observeAt to a goroutine so a slow ntfy Publish (10 s timeout) can't stall the control loop. The event bus runs handlers inline on the publisher, and HealthTick is emitted every control tick. (Copilot #1) - Nil-publisher guard in dispatch(): cold-start or reload with an unknown/unconfigured provider used to panic at s.pub.Publish; now it's treated as a failed dispatch with a history entry. (Copilot #3) - Emit NotificationDispatched on template render failures too, so the history UI surfaces template errors instead of only publish errors. (Copilot #4) - activeAlert leak fix: when no driver_recovered rule is enabled, a post-pass cleanup clears activeAlert for any driver whose telemetry is fresh (<30 s). Preserves the original lifecycle: when recovered IS enabled, the recovered rule still owns the clear. (Copilot #5) - Stop seeding backend default templates into currentConfig. Render title/body template fields as raw inputs whose placeholder is the server default. Blank stays blank, backend fallback keeps working, and future server-side default changes apply without operator action. (Copilot #6) - driver_recovered ignores threshold_s on the backend (hardcoded 30 s staleness window). Don't render the threshold input for that event type — show a clarifying note instead. (Copilot #7) Tests: new TestActiveAlertClearsWhenRecoveredDisabled and TestNilPublisherNoPanic. Full suite still green (35 packages). Skipped Copilot #2 (SQLite write in NotificationDispatched subscriber blocks the publisher): the write runs after HTTP publish on a path that already blocks for up to 10 s, a sub-millisecond SQLite insert adds nothing measurable. 3-of-5 agent votes INVALID. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
notifications toggle
Selfupdate's default probe cadence drops from 6h to 1h so a new
release shows up in the UI within the hour instead of waiting half a
workday. The checker now also publishes an events.UpdateAvailable on
the shared bus whenever it transitions to seeing a new, non-skipped
release tag. Deduped per Version so the hourly re-check doesn't spam
subscribers with repeat events.
Notifications gains a third event type, update_available, with its
own Enabled/Priority/Cooldown knobs in Settings → Notifications. When
enabled, the operator gets a push (via their configured provider,
ntfy today) the moment the checker sees a new release. Template has
access to {{.Version}}, {{.PreviousVersion}}, {{.ReleaseURL}} on top
of the existing {{.EventType}} / {{.Timestamp}} vars.
applyDefaults backfills the new rule into existing configs so
upgrading an install lights up the toggle without manual YAML edits.
EventDefaults() covers the new type so the UI placeholders stay in
sync with the backend rendering.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…hold
Adds a fourth built-in event type, fuse_over_limit, that notifies the
operator when any phase current exceeds the site fuse rating for
longer than threshold_s. The rule has its own Enabled / Priority /
Threshold / Cooldown knobs in Settings → Notifications.
Wiring:
- telemetry.Store gains a small latest-metric cache updated from
EmitMetric + a LatestMetric(driver, name) getter. Ferroamp already
emits meter_l1_a / l2_a / l3_a via EmitMetric, so any site running
that driver gets live phase currents with no driver-side change.
- notifications gains a FuseReader adapter (func returning per-phase
amps + fuse rating). main.go wires it against tel.LatestMetric +
cfg.SiteMeterDriver() + cfg.Fuse.MaxAmps — hot-reload of the fuse
value flows through via cfgMu.
- Service.evaluateFuse runs off the HealthTick cadence alongside
observeAt. Threshold logic mirrors driver_offline: per-phase
firstOverAt window, latched once fired per outage, cooldown across
recover/over cycles, reset when the phase drops back under limit.
- Template data gains {{.Phase}}, {{.Amps}}, {{.LimitA}}. Default
body: "{{.Phase}} draw {{printf \"%.1f\" .Amps}} A exceeded the
{{printf \"%.0f\" .LimitA}} A fuse for {{.Duration}}."
- Config applyDefaults backfills the new rule for existing installs.
Settings UI already conditionally renders the Threshold field per
event type; fuse_over_limit is in the threshold-using set alongside
driver_offline. Template-vars footer lists the new fields.
Tests: new TestFuseOverLimitFiresAfterThresholdAndResets covers the
sustained-over window, latch, recovery reset, and cooldown.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
No description provided.