Skip to content

feat: add support for notifications - #159

Merged
erikarenhill merged 9 commits into
masterfrom
claude/add-ntfy-notifications-0cAzv
Apr 20, 2026
Merged

feat: add support for notifications#159
erikarenhill merged 9 commits into
masterfrom
claude/add-ntfy-notifications-0cAzv

Conversation

@erikarenhill

Copy link
Copy Markdown
Collaborator

No description provided.

erikarenhill and others added 6 commits April 20, 2026 17:50
…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>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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/notifications with 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/*.js and 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.

Comment thread go/internal/notifications/service.go Outdated
if !ok {
return
}
s.observeAt(ev.Health, ev.Now)

Copilot AI Apr 20, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
s.observeAt(ev.Health, ev.Now)
health := ev.Health
now := ev.Now
go s.observeAt(health, now)

Copilot uses AI. Check for mistakes.
Comment on lines +772 to +776
if err := st.RecordNotification(state.NotificationEntry{
TsMs: ev.Time.UnixMilli(),
EventType: ev.EventType,
Driver: ev.Driver,
Title: ev.Title,

Copilot AI Apr 20, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +455 to +458
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)

Copilot AI Apr 20, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copilot uses AI. Check for mistakes.
Comment on lines +433 to +454
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),
}

Copilot AI Apr 20, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Suggested change
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)

Copilot uses AI. Check for mistakes.
Comment on lines +306 to +310
// 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)

Copilot AI Apr 20, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
// 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)

Copilot uses AI. Check for mistakes.
Comment thread web/settings/tabs/notifications.js Outdated
Comment on lines +52 to +56
// 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) {

Copilot AI Apr 20, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment thread web/settings/tabs/notifications.js Outdated
Comment on lines +95 to +100
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>' +

Copilot AI Apr 20, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Suggested change
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>' +

Copilot uses AI. Check for mistakes.
erikarenhill and others added 3 commits April 20, 2026 20:53
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>
@erikarenhill
erikarenhill merged commit f1b1831 into master Apr 20, 2026
1 check passed
@erikarenhill
erikarenhill deleted the claude/add-ntfy-notifications-0cAzv branch April 20, 2026 19:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants