Skip to content
Draft
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/planner-prefs-command.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"ftw": patch
---

Phones can save household planner preferences over the session. `planner.prefs.set` stores the forecast safety factor and whether the battery may sell, and the box maps that permission to a planner mode.
5 changes: 3 additions & 2 deletions contract/registry.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ roles:
# ---------------------------------------------------------------------------
ops:
- { name: site.mode.set, scope: ftw.mode.write, desc: Change the site operating mode }
- { name: planner.prefs.set, scope: ftw.mode.write, desc: Set household planner preferences }
- { name: battery.hold, scope: ftw.dispatch.write, desc: Hold the battery at a fixed setpoint }
- { name: loadpoint.hold, scope: ftw.dispatch.write, desc: Charge the car now at a fixed current }
- { name: loadpoint.boost, scope: ftw.dispatch.write, desc: Boost the car from the house battery }
Expand All @@ -134,8 +135,8 @@ ops:
# release, and it is the same degrade-don't-die rule as capabilities.
#
# tier decides placement, not permission:
# primary — the forecast-driven strategies, shown as the main choices
# advanced — manual fallbacks, behind a "More ways to run it" disclosure
# primary — forecast-driven strategies; the Plan card sets them through household prefs, not buttons
# advanced — manual fallbacks, behind "Manual…"
# hidden — valid over the API but never rendered as a button
# ---------------------------------------------------------------------------
modes:
Expand Down
40 changes: 26 additions & 14 deletions go/cmd/ftw/app_link.go
Original file line number Diff line number Diff line change
Expand Up @@ -595,20 +595,21 @@ func startAppLink(
// are read-only apart from the mode, and the mode goes through
// control's own validation.
return appproto.New(appproto.Config{
Clock: appproto.SystemClock{StartedAt: site.started, Source: "ntp"},
Site: site,
Info: info,
Modes: modes,
Plans: plans,
History: history,
Prices: priceReader,
Loadpoints: loadpoints,
API: gateway,
Caller: caller,
Grants: grants,
Caps: caps,
Codec: appuplink.Codec(),
Sender: sender,
Clock: appproto.SystemClock{StartedAt: site.started, Source: "ntp"},
Site: site,
Info: info,
Modes: modes,
Plans: plans,
PlannerPrefs: gateway,
History: history,
Prices: priceReader,
Loadpoints: loadpoints,
API: gateway,
Caller: caller,
Grants: grants,
Caps: caps,
Codec: appuplink.Codec(),
Sender: sender,
// The three frozen power fields point at the source whose
// freshness governs them. The site meter is the only one the
// box can name without knowing the site's hardware.
Expand Down Expand Up @@ -664,6 +665,17 @@ func (l *lateAPI) ServeHTTP(w http.ResponseWriter, r *http.Request) {
srv.ServeHTTP(w, r)
}

// Apply stores planner prefs through the API server, once it exists.
// Until then the command is refused: the session is up a moment before the
// server is bound, and a write in that window has nowhere to land.
func (l *lateAPI) Apply(safetyK float64, export string) (appproto.PlannerPrefsSnapshot, error) {
srv := l.srv.Load()
if srv == nil {
return appproto.PlannerPrefsSnapshot{}, errors.New("the box is still starting")
}
return srv.ApplyPlannerPrefs(safetyK, export)
}

func (l *lateAPI) Route(r *http.Request) apiauth.RouteFacts {
srv := l.srv.Load()
if srv == nil {
Expand Down
2 changes: 1 addition & 1 deletion go/internal/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -419,7 +419,7 @@ func (s *Server) routes() {
s.handle("GET /api/fleet-ping", Read, s.handleFleetPing)
s.handle("POST /api/mode", Actuate, s.handleSetMode, Via(appproto.OpSetMode))
s.handle("GET /api/planner/prefs", Read, s.handleGetPlannerPrefs)
s.handle("POST /api/planner/prefs", Actuate, s.handleSetPlannerPrefs)
s.handle("POST /api/planner/prefs", Actuate, s.handleSetPlannerPrefs, Via(appproto.OpPlannerPrefsSet))
s.handle("GET /api/modes", Read, s.handleModes)
s.handle("POST /api/target", Actuate, s.handleSetTarget)
s.handle("POST /api/peak_limit", Actuate, s.handleSetPeakLimit)
Expand Down
21 changes: 21 additions & 0 deletions go/internal/api/api_planner_prefs.go
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
package api

import (
"errors"
"net/http"

"github.com/srcfl/ftw/go/internal/appproto"
"github.com/srcfl/ftw/go/internal/config"
"github.com/srcfl/ftw/go/internal/control"
)
Expand Down Expand Up @@ -132,3 +134,22 @@ func (s *Server) applyPlannerPrefs(safetyK float64, export config.BatteryExport)
}
return nil
}

// ApplyPlannerPrefs is the session door into the same write POST
// /api/planner/prefs performs. The mapped mode in the snapshot is this
// server's answer; the caller does not choose it.
func (s *Server) ApplyPlannerPrefs(safetyK float64, export string) (appproto.PlannerPrefsSnapshot, error) {
exp, ok := config.ParseBatteryExport(export)
if !ok {
return appproto.PlannerPrefsSnapshot{}, errors.New("battery_export must be unknown, not_allowed, or allowed")
}
if err := s.applyPlannerPrefs(safetyK, exp); err != nil {
return appproto.PlannerPrefsSnapshot{}, err
}
_, got, k, mapped := s.plannerPrefsSnapshot()
return appproto.PlannerPrefsSnapshot{
SafetyK: k,
Export: string(got),
MappedMode: mapped,
}, nil
}
19 changes: 19 additions & 0 deletions go/internal/api/api_planner_prefs_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"sync"
"testing"

"github.com/srcfl/ftw/go/internal/appproto"
"github.com/srcfl/ftw/go/internal/config"
"github.com/srcfl/ftw/go/internal/control"
"github.com/srcfl/ftw/go/internal/state"
Expand Down Expand Up @@ -222,3 +223,21 @@ func TestYAMLKNeverLocksTheSlider(t *testing.T) {
t.Errorf("mapped_k=%v, want 1.0 (balanced mapping, YAML ignored)", got["mapped_k"])
}
}

func TestPlannerPrefsCommandIsTheActuateDoor(t *testing.T) {
srv, ctrl, _ := plannerPrefsServer(t, control.ModePlannerPassiveArbitrage)
facts := srv.Route(httptest.NewRequest(http.MethodPost, "/api/planner/prefs", nil))
if facts.CmdOp != appproto.OpPlannerPrefsSet {
t.Fatalf("cmd op = %q, want %s", facts.CmdOp, appproto.OpPlannerPrefsSet)
}
snap, err := srv.ApplyPlannerPrefs(0.4, "allowed")
if err != nil {
t.Fatal(err)
}
if snap.MappedMode != "planner_arbitrage" || snap.SafetyK != 0.4 || snap.Export != "allowed" {
t.Fatalf("snapshot = %+v", snap)
}
if ctrl.Mode != control.ModePlannerArbitrage {
t.Fatalf("mode = %q, want planner_arbitrage", ctrl.Mode)
}
}
5 changes: 5 additions & 0 deletions go/internal/appproto/command.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,11 @@ type opSpec struct {
func defaultOps() map[string]opSpec {
return map[string]opSpec{
OpSetMode: {scope: ScopeModeWrite, dispatchWrite: false},
// Household planner prefs can switch which planner mode is driving,
// which is state the box holds. The plan that comes out of them still
// meets the dispatch gate before anything moves, so a sick meter must
// not lock the household out of holding more reserve.
OpPlannerPrefsSet: {scope: ScopeModeWrite, dispatchWrite: false},
// The loadpoint pair moves energy — a manual hold commands the
// charger and a boost opens the battery-to-EV path — so both sit
// behind the dispatch gate. A box with no loadpoint controller
Expand Down
2 changes: 2 additions & 0 deletions go/internal/appproto/contract_gen.go

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

8 changes: 8 additions & 0 deletions go/internal/appproto/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,12 @@ type Config struct {
// this build and the subsystem is what is missing.
Loadpoints Loadpoints

// PlannerPrefs applies the Plan card's safety factor and battery-export
// permission, or nil before the API server is bound. planner.prefs.set
// then answers E_UNAVAILABLE, the same 503 a passthrough meets in that
// window.
PlannerPrefs PlannerPrefs

// Caller is whose session this is. Required: a session always belongs to
// one enrolled device, and a handler that does not know which one cannot
// refuse anything.
Expand Down Expand Up @@ -866,6 +872,8 @@ func (h *Handler) onCmd(ctx context.Context, env Envelope) error {
switch cmd.Op {
case OpSetMode:
return h.setMode(ctx, cmd, uptimeMs)
case OpPlannerPrefsSet:
return h.setPlannerPrefs(cmd, uptimeMs)
case OpLoadpointHold:
return h.loadpointHold(cmd, uptimeMs)
case OpLoadpointBoost:
Expand Down
32 changes: 19 additions & 13 deletions go/internal/appproto/harness_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,11 @@ func (g *fakeGrants) setRole(role string) {
// all live.
func newRig(t *testing.T) (*Handler, *fakeBox, *recorder, *fakeClock) {
t.Helper()
return newRigWith(t, nil)
}

func newRigWith(t *testing.T, prefs PlannerPrefs) (*Handler, *fakeBox, *recorder, *fakeClock) {
t.Helper()

clock := &fakeClock{uptimeMs: 60_000, now: time.UnixMilli(1_760_000_000_000)}
box := &fakeBox{
Expand Down Expand Up @@ -309,19 +314,20 @@ func newRig(t *testing.T) (*Handler, *fakeBox, *recorder, *fakeClock) {
rec := &recorder{}

h, err := New(Config{
Clock: clock,
Site: box,
Info: box,
Modes: box,
Plans: box,
Codec: testCodec{},
Sender: rec,
Caller: ownerCaller(),
Grants: newGrants(),
SrcGrid: "meter.p1",
SrcPV: "inverter.sungrow",
SrcBattery: "battery.sungrow",
NewLeaseID: func() string { return "lease-test" },
Clock: clock,
Site: box,
Info: box,
Modes: box,
Plans: box,
PlannerPrefs: prefs,
Codec: testCodec{},
Sender: rec,
Caller: ownerCaller(),
Grants: newGrants(),
SrcGrid: "meter.p1",
SrcPV: "inverter.sungrow",
SrcBattery: "battery.sungrow",
NewLeaseID: func() string { return "lease-test" },
})
if err != nil {
t.Fatalf("New: %v", err)
Expand Down
4 changes: 4 additions & 0 deletions go/internal/appproto/messages.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@ const (
// mode validation the API and Home Assistant use — a second validator
// here would be a second place for the two to disagree.
OpSetMode = "site.mode.set"
// OpPlannerPrefsSet stores the household safety factor and battery-export
// permission. The box maps that permission onto a planner mode; the
// caller does not name the mode.
OpPlannerPrefsSet = "planner.prefs.set"
// OpLoadpointHold pins one EV loadpoint to a fixed charging power, or
// releases it with `clear`. The same manual hold the HTTP route
// installs, reached through this door's gates instead of a verb.
Expand Down
61 changes: 61 additions & 0 deletions go/internal/appproto/planner_prefs.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package appproto

import (
"github.com/srcfl/ftw/go/internal/config"
)

// setPlannerPrefs stores the household safety factor and battery-export
// permission. Which planner mode that permission selects is the port's
// answer, read back after the write — this handler never maps an export
// permission onto a mode of its own.
func (h *Handler) setPlannerPrefs(cmd Cmd, uptimeMs int64) error {
prefs := h.cfg.PlannerPrefs
if prefs == nil {
return h.sendCmdResult(CmdResult{
CmdID: cmd.CmdID,
State: CmdRejected,
Error: &ErrorBody{
Code: ErrUnavailable,
Retryable: ErrorRetryable[ErrUnavailable],
Args: map[string]any{"op": cmd.Op},
},
})
}

k, ok := argNum(cmd.Args, "safety_k")
if !ok {
return h.rejectArg(cmd, "safety_k", cmd.Args["safety_k"])
}
export, _ := cmd.Args["battery_export"].(string)
if _, ok := config.ParseBatteryExport(export); !ok {
return h.rejectArg(cmd, "battery_export", cmd.Args["battery_export"])
}

if _, err := h.acceptCmd(cmd, uptimeMs); err != nil {
return err
}

snap, err := prefs.Apply(k, export)
if err != nil {
return h.settleAndReport(cmd.CmdID, CmdResult{
CmdID: cmd.CmdID,
State: CmdRejected,
Error: &ErrorBody{
Code: ErrUnavailable,
Retryable: ErrorRetryable[ErrUnavailable],
Args: map[string]any{"op": cmd.Op},
},
})
}

readAtMs := h.cfg.Clock.UptimeMs()
return h.settleAndReport(cmd.CmdID, CmdResult{
CmdID: cmd.CmdID,
State: CmdApplied,
Observed: &Observed{
Value: snap.SafetyK,
Src: ObservedSrcCore,
UptimeMs: readAtMs,
},
})
}
Loading
Loading