From 31a90a00b14ded58db4c9215d48a69bfd7443c5f Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Fri, 4 Sep 2026 08:14:36 +0200 Subject: [PATCH] feat(appproto): ops to set the car's charge level and PV-only Two command operations the app can send where the passthrough refuses the HTTP routes: loadpoint.soc.set re-anchors the car's current state of charge through Manager.SetCurrentSoC and replans synchronously with reason loadpoint_soc_corrected; loadpoint.surplus_only.set flips the flag through Manager.SetSurplusOnly and, when it turns off, forces the same synchronous surplus_only_disabled replan the target route does. Both read back what the box now holds. Neither sits behind the dispatch gate, for the mode's reason. POST /api/loadpoints/{id}/soc and POST /api/loadpoints/{id}/target now carry Via(op), so the passthrough's E_USE_CMD names the command. contract/registry.yaml is the byte-identical copy of the app's; contract_gen.go is regenerated from it. Refs srcfl/ftw-webapp#59 Co-Authored-By: Claude Fable 5.1 --- .changeset/ops-soc-surplus.md | 5 + contract/registry.yaml | 2 + go/cmd/ftw/app_link.go | 44 ++++ go/cmd/ftw/app_link_test.go | 60 +++++ go/internal/api/api.go | 7 +- go/internal/api/api_passthrough_test.go | 16 ++ go/internal/appproto/command.go | 9 + go/internal/appproto/contract_gen.go | 4 + go/internal/appproto/ev.go | 132 +++++++++++ go/internal/appproto/ev_test.go | 300 ++++++++++++++++++++++-- go/internal/appproto/handler.go | 4 + go/internal/appproto/messages.go | 8 + go/internal/appproto/ports.go | 17 ++ 13 files changed, 591 insertions(+), 17 deletions(-) create mode 100644 .changeset/ops-soc-surplus.md diff --git a/.changeset/ops-soc-surplus.md b/.changeset/ops-soc-surplus.md new file mode 100644 index 000000000..7bfd65327 --- /dev/null +++ b/.changeset/ops-soc-surplus.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +The app can now correct the car's charge level and turn PV-only charging on or off over the session. Two new command operations, `loadpoint.soc.set` and `loadpoint.surplus_only.set`, do what the box's own page does through the same code path, and the matching HTTP routes name them when the passthrough refuses them. diff --git a/contract/registry.yaml b/contract/registry.yaml index 3bd94d96d..dfb20e2b9 100644 --- a/contract/registry.yaml +++ b/contract/registry.yaml @@ -117,6 +117,8 @@ ops: - { 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 } + - { name: loadpoint.soc.set, scope: ftw.dispatch.write, desc: Correct the car's current charge level } + - { name: loadpoint.surplus_only.set, scope: ftw.dispatch.write, desc: Charge the car from surplus PV only } # --------------------------------------------------------------------------- # Dispatch modes. diff --git a/go/cmd/ftw/app_link.go b/go/cmd/ftw/app_link.go index 73cef2f2a..edc2bb5f8 100644 --- a/go/cmd/ftw/app_link.go +++ b/go/cmd/ftw/app_link.go @@ -406,6 +406,50 @@ func (a *appLoadpoints) ObservedBoost(id string, now time.Time) loadpoint.Batter return status } +func (a *appLoadpoints) SetSoC(id string, soc float64) bool { + if !a.mgr.SetCurrentSoC(id, soc) { + return false + } + if a.mpc != nil { + // Before returning, on a fresh context, for the reason the HTTP + // route gives: the plan pushed after the result must be drawn from + // the corrected level, not from the estimate it replaced. + a.mpc.ReplanWithReason(context.Background(), "loadpoint_soc_corrected") + } + return true +} + +func (a *appLoadpoints) ObservedSoC(id string) (float64, bool) { + st, ok := a.mgr.State(id) + return st.CurrentSoC, ok +} + +func (a *appLoadpoints) SetSurplusOnly(id string, v bool) (bool, bool) { + prev, ok := a.mgr.SetSurplusOnly(id, v) + if !ok { + return false, false + } + if a.mpc != nil { + if prev && !v { + // Turning PV-only off is a regime change: the car may now + // import from the grid. The same synchronous, tagged replan the + // HTTP target route forces, so the plan pushed after the result + // already says so. + slog.Info("loadpoint surplus_only disabled — forcing replan", "lp", id) + a.mpc.ReplanWithReason(context.Background(), "surplus_only_disabled") + } else { + // Any other edit gets the HTTP route's background nudge. + go a.mpc.ReplanWithReason(context.Background(), "loadpoint_target_changed") + } + } + return prev, true +} + +func (a *appLoadpoints) ObservedSurplusOnly(id string) (bool, bool) { + st, ok := a.mgr.State(id) + return st.SurplusOnly, ok +} + // appPlans hands over the planner's current output. type appPlans struct { planner *mpc.Service diff --git a/go/cmd/ftw/app_link_test.go b/go/cmd/ftw/app_link_test.go index d718c89e9..be88f10ff 100644 --- a/go/cmd/ftw/app_link_test.go +++ b/go/cmd/ftw/app_link_test.go @@ -1,11 +1,13 @@ package main import ( + "math" "sync" "testing" "time" "github.com/srcfl/ftw/go/internal/control" + "github.com/srcfl/ftw/go/internal/loadpoint" "github.com/srcfl/ftw/go/internal/telemetry" ) @@ -240,3 +242,61 @@ func TestAppSetModeRefusesAModeTheBoxDoesNotHave(t *testing.T) { t.Fatalf("a refused mode still changed the state to %q", ctrl.Mode) } } + +// The port's charge-level path is the HTTP route's: SetCurrentSoC on the +// manager, refused while no car is plugged in, and read back from the +// manager's own state — within the half-permille the handler allows, because +// the manager re-anchors through the session's delivered energy. +func TestAppLoadpointsCorrectTheChargeLevelThroughTheManager(t *testing.T) { + mgr := loadpoint.NewManager() + mgr.Load([]loadpoint.Config{{ + ID: "garage", DriverName: "easee-cloud", + VehicleCapacityWh: 60000, PluginSoC: 0.4, + }}) + lp := &appLoadpoints{mgr: mgr} + + if lp.SetSoC("garage", 0.62) { + t.Fatal("an unplugged car's level was set") + } + + mgr.Observe("garage", true, 7400, 1200, true) // 1.2 kWh into the session + if !lp.SetSoC("garage", 0.62) { + t.Fatal("a plugged-in car's level was refused") + } + got, ok := lp.ObservedSoC("garage") + if !ok || math.Abs(got-0.62) > 0.0005 { + t.Fatalf("read back %v (known %v), want 0.62", got, ok) + } + if _, ok := lp.ObservedSoC("street"); ok { + t.Fatal("a loadpoint the box does not have read back a level") + } +} + +// The port's PV-only path is the HTTP target route's SetSurplusOnly: the +// previous value comes back so the caller knows the direction, and the +// read-back is the manager's own flag. +func TestAppLoadpointsFlipSurplusOnlyThroughTheManager(t *testing.T) { + mgr := loadpoint.NewManager() + mgr.Load([]loadpoint.Config{{ID: "garage", DriverName: "easee-cloud", SurplusOnly: true}}) + lp := &appLoadpoints{mgr: mgr} + + prev, ok := lp.SetSurplusOnly("garage", false) + if !ok || !prev { + t.Fatalf("SetSurplusOnly = (%v, %v), want the previous true", prev, ok) + } + if v, ok := lp.ObservedSurplusOnly("garage"); !ok || v { + t.Fatalf("read back %v (known %v), want off", v, ok) + } + + prev, ok = lp.SetSurplusOnly("garage", true) + if !ok || prev { + t.Fatalf("SetSurplusOnly = (%v, %v), want the previous false", prev, ok) + } + if v, ok := lp.ObservedSurplusOnly("garage"); !ok || !v { + t.Fatalf("read back %v (known %v), want on", v, ok) + } + + if _, ok := lp.SetSurplusOnly("street", true); ok { + t.Fatal("a loadpoint the box does not have took a flag") + } +} diff --git a/go/internal/api/api.go b/go/internal/api/api.go index 3a6efc75c..38bd42dca 100644 --- a/go/internal/api/api.go +++ b/go/internal/api/api.go @@ -517,14 +517,17 @@ func (s *Server) routes() { s.handle("POST /api/ev/chargers", Configure, s.handleEVChargers) s.handle("GET /api/ev/providers", Read, s.handleEVProviders) s.handle("GET /api/loadpoints", Read, s.handleLoadpoints) - s.handle("POST /api/loadpoints/{id}/target", Actuate, s.handleLoadpointTarget) + // Via names the one field of this body the session can set. The target + // level and its deadline still have no command; the passthrough refuses + // the whole route either way. + s.handle("POST /api/loadpoints/{id}/target", Actuate, s.handleLoadpointTarget, Via(appproto.OpLoadpointSurplusOnlySet)) // The schedule is configuration where its sibling target is // actuation: a schedule saved late is the same instruction, only // later, while target/soc/force_start move energy now. The split is // what lets a phone save one through the passthrough. s.handle("PUT /api/loadpoints/{id}/schedule", Configure, s.handleLoadpointSchedulePut) s.handle("DELETE /api/loadpoints/{id}/schedule", Configure, s.handleLoadpointScheduleClear) - s.handle("POST /api/loadpoints/{id}/soc", Actuate, s.handleLoadpointSoC) + s.handle("POST /api/loadpoints/{id}/soc", Actuate, s.handleLoadpointSoC, Via(appproto.OpLoadpointSoCSet)) s.handle("POST /api/loadpoints/{id}/force_start", Actuate, s.handleLoadpointForceStart) s.handle("POST /api/loadpoints/{id}/manual_hold", Actuate, s.handleLoadpointManualHold) s.handle("DELETE /api/loadpoints/{id}/manual_hold", Actuate, s.handleLoadpointManualHoldClear) diff --git a/go/internal/api/api_passthrough_test.go b/go/internal/api/api_passthrough_test.go index f406ddfea..31d9fb990 100644 --- a/go/internal/api/api_passthrough_test.go +++ b/go/internal/api/api_passthrough_test.go @@ -353,6 +353,22 @@ func TestActuationThroughThePassthroughIsRefused(t *testing.T) { Body: []byte(`{}`), StepUp: true, }, }, + { + name: "correcting the car's charge level, which has a command", + req: appproto.APIReq{ + Method: appproto.APIPost, Path: "/api/loadpoints/1/soc", + Body: []byte(`{"soc":0.6}`), StepUp: true, + }, + wantOp: appproto.OpLoadpointSoCSet, + }, + { + name: "the loadpoint target, whose PV-only flag has a command", + req: appproto.APIReq{ + Method: appproto.APIPost, Path: "/api/loadpoints/1/target", + Body: []byte(`{"surplus_only":false}`), StepUp: true, + }, + wantOp: appproto.OpLoadpointSurplusOnlySet, + }, { name: "holding the battery", req: appproto.APIReq{ diff --git a/go/internal/appproto/command.go b/go/internal/appproto/command.go index 3c44ca26a..cb7a0919d 100644 --- a/go/internal/appproto/command.go +++ b/go/internal/appproto/command.go @@ -44,6 +44,15 @@ func defaultOps() map[string]opSpec { // same routes give. OpLoadpointHold: {scope: ScopeDispatchWrite, dispatchWrite: true}, OpLoadpointBoost: {scope: ScopeDispatchWrite, dispatchWrite: true}, + // The two loadpoint settings carry the same scope — their HTTP + // routes are priced Actuate, and a viewer must not change what the + // car may draw — but not the dispatch gate, for the mode's reason: + // each is state the box holds, and the planner output it reshapes + // still meets the gate before anything moves. Refusing a corrected + // charge level or a PV-only toggle while a meter is sick would keep + // the plan wrong for exactly as long as the box cannot act on it. + OpLoadpointSoCSet: {scope: ScopeDispatchWrite, dispatchWrite: false}, + OpLoadpointSurplusOnlySet: {scope: ScopeDispatchWrite, dispatchWrite: false}, } } diff --git a/go/internal/appproto/contract_gen.go b/go/internal/appproto/contract_gen.go index 99ad5231c..a2baa4526 100644 --- a/go/internal/appproto/contract_gen.go +++ b/go/internal/appproto/contract_gen.go @@ -144,6 +144,10 @@ var RegistryOps = map[string]string{ "loadpoint.hold": "ftw.dispatch.write", // loadpoint.boost — Boost the car from the house battery. "loadpoint.boost": "ftw.dispatch.write", + // loadpoint.soc.set — Correct the car's current charge level. + "loadpoint.soc.set": "ftw.dispatch.write", + // loadpoint.surplus_only.set — Charge the car from surplus PV only. + "loadpoint.surplus_only.set": "ftw.dispatch.write", } // Error codes. The box sends the code and machine-readable args; the app diff --git a/go/internal/appproto/ev.go b/go/internal/appproto/ev.go index 9ba5b15da..ac1f6e875 100644 --- a/go/internal/appproto/ev.go +++ b/go/internal/appproto/ev.go @@ -311,3 +311,135 @@ func (h *Handler) cancelBoost(lp Loadpoints, id string, cmd Cmd, uptimeMs int64) } return h.settleAndReport(cmd.CmdID, res) } + +// socTolerance is how far a read-back may sit from the level asked for and +// still be that level: half a permille, the finest unit the telemetry wire +// carries a state of charge in. The manager re-anchors by subtracting and +// re-adding the session's delivered energy, which is float arithmetic, and a +// result that called that "superseded" would be reporting rounding as a +// rival writer. +const socTolerance = 0.0005 + +// loadpointSoCSet is the operator's correction of the car's charge level +// through the door: the same re-anchor POST /api/loadpoints/{id}/soc does, +// refused the same way when no car is plugged in. `soc` is a fraction in +// [0,1] — the rule the rest of the box keeps; permille is a telemetry wire +// unit, not an argument shape — and the read-back is the same fraction. +func (h *Handler) loadpointSoCSet(cmd Cmd, uptimeMs int64) error { + lp, id, ok, err := h.loadpointFor(cmd) + if !ok { + return err + } + + soc, ok := argNum(cmd.Args, "soc") + if !ok || soc < 0 || soc > 1 { + return h.rejectArg(cmd, "soc", cmd.Args["soc"]) + } + + if _, err := h.acceptCmd(cmd, uptimeMs); err != nil { + return err + } + + if !lp.SetSoC(id, soc) { + // No session to correct — the HTTP route's 409. Reported after the + // ack, the way control refusing a boost is, and named so the app can + // say "plug the car in" rather than "the box is down". + 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, "reason": "unplugged"}, + }, + }) + } + + // Read back the level the box now holds, never the echo of the request. + observed, known := lp.ObservedSoC(id) + readAtMs := h.cfg.Clock.UptimeMs() + var res CmdResult + switch { + case !known: + res = CmdResult{CmdID: cmd.CmdID, State: CmdUnconfirmed} + case math.Abs(observed-soc) > socTolerance: + // Something else re-anchored the level between the write and the + // read — a vehicle reading, another operator. + res = CmdResult{ + CmdID: cmd.CmdID, + State: CmdSuperseded, + Observed: &Observed{Value: observed, Src: ObservedSrcCore, UptimeMs: readAtMs}, + } + default: + res = CmdResult{ + CmdID: cmd.CmdID, + State: CmdApplied, + Observed: &Observed{Value: observed, Src: ObservedSrcCore, UptimeMs: readAtMs}, + } + } + return h.settleAndReport(cmd.CmdID, res) +} + +// loadpointSurplusOnlySet turns PV-only charging on or off: the surplus_only +// field of POST /api/loadpoints/{id}/target, and only that field. The port +// carries the replan the HTTP route forces when the flag turns off, so the +// plan pushed after the result already allows the grid. The read-back is 1 +// for on and 0 for off, the boost's convention for a flag. +func (h *Handler) loadpointSurplusOnlySet(cmd Cmd, uptimeMs int64) error { + lp, id, ok, err := h.loadpointFor(cmd) + if !ok { + return err + } + + want, ok := cmd.Args["surplus_only"].(bool) + if !ok { + return h.rejectArg(cmd, "surplus_only", cmd.Args["surplus_only"]) + } + + if _, err := h.acceptCmd(cmd, uptimeMs); err != nil { + return err + } + + if _, ok := lp.SetSurplusOnly(id, want); !ok { + // The loadpoint went away between the existence check and the + // write — a configuration reload mid-command. + 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}, + }, + }) + } + + observed, known := lp.ObservedSurplusOnly(id) + readAtMs := h.cfg.Clock.UptimeMs() + var res CmdResult + switch { + case !known: + res = CmdResult{CmdID: cmd.CmdID, State: CmdUnconfirmed} + case observed != want: + res = CmdResult{ + CmdID: cmd.CmdID, + State: CmdSuperseded, + Observed: &Observed{Value: flagValue(observed), Src: ObservedSrcCore, UptimeMs: readAtMs}, + } + default: + res = CmdResult{ + CmdID: cmd.CmdID, + State: CmdApplied, + Observed: &Observed{Value: flagValue(observed), Src: ObservedSrcCore, UptimeMs: readAtMs}, + } + } + return h.settleAndReport(cmd.CmdID, res) +} + +// flagValue is a flag as an observed value: 1 on, 0 off. +func flagValue(v bool) float64 { + if v { + return 1 + } + return 0 +} diff --git a/go/internal/appproto/ev_test.go b/go/internal/appproto/ev_test.go index 20b2b82f1..818c4fa01 100644 --- a/go/internal/appproto/ev_test.go +++ b/go/internal/appproto/ev_test.go @@ -30,6 +30,18 @@ type fakeLoadpoints struct { boostCalls int cancelCalls int onBoost func() + + // plugged gates SetSoC the way the manager does: no session, no + // correction. + plugged bool + soc float64 + socKnown bool + socCalls int + onSoC func() + + surplusOnly bool + surplusCalls int + onSurplus func() } func (f *fakeLoadpoints) Exists(id string) bool { return f.ids[id] } @@ -86,11 +98,57 @@ func (f *fakeLoadpoints) ObservedBoost(id string, _ time.Time) loadpoint.Battery return loadpoint.BatteryBoostStatus{State: "inactive"} } -// newEVRig is newRig with one loadpoint, "lp1", behind the port. +func (f *fakeLoadpoints) SetSoC(id string, soc float64) bool { + f.socCalls++ + if !f.ids[id] || !f.plugged { + return false + } + f.soc = soc + f.socKnown = true + if f.onSoC != nil { + f.onSoC() + } + return true +} + +func (f *fakeLoadpoints) ObservedSoC(id string) (float64, bool) { + if !f.ids[id] || !f.socKnown { + return 0, false + } + return f.soc, true +} + +func (f *fakeLoadpoints) SetSurplusOnly(id string, v bool) (bool, bool) { + f.surplusCalls++ + if !f.ids[id] { + return false, false + } + prev := f.surplusOnly + f.surplusOnly = v + if f.onSurplus != nil { + f.onSurplus() + } + return prev, true +} + +func (f *fakeLoadpoints) ObservedSurplusOnly(id string) (bool, bool) { + if !f.ids[id] { + return false, false + } + return f.surplusOnly, true +} + +// controllerCalls is every write that reached the fake, for the tests whose +// point is that nothing did. +func (f *fakeLoadpoints) controllerCalls() int { + return f.holdCalls + f.clearCalls + f.boostCalls + f.cancelCalls + f.socCalls + f.surplusCalls +} + +// newEVRig is newRig with one loadpoint, "lp1", plugged in, behind the port. func newEVRig(t *testing.T) (*Handler, *fakeLoadpoints, *fakeBox, *recorder, *fakeClock) { t.Helper() h, box, rec, clock := newRig(t) - lp := &fakeLoadpoints{ids: map[string]bool{"lp1": true}} + lp := &fakeLoadpoints{ids: map[string]bool{"lp1": true}, plugged: true} h.cfg.Loadpoints = lp return h, lp, box, rec, clock } @@ -116,6 +174,14 @@ func boostArgs() map[string]any { } } +func socArgs() map[string]any { + return map[string]any{"id": "lp1", "soc": 0.62} +} + +func surplusArgs(on bool) map[string]any { + return map[string]any{"id": "lp1", "surplus_only": on} +} + // The ack says the dispatcher took the intent; the result reports the hold // the box now carries. The same two-step site.mode.set has, because the echo // of a request is never confirmation. @@ -274,6 +340,8 @@ func TestAnExpiredLoadpointCommandNeverReachesTheCharger(t *testing.T) { }{ {OpLoadpointHold, holdArgs()}, {OpLoadpointBoost, boostArgs()}, + {OpLoadpointSoCSet, socArgs()}, + {OpLoadpointSurplusOnlySet, surplusArgs(false)}, } { t.Run(c.op, func(t *testing.T) { h, lp, _, rec, clock := newEVRig(t) @@ -285,7 +353,7 @@ func TestAnExpiredLoadpointCommandNeverReachesTheCharger(t *testing.T) { if res.State != CmdExpired || res.Error == nil || res.Error.Code != ErrCmdExpired { t.Fatalf("result = %+v, want expired/%s", res, ErrCmdExpired) } - if lp.holdCalls != 0 || lp.boostCalls != 0 { + if lp.controllerCalls() != 0 { t.Fatal("an expired command reached the controller") } if rec.has(MsgCmdAck) { @@ -304,6 +372,8 @@ func TestAnUnknownLoadpointIdIsRefused(t *testing.T) { }{ {OpLoadpointHold, map[string]any{"id": "lp9", "power_w": 7360, "hold_s": 0}}, {OpLoadpointBoost, map[string]any{"id": "lp9", "duration_s": 3600, "min_battery_soc_pct": 30}}, + {OpLoadpointSoCSet, map[string]any{"id": "lp9", "soc": 0.62}}, + {OpLoadpointSurplusOnlySet, map[string]any{"id": "lp9", "surplus_only": true}}, } { t.Run(c.op, func(t *testing.T) { h, lp, _, rec, _ := newEVRig(t) @@ -318,7 +388,7 @@ func TestAnUnknownLoadpointIdIsRefused(t *testing.T) { if res.Error.Args["arg"] != "id" { t.Fatalf("refusal args = %v, want the argument named", res.Error.Args) } - if lp.holdCalls != 0 || lp.boostCalls != 0 { + if lp.controllerCalls() != 0 { t.Fatal("an unknown loadpoint reached the controller") } if rec.has(MsgCmdAck) { @@ -339,6 +409,8 @@ func TestAViewersLoadpointCommandNeverReachesTheCharger(t *testing.T) { }{ {OpLoadpointHold, holdArgs()}, {OpLoadpointBoost, boostArgs()}, + {OpLoadpointSoCSet, socArgs()}, + {OpLoadpointSurplusOnlySet, surplusArgs(false)}, } { t.Run(c.op, func(t *testing.T) { h, lp, _, rec, _ := newEVRig(t) @@ -355,7 +427,7 @@ func TestAViewersLoadpointCommandNeverReachesTheCharger(t *testing.T) { if res.Error.Args["needScope"] != ScopeDispatchWrite { t.Fatalf("refusal args = %v, want the scope it needs", res.Error.Args) } - if lp.holdCalls != 0 || lp.clearCalls != 0 || lp.boostCalls != 0 || lp.cancelCalls != 0 { + if lp.controllerCalls() != 0 { t.Fatal("a viewer reached the loadpoint controller") } if rec.has(MsgCmdAck) { @@ -403,17 +475,28 @@ func TestLoadpointCommandsAreRefusedWhileDispatchIsBlocked(t *testing.T) { // missing, the session's word for the 503 the HTTP routes give. Distinct // from battery.hold, which no box implements and which stays E_UNKNOWN_OP. func TestABoxWithoutLoadpointsSaysUnavailableNotUnknown(t *testing.T) { - h, _, rec, _ := newRig(t) - subscribe(t, h, rec) + for _, c := range []struct { + op string + args map[string]any + }{ + {OpLoadpointHold, holdArgs()}, + {OpLoadpointSoCSet, socArgs()}, + {OpLoadpointSurplusOnlySet, surplusArgs(false)}, + } { + t.Run(c.op, func(t *testing.T) { + h, _, rec, _ := newRig(t) + subscribe(t, h, rec) - deliver(t, h, MsgCmd, nil, cmdLoadpoint(OpLoadpointHold, "cmd-noev", holdArgs(), 7, 200_000)) + deliver(t, h, MsgCmd, nil, cmdLoadpoint(c.op, "cmd-noev", c.args, 7, 200_000)) - res := body[CmdResult](t, rec.only(t, MsgCmdResult)) - if res.State != CmdRejected || res.Error == nil || res.Error.Code != ErrUnavailable { - t.Fatalf("result = %+v, want rejected/%s", res, ErrUnavailable) - } - if res.Error.Args["subsystem"] != "loadpoints" { - t.Fatalf("refusal args = %v, want the subsystem named", res.Error.Args) + res := body[CmdResult](t, rec.only(t, MsgCmdResult)) + if res.State != CmdRejected || res.Error == nil || res.Error.Code != ErrUnavailable { + t.Fatalf("result = %+v, want rejected/%s", res, ErrUnavailable) + } + if res.Error.Args["subsystem"] != "loadpoints" { + t.Fatalf("refusal args = %v, want the subsystem named", res.Error.Args) + } + }) } } @@ -486,6 +569,18 @@ func TestAMalformedLoadpointCommandIsRefused(t *testing.T) { map[string]any{"id": "lp1", "duration_s": 3600, "min_battery_soc_pct": 0}, "lease"}, {"a lease longer than the boost allows", OpLoadpointBoost, map[string]any{"id": "lp1", "duration_s": 5 * 3600, "min_battery_soc_pct": 30}, "lease"}, + {"a level above full", OpLoadpointSoCSet, + map[string]any{"id": "lp1", "soc": 1.2}, "soc"}, + {"a level below empty", OpLoadpointSoCSet, + map[string]any{"id": "lp1", "soc": -0.1}, "soc"}, + {"a level that is not a number", OpLoadpointSoCSet, + map[string]any{"id": "lp1", "soc": "62"}, "soc"}, + {"no level at all", OpLoadpointSoCSet, + map[string]any{"id": "lp1"}, "soc"}, + {"a flag that is not a bool", OpLoadpointSurplusOnlySet, + map[string]any{"id": "lp1", "surplus_only": "yes"}, "surplus_only"}, + {"no flag at all", OpLoadpointSurplusOnlySet, + map[string]any{"id": "lp1"}, "surplus_only"}, } for _, c := range cases { @@ -502,7 +597,7 @@ func TestAMalformedLoadpointCommandIsRefused(t *testing.T) { if res.Error.Args["arg"] != c.arg { t.Fatalf("refusal named %v, want %q", res.Error.Args["arg"], c.arg) } - if lp.holdCalls != 0 || lp.boostCalls != 0 { + if lp.controllerCalls() != 0 { t.Fatal("a malformed command reached the controller") } if rec.has(MsgCmdAck) { @@ -557,6 +652,8 @@ func TestAppliedLoadpointCommandsPushAFreshPlan(t *testing.T) { }{ {OpLoadpointHold, holdArgs()}, {OpLoadpointBoost, boostArgs()}, + {OpLoadpointSoCSet, socArgs()}, + {OpLoadpointSurplusOnlySet, surplusArgs(true)}, } { t.Run(c.op, func(t *testing.T) { h, _, _, rec, _ := newEVRig(t) @@ -573,3 +670,176 @@ func TestAppliedLoadpointCommandsPushAFreshPlan(t *testing.T) { }) } } + +// Correcting the car's level is the same two-step as every other op: the ack +// says the box took the intent, the result reports the level the box now +// holds, read back from core rather than echoed. +func TestLoadpointSoCSetAcksThenConfirmsFromAReadBack(t *testing.T) { + h, lp, _, rec, clock := newEVRig(t) + subscribe(t, h, rec) + + deliver(t, h, MsgCmd, nil, cmdLoadpoint(OpLoadpointSoCSet, "cmd-soc-1", socArgs(), 7, 200_000)) + + ack := body[CmdAck](t, rec.only(t, MsgCmdAck)) + if ack.LeaseID == "" { + t.Fatal("ack carried no lease") + } + if ack.ExpiresAtMs != clock.uptimeMs+LeaseMs { + t.Fatalf("lease expires at %d, want %d", ack.ExpiresAtMs, clock.uptimeMs+LeaseMs) + } + + res := body[CmdResult](t, rec.only(t, MsgCmdResult)) + if res.State != CmdApplied { + t.Fatalf("state = %q (%+v), want applied", res.State, res.Error) + } + if res.Observed == nil || res.Observed.Src != ObservedSrcCore { + t.Fatalf("observed = %+v; applied without a core read-back", res.Observed) + } + if res.Observed.Value != 0.62 { + t.Fatalf("observed value = %v, want the corrected level as a fraction", res.Observed.Value) + } + if lp.socCalls != 1 || lp.soc != 0.62 { + t.Fatalf("the manager was re-anchored %d times, to %v", lp.socCalls, lp.soc) + } +} + +// No car, no session, nothing to correct: the HTTP route's 409, reported after +// the ack the way control refusing a boost is, and named so the app can say +// what to do now. +func TestSettingTheLevelOfAnUnpluggedCarIsRefusedAfterTheAck(t *testing.T) { + h, lp, _, rec, _ := newEVRig(t) + lp.plugged = false + subscribe(t, h, rec) + + deliver(t, h, MsgCmd, nil, cmdLoadpoint(OpLoadpointSoCSet, "cmd-soc-unplugged", socArgs(), 7, 200_000)) + + if !rec.has(MsgCmdAck) { + t.Fatal("the dispatcher took the intent but never acked it") + } + res := body[CmdResult](t, rec.only(t, MsgCmdResult)) + if res.State != CmdRejected || res.Error == nil || res.Error.Code != ErrUnavailable { + t.Fatalf("result = %+v, want rejected/%s", res, ErrUnavailable) + } + if res.Error.Args["reason"] != "unplugged" || res.Error.Args["op"] != OpLoadpointSoCSet { + t.Fatalf("refusal args = %v, want the reason and the op named", res.Error.Args) + } + if lp.socKnown { + t.Fatal("an unplugged car's level was recorded anyway") + } + if rec.has(MsgPlan) { + t.Fatal("a refused correction pushed a plan") + } +} + +// The read-back decides the state. A difference the size of float rounding is +// the same level; a different level means something else re-anchored the car +// between the write and the read, and the result says so. +func TestASoCReadBackIsJudgedWithinHalfAPermille(t *testing.T) { + for _, c := range []struct { + name string + readBack float64 + want string + }{ + {"rounding", 0.6203, CmdApplied}, + {"a rival writer", 0.70, CmdSuperseded}, + } { + t.Run(c.name, func(t *testing.T) { + h, lp, _, rec, _ := newEVRig(t) + subscribe(t, h, rec) + lp.onSoC = func() { lp.soc = c.readBack } + + deliver(t, h, MsgCmd, nil, cmdLoadpoint(OpLoadpointSoCSet, "cmd-soc-moved", socArgs(), 7, 200_000)) + + res := body[CmdResult](t, rec.only(t, MsgCmdResult)) + if res.State != c.want { + t.Fatalf("state = %q, want %s", res.State, c.want) + } + if res.Observed == nil || res.Observed.Value != c.readBack { + t.Fatalf("observed = %+v, want the level actually held", res.Observed) + } + }) + } +} + +// PV-only goes on and off through the same setter the HTTP target route +// uses, and the result reports the flag read back: 1 on, 0 off. +func TestLoadpointSurplusOnlySetReportsTheFlagReadBack(t *testing.T) { + h, lp, _, rec, _ := newEVRig(t) + subscribe(t, h, rec) + + deliver(t, h, MsgCmd, nil, cmdLoadpoint(OpLoadpointSurplusOnlySet, "cmd-pv-on", surplusArgs(true), 7, 200_000)) + if ack := body[CmdAck](t, rec.only(t, MsgCmdAck)); ack.LeaseID == "" { + t.Fatal("ack carried no lease") + } + res := body[CmdResult](t, rec.only(t, MsgCmdResult)) + if res.State != CmdApplied { + t.Fatalf("state = %q (%+v), want applied", res.State, res.Error) + } + if res.Observed == nil || res.Observed.Src != ObservedSrcCore || res.Observed.Value != 1 { + t.Fatalf("observed = %+v, want the flag read back on", res.Observed) + } + if !lp.surplusOnly || lp.surplusCalls != 1 { + t.Fatalf("flag = %v after %d calls", lp.surplusOnly, lp.surplusCalls) + } + rec.reset() + + deliver(t, h, MsgCmd, nil, cmdLoadpoint(OpLoadpointSurplusOnlySet, "cmd-pv-off", surplusArgs(false), 7, 200_000)) + res = body[CmdResult](t, rec.only(t, MsgCmdResult)) + if res.State != CmdApplied { + t.Fatalf("state = %q (%+v), want applied", res.State, res.Error) + } + if res.Observed == nil || res.Observed.Value != 0 { + t.Fatalf("observed = %+v, want the flag read back off", res.Observed) + } + if lp.surplusOnly || lp.surplusCalls != 2 { + t.Fatalf("flag = %v after %d calls", lp.surplusOnly, lp.surplusCalls) + } +} + +// A flag that moved back between the write and the read is reported as it is +// held, not as it was asked for. +func TestASurplusOnlyReadBackThatDisagreesIsSuperseded(t *testing.T) { + h, lp, _, rec, _ := newEVRig(t) + subscribe(t, h, rec) + lp.onSurplus = func() { lp.surplusOnly = true } + + deliver(t, h, MsgCmd, nil, cmdLoadpoint(OpLoadpointSurplusOnlySet, "cmd-pv-moved", surplusArgs(false), 7, 200_000)) + + res := body[CmdResult](t, rec.only(t, MsgCmdResult)) + if res.State != CmdSuperseded { + t.Fatalf("state = %q, want superseded", res.State) + } + if res.Observed == nil || res.Observed.Value != 1 { + t.Fatalf("observed = %+v, want the flag actually held", res.Observed) + } +} + +// Neither setting moves energy on its own — each is state the box holds, and +// the plan it reshapes still meets the dispatch gate — so a stale meter must +// not lock a user out of correcting the car or letting it use the grid. The +// same rule the mode has, held here by the two settings. +func TestAStaleMeterDoesNotBlockALoadpointSetting(t *testing.T) { + for _, c := range []struct { + op string + args map[string]any + }{ + {OpLoadpointSoCSet, socArgs()}, + {OpLoadpointSurplusOnlySet, surplusArgs(false)}, + } { + t.Run(c.op, func(t *testing.T) { + h, lp, box, rec, _ := newEVRig(t) + subscribe(t, h, rec) + box.snap.DispatchBlockedBy = []string{"meter.p1"} + + deliver(t, h, MsgCmd, nil, cmdLoadpoint(c.op, "cmd-setting", c.args, 7, 200_000)) + + res := body[CmdResult](t, rec.only(t, MsgCmdResult)) + if res.State != CmdApplied { + t.Fatalf("state = %q (%+v), want applied", res.State, res.Error) + } + if lp.socCalls+lp.surplusCalls != 1 { + t.Fatal("the setting never reached the manager") + } + }) + } +} diff --git a/go/internal/appproto/handler.go b/go/internal/appproto/handler.go index 1b8934d6c..c18fac345 100644 --- a/go/internal/appproto/handler.go +++ b/go/internal/appproto/handler.go @@ -870,6 +870,10 @@ func (h *Handler) onCmd(ctx context.Context, env Envelope) error { return h.loadpointHold(cmd, uptimeMs) case OpLoadpointBoost: return h.loadpointBoost(cmd, uptimeMs) + case OpLoadpointSoCSet: + return h.loadpointSoCSet(cmd, uptimeMs) + case OpLoadpointSurplusOnlySet: + return h.loadpointSurplusOnlySet(cmd, uptimeMs) default: return fmt.Errorf("appproto: op %q is in the table but has no handler", cmd.Op) } diff --git a/go/internal/appproto/messages.go b/go/internal/appproto/messages.go index 0d93ed153..7ae7aad96 100644 --- a/go/internal/appproto/messages.go +++ b/go/internal/appproto/messages.go @@ -45,6 +45,14 @@ const ( // lease, or withdraws it with `cancel`. Same lease and same preflight // as the HTTP route. OpLoadpointBoost = "loadpoint.boost" + // OpLoadpointSoCSet corrects the car's current state of charge, a + // fraction in [0,1], the way POST /api/loadpoints/{id}/soc does: the + // session's estimate is re-anchored and the plan remade from it. + OpLoadpointSoCSet = "loadpoint.soc.set" + // OpLoadpointSurplusOnlySet turns PV-only charging on or off for one + // loadpoint — the `surplus_only` field of the HTTP target route, and + // only that field: the target level and its deadline have no command. + OpLoadpointSurplusOnlySet = "loadpoint.surplus_only.set" ) // BoxMode is what the box is able to offer this session. diff --git a/go/internal/appproto/ports.go b/go/internal/appproto/ports.go index 61601201f..9a0d84046 100644 --- a/go/internal/appproto/ports.go +++ b/go/internal/appproto/ports.go @@ -161,6 +161,23 @@ type Loadpoints interface { CancelBoost(id string, now time.Time) // ObservedBoost is the boost as the box reports it right now. ObservedBoost(id string, now time.Time) loadpoint.BatteryBoostStatus + // SetSoC re-anchors the car's current state of charge, a fraction in + // [0,1]. False when the loadpoint is not plugged in — there is no + // session to correct — which is the HTTP route's 409. The + // implementation replans before returning, as the HTTP route does, so + // the plan pushed after the result is already drawn from the corrected + // level. + SetSoC(id string, soc float64) bool + // ObservedSoC is the state of charge the box holds for the car now. + ObservedSoC(id string) (soc float64, ok bool) + // SetSurplusOnly turns PV-only charging on or off and reports the value + // it replaced. The implementation carries the replan the HTTP target + // route does — synchronous when the flag turns off, because the car may + // now draw from the grid and the plan must say so before the app reads + // it back. + SetSurplusOnly(id string, v bool) (prev bool, ok bool) + // ObservedSurplusOnly is the flag as the box holds it now. + ObservedSurplusOnly(id string) (v bool, ok bool) } // PlanReader hands over the planner's current output.