Repository navigation
feat(notifications): tell the owner when a device does not follow FTW #1972
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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) | ||
| } | ||
| }) | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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, | ||
| }) | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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") | ||
| } | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If notifications are disabled after this rule has observed a device not following, this early return preserves
controlFirstAtthroughout the disabled period.Service.Reloadalso leaves that map intact, so reenabling notifications can immediately count the entire disabled interval toward the ten-minute threshold—even if the device recovered and only just started failing again. This can send an alert without the required sustained observation window. Clear the control observation state when disabling notifications or reset it on reload, while retaininglastFiredfor cooldown enforcement.Useful? React with 👍 / 👎.