Skip to content
Merged
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/ops-soc-surplus.md
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.
2 changes: 2 additions & 0 deletions contract/registry.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
44 changes: 44 additions & 0 deletions go/cmd/ftw/app_link.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Comment on lines +427 to +428

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Include loadpoint mutations in the command revision

When either new operation changes manager state, the revision in subsequent snapshots does not move: appSite.Snapshot derives ControlRev exclusively from revision.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-change expect.rev and 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 👍 / 👎.

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Finish the PV-only replan before pushing it

When surplus_only changes from false to true, the replan runs in a goroutine, but loadpointSurplusOnlySet immediately calls settleAndReport, which sends Plans.Latest() before that replan normally finishes. There is no plan-completion push elsewhere—plans are sent only here or in response to plan.get—so the app can receive and retain the pre-toggle plan even though the result says the setting was applied. Complete the replan synchronously or arrange to push the newly completed plan.

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
Expand Down
60 changes: 60 additions & 0 deletions go/cmd/ftw/app_link_test.go
Original file line number Diff line number Diff line change
@@ -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"
)

Expand Down Expand Up @@ -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")
}
}
7 changes: 5 additions & 2 deletions go/internal/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
16 changes: 16 additions & 0 deletions go/internal/api/api_passthrough_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down
9 changes: 9 additions & 0 deletions go/internal/appproto/command.go
Original file line number Diff line number Diff line change
Expand Up @@ -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},
}
}

Expand Down
4 changes: 4 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.

132 changes: 132 additions & 0 deletions go/internal/appproto/ev.go
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject non-finite charge levels

When a CBOR client sends soc: NaN, both range comparisons evaluate false, so the command reaches the manager instead of being rejected. The real manager's ClampFraction converts that NaN to zero, and the subsequent math.Abs(observed-soc) > socTolerance comparison also evaluates false, causing the box to report the command as applied after resetting the car's estimated charge to empty and replanning from it. Validate with units.ValidFraction or an explicit finite check before accepting the command.

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},
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Clamped SoC read-back marked superseded

Medium Severity

After a successful SetSoC, the result compares the requested fraction to ObservedSoC and treats any gap above half a permille as a rival writer. The manager itself can create a much larger gap: it clamps a negative plugin anchor to 0, so the stored level becomes session delivered energy over capacity rather than the value just written.

Fix in Cursor Fix in Web

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
}
Loading