From add0210347d0dad002b8d4bc1911110d8982e12b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 26 Aug 2026 18:14:33 +0000 Subject: [PATCH] feat(notifications): lock-screen kinds for a quiet device and an over-fuse house The phone app's catalogue had no sentence for a driver that stopped reporting. The engine already knew how; it just never rendered the app's words. driver.offline and fuse.over_limit use the same thresholds as the operator rules so a blip or a kettle is not a notification. Signed-off-by: Cursor Agent --- .changeset/push-driver-offline.md | 5 + contract/push-catalogue.yaml | 10 ++ .../api/api_notifications_push_test.go | 5 +- go/internal/notifications/catalogue_gen.go | 4 + go/internal/notifications/catalogue_test.go | 19 +++ go/internal/notifications/service.go | 135 ++++++++++++------ go/internal/notifications/service_test.go | 83 +++++++++++ web/settings/tabs/notifications.js | 3 +- 8 files changed, 221 insertions(+), 43 deletions(-) create mode 100644 .changeset/push-driver-offline.md diff --git a/.changeset/push-driver-offline.md b/.changeset/push-driver-offline.md new file mode 100644 index 00000000..97c0b24a --- /dev/null +++ b/.changeset/push-driver-offline.md @@ -0,0 +1,5 @@ +--- +"ftw": minor +--- + +The phone app can now notify you when a device goes quiet or the house draws more than the fuse allows. Same thresholds as before (ten minutes of silence, thirty seconds over the rating) so a blip is not a lock-screen. diff --git a/contract/push-catalogue.yaml b/contract/push-catalogue.yaml index e7eda18f..5f4a2e12 100644 --- a/contract/push-catalogue.yaml +++ b/contract/push-catalogue.yaml @@ -25,6 +25,16 @@ events: - kind: update.installed title: Your box updated itself body: "Now running {version}. Everything came back on its own." + - kind: driver.offline + # After the box's own threshold: ten minutes of silence from a driver + # that had been reporting. A blip must not become a lock-screen. + title: A device went quiet + body: "{name} stopped answering." + - kind: fuse.over_limit + # After the box's own threshold: thirty seconds over the rating. A + # kettle must not page anyone. + title: The house is drawing too much + body: "{phase} is over the fuse rating." - kind: box.unreachable # The one sentence the box cannot send about itself. The relay holds # this pre-encrypted and posts it only when the box has missed its diff --git a/go/internal/api/api_notifications_push_test.go b/go/internal/api/api_notifications_push_test.go index 3d0a79d8..78e3e637 100644 --- a/go/internal/api/api_notifications_push_test.go +++ b/go/internal/api/api_notifications_push_test.go @@ -440,7 +440,10 @@ func TestRulesGetOffersKindsAddedAfterTheConfigWasSaved(t *testing.T) { t.Fatalf("the stored rule was rewritten: %+v", stored) } // The kinds the release added are offered, disabled. - for _, kind := range []string{"charging.session_complete", "charging.interrupted", "update.installed"} { + for _, kind := range []string{ + "charging.session_complete", "charging.interrupted", "update.installed", + "driver.offline", "fuse.over_limit", + } { rule, ok := byType[kind] if !ok { t.Fatalf("kind %s is not offered to a box with a stored config", kind) diff --git a/go/internal/notifications/catalogue_gen.go b/go/internal/notifications/catalogue_gen.go index 7ec59bed..c9c1e5ae 100644 --- a/go/internal/notifications/catalogue_gen.go +++ b/go/internal/notifications/catalogue_gen.go @@ -11,6 +11,8 @@ const ( PushChargingSessionComplete = "charging.session_complete" PushChargingInterrupted = "charging.interrupted" PushUpdateInstalled = "update.installed" + PushDriverOffline = "driver.offline" + PushFuseOverLimit = "fuse.over_limit" PushBoxUnreachable = "box.unreachable" ) @@ -26,5 +28,7 @@ var PushSentences = map[string]PushSentence{ PushChargingSessionComplete: {Title: "Car charged", Body: "{kwh} kWh delivered — ready to go."}, PushChargingInterrupted: {Title: "Charging stopped early", Body: "The car stopped charging before it was done."}, PushUpdateInstalled: {Title: "Your box updated itself", Body: "Now running {version}. Everything came back on its own."}, + PushDriverOffline: {Title: "A device went quiet", Body: "{name} stopped answering."}, + PushFuseOverLimit: {Title: "The house is drawing too much", Body: "{phase} is over the fuse rating."}, PushBoxUnreachable: {Title: "Your box is out of reach", Body: "It stopped answering. Power cut, or the internet at home is down."}, } diff --git a/go/internal/notifications/catalogue_test.go b/go/internal/notifications/catalogue_test.go index f3f4795c..b8c0ada5 100644 --- a/go/internal/notifications/catalogue_test.go +++ b/go/internal/notifications/catalogue_test.go @@ -51,6 +51,22 @@ func TestRenderPushFillsPlaceholders(t *testing.T) { if title != "Your box updated itself" { t.Fatalf("title = %q", title) } + + title, body, err = RenderPush(PushDriverOffline, map[string]string{"name": "Batteri"}) + if err != nil { + t.Fatalf("render driver.offline: %v", err) + } + if title != "A device went quiet" || body != "Batteri stopped answering." { + t.Fatalf("driver.offline = %q / %q", title, body) + } + + title, body, err = RenderPush(PushFuseOverLimit, map[string]string{"phase": "L1"}) + if err != nil { + t.Fatalf("render fuse.over_limit: %v", err) + } + if title != "The house is drawing too much" || body != "L1 is over the fuse rating." { + t.Fatalf("fuse.over_limit = %q / %q", title, body) + } } // A sentence with a hole in it never leaves the box. The catalogue promised @@ -60,6 +76,9 @@ func TestRenderPushRefusesAnUnfilledPlaceholder(t *testing.T) { if _, _, err := RenderPush(PushChargingSessionComplete, nil); err == nil { t.Fatal("rendered a sentence with {kwh} unfilled") } + if _, _, err := RenderPush(PushDriverOffline, nil); err == nil { + t.Fatal("rendered a sentence with {name} unfilled") + } if _, _, err := RenderPush("charging.someday", nil); err == nil { t.Fatal("rendered a kind the catalogue does not carry") } diff --git a/go/internal/notifications/service.go b/go/internal/notifications/service.go index 567f56ed..0cfd2575 100644 --- a/go/internal/notifications/service.go +++ b/go/internal/notifications/service.go @@ -97,6 +97,11 @@ func DefaultRules() []config.NotificationRule { {Type: PushChargingSessionComplete, Enabled: false, Priority: 3}, {Type: PushChargingInterrupted, Enabled: false, Priority: 4, CooldownS: 3600}, {Type: PushUpdateInstalled, Enabled: false, Priority: 2}, + // Phone lock-screen counterparts of the operator rules above. + // Same thresholds so a blip or a kettle is not a notification; + // the sentences come from the catalogue, never from templates. + {Type: PushDriverOffline, Enabled: false, ThresholdS: DefaultThresholdS, Priority: 4, CooldownS: DefaultCooldownS}, + {Type: PushFuseOverLimit, Enabled: false, ThresholdS: 30, Priority: 5, CooldownS: 900}, } } @@ -110,7 +115,7 @@ func KnownRuleTypes() []string { EventDriverOffline, EventDriverRecovered, EventUpdateAvailable, EventFuseOverLimit, EventConcurrentDriversOffline, PushChargingSessionComplete, PushChargingInterrupted, - PushUpdateInstalled, + PushUpdateInstalled, PushDriverOffline, PushFuseOverLimit, } } @@ -496,11 +501,15 @@ func (s *Service) handleUpdateAvailable(ev events.UpdateAvailable) { } // evaluateFuse reads the live per-phase current snapshot and fires the -// fuse_over_limit rule when a phase has been over its rating for at -// least threshold_s. State is per-phase: firstOverAt records when the -// over-window started, alreadyFired latches to fire once per outage, -// and cooldownS (shared lastFired map) rate-limits re-fires across -// quick recover/over cycles. +// fuse over-limit rules when a phase has been over its rating for at +// least that rule's threshold_s. State is per-phase: firstOverAt records +// when the over-window started, alreadyFired latches to fire once per +// outage per rule, and cooldownS rate-limits re-fires across quick +// recover/over cycles. +// +// Two rule names share this check: the operator template (fuse_over_limit) +// and the phone catalogue (fuse.over_limit). Each is gated on its own +// enabled bit so ntfy and the lock screen can be opted into separately. func (s *Service) evaluateFuse(now time.Time) { if s == nil { return @@ -510,11 +519,6 @@ func (s *Service) evaluateFuse(now time.Time) { s.mu.Unlock() return } - rule, ok := findRule(s.cfg.Events, EventFuseOverLimit) - if !ok || !rule.Enabled { - s.mu.Unlock() - return - } reader := s.fuseReader if reader == nil { s.mu.Unlock() @@ -526,53 +530,67 @@ func (s *Service) evaluateFuse(now time.Time) { return } cfg := s.cfg - threshold := time.Duration(rule.ThresholdS) * time.Second - if threshold == 0 { - threshold = 30 * time.Second - } type toFire struct { rule config.NotificationRule data templateData } var pending []toFire + + // Shared over-window: a phase is over or it isn't, independent of + // which rule is watching. Reset once, then each enabled rule decides + // against its own threshold and latch. for phase, a := range amps { - key := EventFuseOverLimit + "|" + phase if a <= limitA { - // Back under: reset the over-window and per-outage latch. delete(s.fuseFirstOverAt, phase) - delete(s.alreadyFired, key) continue } - first, ok := s.fuseFirstOverAt[phase] - if !ok { + if _, seen := s.fuseFirstOverAt[phase]; !seen { s.fuseFirstOverAt[phase] = now - continue } - if now.Sub(first) < threshold { + } + + for _, typ := range []string{EventFuseOverLimit, PushFuseOverLimit} { + rule, found := findRule(cfg.Events, typ) + if !found || !rule.Enabled { continue } - if s.alreadyFired[key] { - continue + threshold := time.Duration(rule.ThresholdS) * time.Second + if threshold == 0 { + threshold = 30 * time.Second } - if rule.CooldownS > 0 { - if last, ok := s.lastFired[key]; ok && now.Sub(last) < time.Duration(rule.CooldownS)*time.Second { + for phase, a := range amps { + key := typ + "|" + phase + if a <= limitA { + delete(s.alreadyFired, key) + continue + } + first, seen := s.fuseFirstOverAt[phase] + if !seen || now.Sub(first) < threshold { continue } + if s.alreadyFired[key] { + continue + } + if rule.CooldownS > 0 { + if last, ok := s.lastFired[key]; ok && now.Sub(last) < time.Duration(rule.CooldownS)*time.Second { + continue + } + } + s.alreadyFired[key] = true + s.lastFired[key] = now + pending = append(pending, toFire{ + rule: rule, + data: templateData{ + EventType: typ, + Timestamp: now.UTC().Format(time.RFC3339), + Duration: humanDuration(now.Sub(first)), + DurationS: int(now.Sub(first) / time.Second), + Phase: phase, + Amps: a, + LimitA: limitA, + }, + }) } - s.alreadyFired[key] = true - s.lastFired[key] = now - pending = append(pending, toFire{ - rule: rule, - data: templateData{ - EventType: EventFuseOverLimit, - Timestamp: now.UTC().Format(time.RFC3339), - Duration: humanDuration(now.Sub(first)), - DurationS: int(now.Sub(first) / time.Second), - Phase: phase, - Amps: a, - LimitA: limitA, - }, - }) } s.mu.Unlock() @@ -670,7 +688,7 @@ func (s *Service) observeAt(health map[string]telemetry.DriverHealth, now time.T key := rule.Type + "|" + driver switch rule.Type { - case EventDriverOffline: + case EventDriverOffline, PushDriverOffline: threshold := time.Duration(rule.ThresholdS) * time.Second if threshold == 0 { threshold = time.Duration(DefaultThresholdS) * time.Second @@ -914,7 +932,42 @@ func (s *Service) buildData(driver, eventType string, since time.Duration, now t return td } +func (s *Service) dispatchCatalogue(cfg *config.Notifications, rule config.NotificationRule, data templateData) { + title, body, err := RenderPush(rule.Type, catalogueArgs(rule.Type, data)) + if err != nil { + slog.Warn("notifications: catalogue render failed", "event", rule.Type, "err", err) + s.bumpFailed() + s.emitDispatched(rule.Type, data.Device, Message{Priority: rule.Priority}, "failed", err.Error()) + return + } + prio := rule.Priority + if prio == 0 { + prio = cfg.DefaultPriority + } + s.deliver(rule.Type, data.Device, Message{ + Title: title, + Body: body, + Priority: prio, + Tags: splitTags(rule.Tags), + }) +} + +func catalogueArgs(kind string, data templateData) map[string]string { + switch kind { + case PushDriverOffline: + return map[string]string{"name": data.Device} + case PushFuseOverLimit: + return map[string]string{"phase": data.Phase} + default: + return nil + } +} + func (s *Service) dispatch(cfg *config.Notifications, rule config.NotificationRule, data templateData) { + if _, ok := PushSentences[rule.Type]; ok { + s.dispatchCatalogue(cfg, rule, data) + return + } titleTpl := rule.TitleTemplate if strings.TrimSpace(titleTpl) == "" { titleTpl = defaultTitleFor(rule.Type) diff --git a/go/internal/notifications/service_test.go b/go/internal/notifications/service_test.go index 2ea5078f..829a71f7 100644 --- a/go/internal/notifications/service_test.go +++ b/go/internal/notifications/service_test.go @@ -741,3 +741,86 @@ func TestConcurrentOffline_IgnoresColdStartDrivers(t *testing.T) { } func addr(t time.Time) *time.Time { return &t } + +// The phone's driver.offline kind uses the same silence threshold as the +// operator rule, but the lock-screen sentence is the catalogue's — never +// the ntfy template. +func TestCatalogueDriverOfflineRendersFromCatalogue(t *testing.T) { + pub := &fakePub{} + svc, clk := newSvc(pushCfg(config.NotificationRule{ + Type: PushDriverOffline, Enabled: true, ThresholdS: 600, Priority: 4, CooldownS: 3600, + }), pub) + last := clk.now() + clk.advance(400 * time.Second) + svc.Observe(healthOk(last)) + if n := len(pub.Messages()); n != 0 { + t.Fatalf("below threshold: got %d msgs", n) + } + clk.advance(400 * time.Second) + svc.Observe(healthStale(last)) + msgs := pub.Messages() + if len(msgs) != 1 { + t.Fatalf("above threshold: got %d msgs", len(msgs)) + } + if msgs[0].Title != "A device went quiet" { + t.Fatalf("title = %q", msgs[0].Title) + } + if msgs[0].Body != "ferroamp stopped answering." { + t.Fatalf("body = %q", msgs[0].Body) + } +} + +func TestCatalogueDriverOfflineStaysSilentWhenDisabled(t *testing.T) { + pub := &fakePub{} + svc, clk := newSvc(pushCfg(config.NotificationRule{ + Type: PushDriverOffline, Enabled: false, ThresholdS: 600, + }), pub) + last := clk.now() + clk.advance(1000 * time.Second) + svc.Observe(healthStale(last)) + if n := len(pub.Messages()); n != 0 { + t.Fatalf("disabled catalogue kind dispatched %d messages", n) + } +} + +func TestCatalogueFuseOverLimitRendersFromCatalogue(t *testing.T) { + cfg := pushCfg(config.NotificationRule{ + Type: PushFuseOverLimit, Enabled: true, ThresholdS: 30, Priority: 5, CooldownS: 900, + }) + pub := &fakePub{published: make(chan struct{}, 1)} + svc, clk := newSvc(cfg, pub) + bus := events.NewBus() + svc.Subscribe(bus) + + fuseEvaluationStarted := make(chan struct{}, 1) + svc.SetFuseReader(func() (map[string]float64, float64, bool) { + fuseEvaluationStarted <- struct{}{} + return map[string]float64{"L1": 20.0, "L2": 10, "L3": 11}, 16.0, true + }) + tick := func() { + bus.Publish(events.HealthTick{Health: map[string]telemetry.DriverHealth{}, Now: clk.now()}) + waitForFuseEvaluation(t, svc, fuseEvaluationStarted) + } + + tick() + if n := len(pub.Messages()); n != 0 { + t.Fatalf("before threshold: got %d msgs", n) + } + clk.advance(40 * time.Second) + tick() + select { + case <-pub.published: + case <-time.After(time.Second): + t.Fatal("catalogue fuse notification was not published") + } + msgs := pub.Messages() + if len(msgs) != 1 { + t.Fatalf("after threshold: got %d msgs", len(msgs)) + } + if msgs[0].Title != "The house is drawing too much" { + t.Fatalf("title = %q", msgs[0].Title) + } + if msgs[0].Body != "L1 is over the fuse rating." { + t.Fatalf("body = %q", msgs[0].Body) + } +} diff --git a/web/settings/tabs/notifications.js b/web/settings/tabs/notifications.js index cf5e7b43..8a65e6a1 100644 --- a/web/settings/tabs/notifications.js +++ b/web/settings/tabs/notifications.js @@ -104,7 +104,8 @@ // driver_offline and fuse_over_limit. Others (driver_recovered, // update_available) would render a dead field — skip with a // clarifying note. - var usesThreshold = rule.type === "driver_offline" || rule.type === "fuse_over_limit"; + var usesThreshold = rule.type === "driver_offline" || rule.type === "fuse_over_limit" + || rule.type === "driver.offline" || rule.type === "fuse.over_limit"; var noThresholdNote = rule.type === "driver_recovered" ? "Fires within 30 s of telemetry resuming — no threshold configurable." : rule.type === "update_available"