-
Notifications
You must be signed in to change notification settings - Fork 10
feat(appproto): ops to set the car's charge level and PV-only #1069
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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") | ||
|
Comment on lines
+440
to
+442
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When Useful? React with 👍 / 👎. |
||
| } | ||
| } | ||
| 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 | ||
|
|
||
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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"]) | ||
|
Comment on lines
+334
to
+336
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a CBOR client sends Useful? React with 👍 / 👎. |
||
| } | ||
|
|
||
| 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}, | ||
| } | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Clamped SoC read-back marked supersededMedium Severity After a successful Reviewed by Cursor Bugbot for commit 736e277. Configure here. |
||
| 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 | ||
| } | ||


There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When either new operation changes manager state, the revision in subsequent snapshots does not move:
appSite.SnapshotderivesControlRevexclusively fromrevision.Observe(a.ctrl), while these writes touch only the loadpoint manager. Consequently, after one client changes SoC or PV-only state, another client can submit a command using the pre-changeexpect.revand pass the conflict check, defeating the protocol's optimistic-concurrency guard for these operations. Incorporate the relevant loadpoint state into the shared revision or explicitly advance it on these mutations.Useful? React with 👍 / 👎.