From 4fc3194c602eec88ba4869371db6864b3dae6d7e Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Thu, 5 Nov 2026 15:00:59 +0100 Subject: [PATCH] feat(notifications): tell the owner when a device does not follow FTW xorath, 5 Nov 2026: a battery charged 2.3 kW while FTW asked it to discharge 812 W. The box's page said "Not following"; no phone heard. The new control.not_following rule reads the box's own answer to "Are we in control?" (the control_feedback GET /api/status serves) at most every 30 s on the HealthTick, and fires once a device function has been not_following for ten minutes. It fires once until the device follows again, with an hour's cooldown on top. On by default for owners with notifications on (Fredrik, 5 Nov 2026); a box whose stored rules predate the kind takes the default. The words come from the push catalogue. Co-Authored-By: Claude Opus 5.5 --- .changeset/control-not-following-notice.md | 11 ++ contract/push-catalogue.yaml | 7 + go/cmd/ftw/main.go | 3 + go/internal/api/api_control_states.go | 20 +++ go/internal/api/api_control_states_test.go | 47 ++++++ .../api/api_notifications_push_test.go | 6 +- go/internal/notifications/catalogue.go | 1 + go/internal/notifications/control.go | 134 ++++++++++++++++++ go/internal/notifications/control_test.go | 113 +++++++++++++++ go/internal/notifications/service.go | 22 ++- go/protocol/push_gen.go | 2 + web/settings/tabs/notifications.js | 9 +- 12 files changed, 365 insertions(+), 10 deletions(-) create mode 100644 .changeset/control-not-following-notice.md create mode 100644 go/internal/api/api_control_states.go create mode 100644 go/internal/api/api_control_states_test.go create mode 100644 go/internal/notifications/control.go create mode 100644 go/internal/notifications/control_test.go diff --git a/.changeset/control-not-following-notice.md b/.changeset/control-not-following-notice.md new file mode 100644 index 000000000..69a5dd899 --- /dev/null +++ b/.changeset/control-not-following-notice.md @@ -0,0 +1,11 @@ +--- +"ftw": minor +--- + +A phone now hears when a device does not do what FTW asked. The new +`control.not_following` notification fires when the box's answer to "Are we +in control?" has said a device is not following for ten minutes, as when a +battery charges while FTW asks it to discharge. It fires once until the +device follows again, and at most once an hour. It is on by default for +owners who have notifications on, and each owner can turn it off with the +other notification choices. diff --git a/contract/push-catalogue.yaml b/contract/push-catalogue.yaml index 3a563679d..e428f57d1 100644 --- a/contract/push-catalogue.yaml +++ b/contract/push-catalogue.yaml @@ -44,6 +44,13 @@ events: # kettle must not page anyone. title: The house is drawing too much body: "{phase} is over the fuse rating." + - kind: control.not_following + # After the box's own threshold: ten minutes in which the box's answer + # to "Are we in control?" said a device does not do what FTW asked, as + # a battery that charges when told to discharge. A slow battery must not + # page anyone. Once until the device follows again. + title: A device is not following FTW + body: "{name} does not do what FTW asks. Open FTW to see why." - 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/cmd/ftw/main.go b/go/cmd/ftw/main.go index 8ce3b1a01..27bc7a544 100644 --- a/go/cmd/ftw/main.go +++ b/go/cmd/ftw/main.go @@ -2722,6 +2722,9 @@ func main() { } return dev.DeviceID, dev.Make, dev.Serial, true }) + // The control.not_following rule reads the box's own answer to "Are we + // in control?", the one GET /api/status serves. + notifSvc.SetControlReader(srv.ControlStates) // FuseReader: on each HealthTick the fuse_over_limit rule reads // the site meter's live per-phase currents from telemetry and // compares against cfg.Fuse.MaxAmps. Closes over cfg + cfgMu so diff --git a/go/internal/api/api_control_states.go b/go/internal/api/api_control_states.go new file mode 100644 index 000000000..564da4bdd --- /dev/null +++ b/go/internal/api/api_control_states.go @@ -0,0 +1,20 @@ +package api + +import ( + "time" + + "github.com/srcfl/ftw/go/internal/notifications" +) + +// ControlStates is the box's answer to "Are we in control?" for the +// control.not_following notification rule: the same verdicts GET +// /api/status serves in control_feedback, so a push never says what the +// box's page does not. +func (s *Server) ControlStates(now time.Time) []notifications.ControlState { + rows := s.controlFeedback(now) + out := make([]notifications.ControlState, 0, len(rows)) + for _, f := range rows { + out = append(out, notifications.ControlState{Driver: f.Driver, Kind: f.Kind, Status: f.Status}) + } + return out +} diff --git a/go/internal/api/api_control_states_test.go b/go/internal/api/api_control_states_test.go new file mode 100644 index 000000000..98ad91a5d --- /dev/null +++ b/go/internal/api/api_control_states_test.go @@ -0,0 +1,47 @@ +package api + +import ( + "encoding/json" + "sync" + "testing" + "testing/synctest" + "time" + + "github.com/srcfl/ftw/go/internal/control" + "github.com/srcfl/ftw/go/internal/notifications" + "github.com/srcfl/ftw/go/internal/telemetry" +) + +// The control.not_following rule reads the same verdict GET /api/status +// serves: a battery that charges while FTW asked it to discharge. +func TestControlStatesAreTheStatusDocumentsVerdicts(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + tel := telemetry.NewStore() + read := func(power float64) { + tel.Update("hybrid", telemetry.DerBattery, power, nil, []byte(`{}`)) + tel.Update("hybrid", telemetry.DerMeter, 800+power, nil, []byte(`{"power_origin":"external_meter"}`)) + tel.RecordDriverSuccess("hybrid") + } + for range 5 { + read(0) + time.Sleep(3 * time.Second) + } + request, _ := json.Marshal(map[string]any{"action": "battery", "power_w": -5000}) + tel.CompleteCommand(tel.BeginCommand("hybrid", request, time.Now()), "accepted") + for range 15 { + read(4400) + time.Sleep(3 * time.Second) + } + srv := New(&Deps{Tel: tel, Ctrl: &control.State{SiteMeterDriver: "hybrid"}, CtrlMu: &sync.Mutex{}}) + now := time.Now() + rows := srv.controlFeedback(now) + got := srv.ControlStates(now) + if len(got) != len(rows) || len(got) == 0 { + t.Fatalf("states %+v for rows %+v", got, rows) + } + want := notifications.ControlState{Driver: "hybrid", Kind: "battery", Status: "not_following"} + if got[0] != want || rows[0].Reason != "power_wrong_direction" { + t.Fatalf("got %+v (%s), want %+v", got[0], rows[0].Reason, want) + } + }) +} diff --git a/go/internal/api/api_notifications_push_test.go b/go/internal/api/api_notifications_push_test.go index c0510440c..985905224 100644 --- a/go/internal/api/api_notifications_push_test.go +++ b/go/internal/api/api_notifications_push_test.go @@ -387,14 +387,16 @@ func TestRulesPutSeedsDefaultsOnFirstTouch(t *testing.T) { t.Fatalf("defaults not seeded: %+v", notif) } // Everything is opt-in except the FTW Cloud and agent notices, which - // every owner should hear without having asked, and car.which: with + // every owner should hear without having asked, car.which: with // several cars and no answer, the box plans for a visitor until someone - // taps, and only this asks (docs/cars.md). + // taps, and only this asks (docs/cars.md), and control.not_following: a + // device that does not do what FTW asked (Fredrik, 5 Nov 2026). onByDefault := map[string]bool{ notifications.PushCloudGranted: true, notifications.PushCloudRevoked: true, notifications.PushAgentAdded: true, notifications.PushAgentRequest: true, notifications.PushAgentExpiring: true, notifications.PushApprovalKeySet: true, notifications.PushOwnerAdded: true, notifications.PushCarWhich: true, + notifications.PushControlNotFollowing: true, } enabled := 0 for _, e := range notif.Events { diff --git a/go/internal/notifications/catalogue.go b/go/internal/notifications/catalogue.go index 8a6dd69ea..01bd1f15b 100644 --- a/go/internal/notifications/catalogue.go +++ b/go/internal/notifications/catalogue.go @@ -18,6 +18,7 @@ const ( PushUpdateInstalled = protocol.PushUpdateInstalled PushDriverOffline = protocol.PushDriverOffline PushFuseOverLimit = protocol.PushFuseOverLimit + PushControlNotFollowing = protocol.PushControlNotFollowing PushBoxUnreachable = protocol.PushBoxUnreachable PushCloudGranted = protocol.PushCloudGranted PushAgentAdded = protocol.PushAgentAdded diff --git a/go/internal/notifications/control.go b/go/internal/notifications/control.go new file mode 100644 index 000000000..7f71baf16 --- /dev/null +++ b/go/internal/notifications/control.go @@ -0,0 +1,134 @@ +package notifications + +import ( + "log/slog" + "time" + + "github.com/srcfl/ftw/go/internal/config" +) + +// ControlState is one device function in the box's answer to "Are we in +// control?", as GET /api/status serves it in control_feedback. +type ControlState struct { + Driver string + Kind string + Status string +} + +// ControlReader answers what the box's control feedback says now. main.go +// wires it to the API's own builder, so the rule and the status document +// can never disagree. +type ControlReader = func(now time.Time) []ControlState + +// controlCheckEvery is how often the control rule asks. The answer is +// worked out on demand, and the rule waits minutes before it speaks, so a +// HealthTick every few seconds would only repeat the work. +const controlCheckEvery = 30 * time.Second + +// SetControlReader wires the control feedback the control.not_following rule +// reads. nil disables the rule. +func (s *Service) SetControlReader(r ControlReader) { + if s == nil { + return + } + s.mu.Lock() + s.controlReader = r + s.mu.Unlock() +} + +// evaluateControl fires control.not_following for each device function the +// box has said is not following for the rule's threshold, once until it +// follows again. A box whose stored rules predate the kind takes its +// default, as FTW Cloud's own notices do. +func (s *Service) evaluateControl(now time.Time) { + if s == nil { + return + } + s.mu.Lock() + if s.cfg == nil || !s.cfg.Enabled || s.controlReader == nil || now.Sub(s.controlCheckedAt) < controlCheckEvery { + s.mu.Unlock() + return + } + s.controlCheckedAt = now + rule, ok := findRule(s.cfg.Events, PushControlNotFollowing) + if !ok { + rule, _ = findRule(DefaultRules(), PushControlNotFollowing) + } + if !rule.Enabled { + clear(s.controlFirstAt) + s.mu.Unlock() + return + } + reader := s.controlReader + prio := rule.Priority + if prio == 0 { + prio = s.cfg.DefaultPriority + } + s.mu.Unlock() + + // Read outside the lock: the builder takes the controller's own. + troubled := map[string]ControlState{} + for _, st := range reader(now) { + if st.Status == "not_following" { + troubled[st.Driver+"|"+st.Kind] = st + } + } + + threshold := time.Duration(rule.ThresholdS) * time.Second + if threshold == 0 { + threshold = time.Duration(DefaultThresholdS) * time.Second + } + var pending []ControlState + s.mu.Lock() + for key := range s.controlFirstAt { + if _, still := troubled[key]; !still { + delete(s.controlFirstAt, key) + delete(s.alreadyFired, PushControlNotFollowing+"|"+key) + } + } + for key, st := range troubled { + first, seen := s.controlFirstAt[key] + if !seen { + s.controlFirstAt[key] = now + continue + } + fired := PushControlNotFollowing + "|" + key + if now.Sub(first) < threshold || s.alreadyFired[fired] { + continue + } + if rule.CooldownS > 0 { + if last, ok := s.lastFired[fired]; ok && now.Sub(last) < time.Duration(rule.CooldownS)*time.Second { + continue + } + } + s.alreadyFired[fired] = true + s.lastFired[fired] = now + pending = append(pending, st) + } + s.mu.Unlock() + + for _, st := range pending { + s.deliverControl(rule, prio, st, now) + } +} + +func (s *Service) deliverControl(rule config.NotificationRule, prio int, st ControlState, now time.Time) { + args := map[string]string{"name": st.Driver} + title, body, err := RenderPush(PushControlNotFollowing, args) + if err != nil { + slog.Warn("notifications: catalogue render failed", "event", PushControlNotFollowing, "err", err) + s.bumpFailed() + s.emitDispatched(PushControlNotFollowing, st.Driver, Message{Priority: prio}, "failed", err.Error()) + return + } + s.deliver(PushControlNotFollowing, st.Driver, Message{ + Kind: PushControlNotFollowing, + Title: title, + Body: body, + Priority: prio, + Tags: splitTags(rule.Tags), + Args: args, + Device: st.Driver, + At: now, + }) +} diff --git a/go/internal/notifications/control_test.go b/go/internal/notifications/control_test.go new file mode 100644 index 000000000..01b1b2553 --- /dev/null +++ b/go/internal/notifications/control_test.go @@ -0,0 +1,113 @@ +package notifications + +import ( + "testing" + "time" + + "github.com/srcfl/ftw/go/internal/config" +) + +// xorath, 5 Nov 2026: a battery charged 2.3 kW while FTW asked it to +// discharge 812 W, and only the box's page said so. The rule tells the owner +// once the box has said it for ten minutes, once until the device follows. +func TestControlNotFollowingTellsTheOwnerOnceAfterTenMinutes(t *testing.T) { + pub := &fakePub{} + // Stored rules from before the kind existed: its default holds, and it + // is on. + svc, clk := newSvc(pushCfg(config.NotificationRule{Type: PushDriverOffline, Enabled: false}), pub) + status := "not_following" + reads := 0 + svc.SetControlReader(func(time.Time) []ControlState { + reads++ + return []ControlState{ + {Driver: "pixii", Kind: "battery", Status: status}, + {Driver: "easee", Kind: "ev", Status: "following"}, + } + }) + step := func(d time.Duration) { + clk.advance(d) + svc.evaluateControl(clk.now()) + } + + svc.evaluateControl(clk.now()) + for i := 0; i < 19; i++ { + step(30 * time.Second) + } + if n := len(pub.Messages()); n != 0 { + t.Fatalf("before ten minutes: %d messages", n) + } + step(30 * time.Second) + msgs := pub.Messages() + if len(msgs) != 1 { + t.Fatalf("after ten minutes: %d messages, want 1", len(msgs)) + } + m := msgs[0] + if m.Kind != PushControlNotFollowing || m.Title != "A device is not following FTW" || + m.Body != "pixii does not do what FTW asks. Open FTW to see why." || m.Args["name"] != "pixii" || m.Device != "pixii" { + t.Fatalf("message = %+v", m) + } + + // Still not following: nothing more. + step(30 * time.Minute) + if n := len(pub.Messages()); n != 1 { + t.Fatalf("still not following: %d messages", n) + } + + // It follows, then fails again within the hour: the cooldown holds. + status = "following" + step(30 * time.Second) + status = "not_following" + step(30 * time.Second) + step(11 * time.Minute) + if n := len(pub.Messages()); n != 1 { + t.Fatalf("again within the cooldown: %d messages", n) + } + // Past the hour since the first, and ten minutes into this one. + step(30 * time.Minute) + if n := len(pub.Messages()); n != 2 { + t.Fatalf("again after the cooldown: %d messages, want 2", n) + } + + // A HealthTick every few seconds asks at most every 30 s. + before := reads + for i := 0; i < 10; i++ { + step(2 * time.Second) + } + if got := reads - before; got > 1 { + t.Fatalf("asked %d times in 20 s", got) + } +} + +func TestControlNotFollowingStaysQuietWhenTurnedOff(t *testing.T) { + for name, cfg := range map[string]*config.Notifications{ + "rule off": pushCfg(config.NotificationRule{Type: PushControlNotFollowing, Enabled: false}), + "notifications off": {Enabled: false, Events: []config.NotificationRule{{Type: PushControlNotFollowing, Enabled: true}}}, + } { + pub := &fakePub{} + svc, clk := newSvc(cfg, pub) + svc.SetControlReader(func(time.Time) []ControlState { + return []ControlState{{Driver: "pixii", Kind: "battery", Status: "not_following"}} + }) + for i := 0; i < 60; i++ { + clk.advance(30 * time.Second) + svc.evaluateControl(clk.now()) + } + if n := len(pub.Messages()); n != 0 { + t.Fatalf("%s: %d messages", name, n) + } + } +} + +func TestControlNotFollowingIsARuleThePhonesCanSet(t *testing.T) { + rule, ok := findRule(DefaultRules(), PushControlNotFollowing) + if !ok || !rule.Enabled || rule.ThresholdS != 600 || rule.CooldownS != 3600 { + t.Fatalf("default rule = %+v, %v", rule, ok) + } + known := false + for _, k := range KnownRuleTypes() { + known = known || k == PushControlNotFollowing + } + if !known { + t.Fatal("control.not_following is not a rule type a phone may set") + } +} diff --git a/go/internal/notifications/service.go b/go/internal/notifications/service.go index 73a443ce9..3ed363856 100644 --- a/go/internal/notifications/service.go +++ b/go/internal/notifications/service.go @@ -107,6 +107,11 @@ func DefaultRules() []config.NotificationRule { // 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}, + // A device that does not do what FTW asked, for ten minutes. On by + // default (Fredrik, 5 Nov 2026): a battery that charged while FTW + // asked it to discharge said so on the box's page alone, and the + // owner looking at the phone never heard. + {Type: PushControlNotFollowing, Enabled: true, ThresholdS: DefaultThresholdS, Priority: 4, CooldownS: DefaultCooldownS}, // FTW Cloud on or off. The only rules on by default: each says the // house has started or stopped sending a readable copy of itself to // a Sourceful service, and every owner should hear that without @@ -138,7 +143,7 @@ func KnownRuleTypes() []string { EventFuseOverLimit, EventConcurrentDriversOffline, PushChargingConnected, PushChargingSessionComplete, PushChargingInterrupted, PushChargingGoalAtRisk, PushUpdateInstalled, PushDriverOffline, PushFuseOverLimit, - PushCloudGranted, PushCloudRevoked, PushAgentAdded, PushAgentRequest, + PushControlNotFollowing, PushCloudGranted, PushCloudRevoked, PushAgentAdded, PushAgentRequest, PushAgentExpiring, PushApprovalKeySet, PushOwnerAdded, PushCarWhich, } } @@ -282,9 +287,14 @@ type Service struct { // Used by the fuse_over_limit rule to enforce the threshold_s // sustained-over check, analogous to driver_offline's threshold. fuseFirstOverAt map[string]time.Time - sent uint64 - failed uint64 - now func() time.Time + // controlFirstAt[driver|kind] is when the control rule first saw a + // device function not following; controlCheckedAt when it last asked. + controlReader ControlReader + controlFirstAt map[string]time.Time + controlCheckedAt time.Time + sent uint64 + failed uint64 + now func() time.Time // charging answers what the plug-in rule needs to know about a // loadpoint; connectedSettle is how long it waits before asking. charging func(loadpointID string) (ChargingFacts, bool) @@ -301,6 +311,7 @@ func New(cfg *config.Notifications, pub Publisher, lookup DeviceLookup) *Service alreadyFired: map[string]bool{}, activeAlert: map[string]bool{}, fuseFirstOverAt: map[string]time.Time{}, + controlFirstAt: map[string]time.Time{}, now: time.Now, connectedSettle: ConnectedSettle, } @@ -398,6 +409,9 @@ func (s *Service) Subscribe(bus *events.Bus) { // Fuse rule runs off the same HealthTick cadence so brief // inrush currents don't fire without the threshold sustaining. go s.evaluateFuse(ev.Now) + // The control rule asks the box's own answer to "Are we in + // control?" on the same cadence, at most every 30 s. + go s.evaluateControl(ev.Now) }) bus.Subscribe(events.KindNotificationTest, func(e events.Event) { ev, ok := e.(events.NotificationTest) diff --git a/go/protocol/push_gen.go b/go/protocol/push_gen.go index 489a71065..ed3e2c7cd 100644 --- a/go/protocol/push_gen.go +++ b/go/protocol/push_gen.go @@ -15,6 +15,7 @@ const ( PushUpdateInstalled = "update.installed" PushDriverOffline = "driver.offline" PushFuseOverLimit = "fuse.over_limit" + PushControlNotFollowing = "control.not_following" PushBoxUnreachable = "box.unreachable" PushCloudGranted = "cloud.granted" PushCloudRevoked = "cloud.revoked" @@ -42,6 +43,7 @@ var PushSentences = map[string]PushSentence{ 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."}, + PushControlNotFollowing: {Title: "A device is not following FTW", Body: "{name} does not do what FTW asks. Open FTW to see why."}, PushBoxUnreachable: {Title: "Your box is out of reach", Body: "It stopped answering. Power cut, or the internet at home is down."}, PushCloudGranted: {Title: "FTW Cloud is on", Body: "Your box now shares a copy of its readings with FTW Cloud. You can turn it off in the app or on the box."}, PushCloudRevoked: {Title: "FTW Cloud is off", Body: "Your box stopped sharing with FTW Cloud and asked it to delete its copy."}, diff --git a/web/settings/tabs/notifications.js b/web/settings/tabs/notifications.js index 8a65e6a1f..36899a285 100644 --- a/web/settings/tabs/notifications.js +++ b/web/settings/tabs/notifications.js @@ -101,11 +101,12 @@ var rule = nc.events[ei]; var def = defaults[rule.type] || {}; // Types that use threshold_s (sustained-over window): - // driver_offline and fuse_over_limit. Others (driver_recovered, - // update_available) would render a dead field — skip with a - // clarifying note. + // driver_offline, fuse_over_limit and control.not_following. 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" - || rule.type === "driver.offline" || rule.type === "fuse.over_limit"; + || rule.type === "driver.offline" || rule.type === "fuse.over_limit" + || rule.type === "control.not_following"; var noThresholdNote = rule.type === "driver_recovered" ? "Fires within 30 s of telemetry resuming — no threshold configurable." : rule.type === "update_available"