From 1853d6747bae789918f1a3257cf9dabf39314339 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 29 Sep 2026 19:11:22 +0000 Subject: [PATCH 1/2] feat(app): save household planner prefs over the session POST /api/planner/prefs changes dispatch, so the phone writes safety_k and battery_export with planner.prefs.set. The box still maps that permission onto a planner mode. Signed-off-by: Cursor Agent --- .changeset/planner-prefs-command.md | 5 + contract/registry.yaml | 5 +- go/cmd/ftw/app_link.go | 40 +++++--- go/internal/api/api.go | 2 +- go/internal/api/api_planner_prefs.go | 21 ++++ go/internal/api/api_planner_prefs_test.go | 19 ++++ go/internal/appproto/command.go | 5 + go/internal/appproto/contract_gen.go | 2 + go/internal/appproto/handler.go | 8 ++ go/internal/appproto/harness_test.go | 32 +++--- go/internal/appproto/messages.go | 4 + go/internal/appproto/planner_prefs.go | 61 ++++++++++++ go/internal/appproto/planner_prefs_test.go | 107 +++++++++++++++++++++ go/internal/appproto/ports.go | 16 +++ 14 files changed, 297 insertions(+), 30 deletions(-) create mode 100644 .changeset/planner-prefs-command.md create mode 100644 go/internal/appproto/planner_prefs.go create mode 100644 go/internal/appproto/planner_prefs_test.go diff --git a/.changeset/planner-prefs-command.md b/.changeset/planner-prefs-command.md new file mode 100644 index 000000000..a0d820c54 --- /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 c7fe97838..d407bcd0b 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 073c88240..b9c989596 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 42a909f34..8227d65a7 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 9af54681a..17ca511b2 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 6377a1964..a93285fa8 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 cb7a0919d..6a1a194e2 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 1c301490a..1c2032867 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 c18fac345..992e09d6d 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 acdaf58ad..f39a5e7a5 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 7ae7aad96..80f3ce88a 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 000000000..aa56da113 --- /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 000000000..7d661a428 --- /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 b59fa6f48..092b48ed7 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 From 68769f322d44d55700d8132b12c859c430928922 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Sun, 4 Oct 2026 17:47:09 +0200 Subject: [PATCH 2/2] fix(app): save only the changed planner preference Signed-off-by: Fredrik Ahlgren --- .changeset/planner-prefs-command.md | 2 +- go/cmd/ftw/app_link.go | 2 +- go/internal/api/api_planner_prefs.go | 17 +++++-- go/internal/api/api_planner_prefs_test.go | 28 ++++++++++- go/internal/appproto/planner_prefs.go | 26 +++++++--- go/internal/appproto/planner_prefs_test.go | 58 ++++++++++++++++++++-- go/internal/appproto/ports.go | 2 +- 7 files changed, 115 insertions(+), 20 deletions(-) diff --git a/.changeset/planner-prefs-command.md b/.changeset/planner-prefs-command.md index a0d820c54..532d98c1d 100644 --- a/.changeset/planner-prefs-command.md +++ b/.changeset/planner-prefs-command.md @@ -2,4 +2,4 @@ "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. +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. Each write sends only the preference it changes, so another client's stored choice stays in place. diff --git a/go/cmd/ftw/app_link.go b/go/cmd/ftw/app_link.go index b9c989596..459015300 100644 --- a/go/cmd/ftw/app_link.go +++ b/go/cmd/ftw/app_link.go @@ -668,7 +668,7 @@ func (l *lateAPI) ServeHTTP(w http.ResponseWriter, r *http.Request) { // 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) { +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") diff --git a/go/internal/api/api_planner_prefs.go b/go/internal/api/api_planner_prefs.go index 35e97ea6a..775abd0fd 100644 --- a/go/internal/api/api_planner_prefs.go +++ b/go/internal/api/api_planner_prefs.go @@ -171,12 +171,19 @@ func (s *Server) applyPlannerPrefsLocked(safetyK float64, export config.BatteryE // 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") +func (s *Server) ApplyPlannerPrefs(safetyK *float64, export *string) (appproto.PlannerPrefsSnapshot, error) { + if safetyK == nil && export == nil { + return appproto.PlannerPrefsSnapshot{}, errors.New("send safety_k or battery_export") + } + var exp *config.BatteryExport + if export != nil { + parsed, ok := config.ParseBatteryExport(*export) + if !ok { + return appproto.PlannerPrefsSnapshot{}, errors.New("battery_export must be unknown, not_allowed, or allowed") + } + exp = &parsed } - if err := s.applyPlannerPrefs(safetyK, exp); err != nil { + if _, err := s.applyPlannerChange(safetyK, exp); err != nil { return appproto.PlannerPrefsSnapshot{}, err } _, got, k, mapped := s.plannerPrefsSnapshot() diff --git a/go/internal/api/api_planner_prefs_test.go b/go/internal/api/api_planner_prefs_test.go index a93285fa8..de3b2e1a9 100644 --- a/go/internal/api/api_planner_prefs_test.go +++ b/go/internal/api/api_planner_prefs_test.go @@ -230,7 +230,8 @@ func TestPlannerPrefsCommandIsTheActuateDoor(t *testing.T) { if facts.CmdOp != appproto.OpPlannerPrefsSet { t.Fatalf("cmd op = %q, want %s", facts.CmdOp, appproto.OpPlannerPrefsSet) } - snap, err := srv.ApplyPlannerPrefs(0.4, "allowed") + k, export := 0.4, "allowed" + snap, err := srv.ApplyPlannerPrefs(&k, &export) if err != nil { t.Fatal(err) } @@ -241,3 +242,28 @@ func TestPlannerPrefsCommandIsTheActuateDoor(t *testing.T) { t.Fatalf("mode = %q, want planner_arbitrage", ctrl.Mode) } } + +func TestPlannerPrefsCommandKeepsTheOtherPreference(t *testing.T) { + for _, mode := range []control.Mode{control.ModePlannerPassiveArbitrage, control.ModeSelfConsumption} { + t.Run(string(mode), func(t *testing.T) { + srv, ctrl, _ := plannerPrefsServer(t, mode) + k, export := 0.6, "allowed" + if _, err := srv.ApplyPlannerPrefs(&k, &export); err != nil { + t.Fatal(err) + } + k = 0.15 + snap, err := srv.ApplyPlannerPrefs(&k, nil) + if err != nil || snap.Export != "allowed" || snap.SafetyK != 0.15 { + t.Fatalf("style change: snapshot=%+v err=%v", snap, err) + } + export = "not_allowed" + snap, err = srv.ApplyPlannerPrefs(nil, &export) + if err != nil || snap.SafetyK != 0.15 || snap.Export != "not_allowed" { + t.Fatalf("export change: snapshot=%+v err=%v", snap, err) + } + if mode == control.ModeSelfConsumption && ctrl.Mode != mode { + t.Fatalf("preference write left manual mode: %s", ctrl.Mode) + } + }) + } +} diff --git a/go/internal/appproto/planner_prefs.go b/go/internal/appproto/planner_prefs.go index aa56da113..59f406c9c 100644 --- a/go/internal/appproto/planner_prefs.go +++ b/go/internal/appproto/planner_prefs.go @@ -22,13 +22,27 @@ func (h *Handler) setPlannerPrefs(cmd Cmd, uptimeMs int64) error { }) } - k, ok := argNum(cmd.Args, "safety_k") - if !ok { - return h.rejectArg(cmd, "safety_k", cmd.Args["safety_k"]) + var k *float64 + if _, present := cmd.Args["safety_k"]; present { + value, ok := argNum(cmd.Args, "safety_k") + if !ok { + return h.rejectArg(cmd, "safety_k", cmd.Args["safety_k"]) + } + k = &value } - export, _ := cmd.Args["battery_export"].(string) - if _, ok := config.ParseBatteryExport(export); !ok { - return h.rejectArg(cmd, "battery_export", cmd.Args["battery_export"]) + var export *string + if raw, present := cmd.Args["battery_export"]; present { + value, ok := raw.(string) + if !ok { + return h.rejectArg(cmd, "battery_export", raw) + } + if _, ok := config.ParseBatteryExport(value); !ok { + return h.rejectArg(cmd, "battery_export", raw) + } + export = &value + } + if k == nil && export == nil { + return h.rejectArg(cmd, "safety_k", nil) } if _, err := h.acceptCmd(cmd, uptimeMs); err != nil { diff --git a/go/internal/appproto/planner_prefs_test.go b/go/internal/appproto/planner_prefs_test.go index 7d661a428..194102c63 100644 --- a/go/internal/appproto/planner_prefs_test.go +++ b/go/internal/appproto/planner_prefs_test.go @@ -17,15 +17,19 @@ type memPrefs struct { err error } -func (m *memPrefs) Apply(k float64, export string) (PlannerPrefsSnapshot, 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 + if k != nil { + m.k = config.ClampSafetyK(*k) + } + if export != nil { + m.export = *export + } + mapped := config.BatteryExport(m.export).PlannerModeKey() + return PlannerPrefsSnapshot{SafetyK: m.k, Export: m.export, MappedMode: mapped}, nil } func cmdPlannerPrefs(k float64, export string) Cmd { @@ -105,3 +109,47 @@ func TestPlannerPrefsSetReportsWhenTheWriteFails(t *testing.T) { t.Fatalf("calls = %d, want the one attempt", mem.calls) } } + +func TestPlannerPrefsSetChangesOnePreference(t *testing.T) { + for _, tc := range []struct { + name string + args map[string]any + wantK float64 + wantExport string + }{ + {"style keeps export", map[string]any{"safety_k": 0.15}, 0.15, "allowed"}, + {"export keeps margin", map[string]any{"battery_export": "not_allowed"}, 0.6, "not_allowed"}, + } { + t.Run(tc.name, func(t *testing.T) { + mem := &memPrefs{k: 0.6, export: "allowed"} + h, _, rec, _ := newRigWith(t, mem) + subscribe(t, h, rec) + rec.reset() + cmd := cmdPlannerPrefs(0, "") + cmd.Args = tc.args + deliver(t, h, MsgCmd, nil, cmd) + res := body[CmdResult](t, rec.only(t, MsgCmdResult)) + if res.State != CmdApplied || mem.k != tc.wantK || mem.export != tc.wantExport { + t.Fatalf("result=%+v stored=%+v", res, mem) + } + }) + } +} + +func TestPlannerPrefsSetRejectsEmptyOrInvalidChanges(t *testing.T) { + for _, args := range []map[string]any{ + {}, {"safety_k": "bold"}, {"safety_k": nil}, {"battery_export": nil}, + } { + mem := &memPrefs{k: 0.3, export: "not_allowed"} + h, _, rec, _ := newRigWith(t, mem) + subscribe(t, h, rec) + rec.reset() + cmd := cmdPlannerPrefs(0, "") + cmd.Args = args + deliver(t, h, MsgCmd, nil, cmd) + res := body[CmdResult](t, rec.only(t, MsgCmdResult)) + if res.State != CmdRejected || mem.calls != 0 { + t.Fatalf("args=%v result=%+v calls=%d", args, res, mem.calls) + } + } +} diff --git a/go/internal/appproto/ports.go b/go/internal/appproto/ports.go index 092b48ed7..0b5d95a43 100644 --- a/go/internal/appproto/ports.go +++ b/go/internal/appproto/ports.go @@ -194,7 +194,7 @@ type PlannerPrefsSnapshot struct { // permission through the same write POST /api/planner/prefs performs. // Nil answers E_UNAVAILABLE. type PlannerPrefs interface { - Apply(safetyK float64, export string) (PlannerPrefsSnapshot, error) + Apply(safetyK *float64, export *string) (PlannerPrefsSnapshot, error) } // PlanReader hands over the planner's current output.