Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/push-driver-offline.md
Original file line number Diff line number Diff line change
@@ -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.
10 changes: 10 additions & 0 deletions contract/push-catalogue.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion go/internal/api/api_notifications_push_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
4 changes: 4 additions & 0 deletions go/internal/notifications/catalogue_gen.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

19 changes: 19 additions & 0 deletions go/internal/notifications/catalogue_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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")
}
Expand Down
135 changes: 94 additions & 41 deletions go/internal/notifications/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -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},
Comment on lines +103 to +104

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Coalesce alias rules before publisher fan-out

When a site already has driver_offline or fuse_over_limit enabled and the phone enables the corresponding new catalogue toggle, both rule entries independently dispatch while deliver sends every dispatch to every installed publisher. The same outage therefore produces two web pushes and two ntfy posts, rather than one notification per selected transport; coalesce these aliases or route catalogue events only to web push.

Useful? React with 👍 / 👎.

}
}

Expand All @@ -110,7 +115,7 @@ func KnownRuleTypes() []string {
EventDriverOffline, EventDriverRecovered, EventUpdateAvailable,
EventFuseOverLimit, EventConcurrentDriversOffline,
PushChargingSessionComplete, PushChargingInterrupted,
PushUpdateInstalled,
PushUpdateInstalled, PushDriverOffline, PushFuseOverLimit,
}
}

Expand Down Expand Up @@ -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
Expand All @@ -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()
Expand All @@ -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
Comment on lines +547 to 548

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Start fuse timers only for enabled rules

When both fuse rules are disabled, this unconditional write still records how long each phase has been over its limit. If either rule is later enabled while the phase remains over, Reload does not clear fuseFirstOverAt, so the next evaluation can fire immediately using time accumulated before opt-in instead of waiting for the configured threshold; the previous evaluator returned before starting this timer when fuse_over_limit was disabled.

Useful? React with 👍 / 👎.

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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
83 changes: 83 additions & 0 deletions go/internal/notifications/service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
3 changes: 2 additions & 1 deletion web/settings/tabs/notifications.js
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down