diff --git a/.changeset/planner-prefs-command.md b/.changeset/planner-prefs-command.md new file mode 100644 index 00000000..a0d820c5 --- /dev/null +++ b/.changeset/planner-prefs-command.md @@ -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. diff --git a/contract/registry.yaml b/contract/registry.yaml index c7fe9783..d407bcd0 100644 --- a/contract/registry.yaml +++ b/contract/registry.yaml @@ -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 } @@ -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: diff --git a/go/cmd/ftw/app_link.go b/go/cmd/ftw/app_link.go index 073c8824..b9c98959 100644 --- a/go/cmd/ftw/app_link.go +++ b/go/cmd/ftw/app_link.go @@ -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. @@ -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 { diff --git a/go/internal/api/api.go b/go/internal/api/api.go index 42a909f3..8227d65a 100644 --- a/go/internal/api/api.go +++ b/go/internal/api/api.go @@ -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) diff --git a/go/internal/api/api_planner_prefs.go b/go/internal/api/api_planner_prefs.go index 9af54681..17ca511b 100644 --- a/go/internal/api/api_planner_prefs.go +++ b/go/internal/api/api_planner_prefs.go @@ -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" ) @@ -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 +} diff --git a/go/internal/api/api_planner_prefs_test.go b/go/internal/api/api_planner_prefs_test.go index 6377a196..a93285fa 100644 --- a/go/internal/api/api_planner_prefs_test.go +++ b/go/internal/api/api_planner_prefs_test.go @@ -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" @@ -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) + } +} diff --git a/go/internal/appproto/command.go b/go/internal/appproto/command.go index cb7a0919..6a1a194e 100644 --- a/go/internal/appproto/command.go +++ b/go/internal/appproto/command.go @@ -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 diff --git a/go/internal/appproto/contract_gen.go b/go/internal/appproto/contract_gen.go index 1c301490..1c203286 100644 --- a/go/internal/appproto/contract_gen.go +++ b/go/internal/appproto/contract_gen.go @@ -138,6 +138,8 @@ var WriteScopes = []string{ var RegistryOps = map[string]string{ // site.mode.set — Change the site operating mode. "site.mode.set": "ftw.mode.write", + // planner.prefs.set — Set household planner preferences. + "planner.prefs.set": "ftw.mode.write", // battery.hold — Hold the battery at a fixed setpoint. "battery.hold": "ftw.dispatch.write", // loadpoint.hold — Charge the car now at a fixed current. diff --git a/go/internal/appproto/handler.go b/go/internal/appproto/handler.go index c18fac34..992e09d6 100644 --- a/go/internal/appproto/handler.go +++ b/go/internal/appproto/handler.go @@ -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. @@ -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: diff --git a/go/internal/appproto/harness_test.go b/go/internal/appproto/harness_test.go index acdaf58a..f39a5e7a 100644 --- a/go/internal/appproto/harness_test.go +++ b/go/internal/appproto/harness_test.go @@ -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{ @@ -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) diff --git a/go/internal/appproto/messages.go b/go/internal/appproto/messages.go index 7ae7aad9..80f3ce88 100644 --- a/go/internal/appproto/messages.go +++ b/go/internal/appproto/messages.go @@ -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. diff --git a/go/internal/appproto/planner_prefs.go b/go/internal/appproto/planner_prefs.go new file mode 100644 index 00000000..aa56da11 --- /dev/null +++ b/go/internal/appproto/planner_prefs.go @@ -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, + }, + }) +} diff --git a/go/internal/appproto/planner_prefs_test.go b/go/internal/appproto/planner_prefs_test.go new file mode 100644 index 00000000..7d661a42 --- /dev/null +++ b/go/internal/appproto/planner_prefs_test.go @@ -0,0 +1,107 @@ +package appproto + +import ( + "errors" + "testing" + + "github.com/srcfl/ftw/go/internal/config" +) + +// memPrefs is the household preference as the command lane's port. It maps +// an allowed export the way the box does, so a test can see that the handler +// stored the permission and did not invent a mode of its own. +type memPrefs struct { + k float64 + export string + calls int + err error +} + +func (m *memPrefs) Apply(k float64, export string) (PlannerPrefsSnapshot, error) { + m.calls++ + if m.err != nil { + return PlannerPrefsSnapshot{}, m.err + } + m.k = config.ClampSafetyK(k) + m.export = export + mapped := config.BatteryExport(export).PlannerModeKey() + return PlannerPrefsSnapshot{SafetyK: m.k, Export: export, MappedMode: mapped}, nil +} + +func cmdPlannerPrefs(k float64, export string) Cmd { + return Cmd{ + CmdID: "0192f2a0-7c1e-7000-8000-0123456789ac", + Op: OpPlannerPrefsSet, + Args: map[string]any{"safety_k": k, "battery_export": export}, + NotValidAfterMs: 200_000, + Expect: Expect{Rev: 7}, + } +} + +func TestPlannerPrefsSetReadsBackTheStoredK(t *testing.T) { + mem := &memPrefs{} + h, _, rec, _ := newRigWith(t, mem) + subscribe(t, h, rec) + rec.reset() + + deliver(t, h, MsgCmd, nil, cmdPlannerPrefs(0.4, "allowed")) + + res := body[CmdResult](t, rec.only(t, MsgCmdResult)) + if res.State != CmdApplied { + t.Fatalf("state = %q, want applied", res.State) + } + if res.Observed == nil || res.Observed.Value != 0.4 || res.Observed.Src != ObservedSrcCore { + t.Fatalf("observed = %+v, want k 0.4 from core", res.Observed) + } + if mem.calls != 1 || mem.export != "allowed" || mem.k != 0.4 { + t.Fatalf("stored %+v", mem) + } +} + +func TestPlannerPrefsSetRejectsABadExportBeforeWriting(t *testing.T) { + mem := &memPrefs{} + h, _, rec, _ := newRigWith(t, mem) + subscribe(t, h, rec) + rec.reset() + + cmd := cmdPlannerPrefs(1, "spicy") + deliver(t, h, MsgCmd, nil, cmd) + + res := body[CmdResult](t, rec.only(t, MsgCmdResult)) + if res.State != CmdRejected || res.Error == nil || res.Error.Code != ErrUnknownOp { + t.Fatalf("result = %+v, want a rejected unknown arg", res) + } + if mem.calls != 0 { + t.Fatal("a bad export was written") + } +} + +func TestPlannerPrefsSetWithoutAPortIsUnavailable(t *testing.T) { + h, _, rec, _ := newRig(t) + subscribe(t, h, rec) + rec.reset() + + deliver(t, h, MsgCmd, nil, cmdPlannerPrefs(1, "not_allowed")) + + res := body[CmdResult](t, rec.only(t, MsgCmdResult)) + if res.State != CmdRejected || res.Error == nil || res.Error.Code != ErrUnavailable { + t.Fatalf("result = %+v, want E_UNAVAILABLE", res) + } +} + +func TestPlannerPrefsSetReportsWhenTheWriteFails(t *testing.T) { + mem := &memPrefs{err: errors.New("store down")} + h, _, rec, _ := newRigWith(t, mem) + subscribe(t, h, rec) + rec.reset() + + deliver(t, h, MsgCmd, nil, cmdPlannerPrefs(1, "not_allowed")) + + res := body[CmdResult](t, rec.only(t, MsgCmdResult)) + if res.State != CmdRejected || res.Error == nil || res.Error.Code != ErrUnavailable { + t.Fatalf("result = %+v, want E_UNAVAILABLE", res) + } + if mem.calls != 1 { + t.Fatalf("calls = %d, want the one attempt", mem.calls) + } +} diff --git a/go/internal/appproto/ports.go b/go/internal/appproto/ports.go index b59fa6f4..092b48ed 100644 --- a/go/internal/appproto/ports.go +++ b/go/internal/appproto/ports.go @@ -181,6 +181,22 @@ type Loadpoints interface { ObservedSurplusOnly(id string) (v bool, ok bool) } +// PlannerPrefsSnapshot is the household planner preference as the box holds +// it after a write. MappedMode is the planner mode the export permission +// selects. The caller does not choose it. +type PlannerPrefsSnapshot struct { + SafetyK float64 + Export string + MappedMode string +} + +// PlannerPrefs applies the Plan card's safety factor and battery-export +// permission through the same write POST /api/planner/prefs performs. +// Nil answers E_UNAVAILABLE. +type PlannerPrefs interface { + Apply(safetyK float64, export string) (PlannerPrefsSnapshot, error) +} + // PlanReader hands over the planner's current output. // // Nil means the planner has produced nothing at all, which the wire reports as