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
11 changes: 11 additions & 0 deletions .changeset/control-not-following-notice.md
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.
7 changes: 7 additions & 0 deletions contract/push-catalogue.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions go/cmd/ftw/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 20 additions & 0 deletions go/internal/api/api_control_states.go
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
}
47 changes: 47 additions & 0 deletions go/internal/api/api_control_states_test.go
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)
}
})
}
6 changes: 4 additions & 2 deletions go/internal/api/api_notifications_push_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
1 change: 1 addition & 0 deletions go/internal/notifications/catalogue.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
134 changes: 134 additions & 0 deletions go/internal/notifications/control.go
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
Comment on lines +48 to +50

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 Reset the control observation window when notifications are disabled

If notifications are disabled after this rule has observed a device not following, this early return preserves controlFirstAt throughout the disabled period. Service.Reload also 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 retaining lastFired for cooldown enforcement.

Useful? React with 👍 / 👎.

}
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,
})
}
113 changes: 113 additions & 0 deletions go/internal/notifications/control_test.go
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")
}
}
Loading
Loading