From 1b11198aff28dd64c25265eea360ea18a8eae139 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Wed, 14 Oct 2026 19:31:27 +0200 Subject: [PATCH] feat(appproto): the plan carries the battery's planned level per slot Co-Authored-By: Claude Opus 5.5 --- .changeset/plan-battery-level.md | 5 +++++ go/internal/appproto/plan.go | 13 +++++++++++-- go/internal/appproto/plan_test.go | 25 +++++++++++++++++++++++++ go/protocol/messages.go | 4 ++++ 4 files changed, 45 insertions(+), 2 deletions(-) create mode 100644 .changeset/plan-battery-level.md diff --git a/.changeset/plan-battery-level.md b/.changeset/plan-battery-level.md new file mode 100644 index 000000000..213e81e63 --- /dev/null +++ b/.changeset/plan-battery-level.md @@ -0,0 +1,5 @@ +--- +"ftw": minor +--- + +The plan an app reads (`plan.get` and the plan pushed after a command) now carries the battery's planned level at the end of each slot, `socPermille`, in thousandths: the same name and unit as the FTW Cloud plan class. A phone can then say where the battery ends up, not only what it does. A level the planner did not give as a number is left out. Older apps ignore the field. diff --git a/go/internal/appproto/plan.go b/go/internal/appproto/plan.go index 9e30b16fb..9cf3a1a23 100644 --- a/go/internal/appproto/plan.go +++ b/go/internal/appproto/plan.go @@ -5,6 +5,7 @@ import ( "time" "github.com/srcfl/ftw/go/internal/mpc" + "github.com/srcfl/ftw/go/internal/units" ) // planFrom projects the planner's output onto the wire. @@ -39,14 +40,22 @@ func planFrom(p *mpc.Plan, rev uint64, ceilingW *int64, uptimeMs int64, now time for _, a := range p.Actions { price := int64(math.Round(a.PriceOre)) - out.Slots = append(out.Slots, PlanSlot{ + slot := PlanSlot{ StartMs: a.ExecutionStart(), DurationMs: int64(math.Round(a.DurationHours() * 3600000)), BatteryW: roundW(a.BatteryW), GridW: roundW(a.GridW), PriceMinor: &price, Reason: reasonCode(a, mean, minSoC, ceilingW), - }) + } + // The level the plan leads to, so a phone can say where the battery + // ends up and not only what it does. Left out rather than guessed + // when the planner's number is not a level. + if units.ValidFraction(a.SoC) { + soc := units.PermilleFromFraction(a.SoC) + slot.SocPermille = &soc + } + out.Slots = append(out.Slots, slot) } return out diff --git a/go/internal/appproto/plan_test.go b/go/internal/appproto/plan_test.go index edd3a0ba2..30e565acf 100644 --- a/go/internal/appproto/plan_test.go +++ b/go/internal/appproto/plan_test.go @@ -1,6 +1,7 @@ package appproto import ( + "math" "testing" "time" @@ -55,6 +56,30 @@ func TestPlanGetAnswersOnTheBulkLaneAndEchoesTheRequestId(t *testing.T) { } } +// Each slot carries the battery's planned level at its end, in thousandths, +// so a phone can say where the plan leads and not only what it does. A +// number that is not a level is left out, never sent as a guess. +func TestPlanCarriesTheBatteryLevelAtEachSlotsEnd(t *testing.T) { + h, box, rec, clock := newRig(t) + subscribe(t, h, rec) + plan := samplePlan(clock.now) + plan.Actions[3].SoC = math.NaN() + box.plan = plan + + deliver(t, h, MsgPlanGet, ptrU32(5), nil) + + p := body[Plan](t, rec.only(t, MsgPlan)) + want := []int64{400, 600, 450} + for i, w := range want { + if p.Slots[i].SocPermille == nil || *p.Slots[i].SocPermille != w { + t.Fatalf("slot %d socPermille = %v, want %d", i, p.Slots[i].SocPermille, w) + } + } + if p.Slots[3].SocPermille != nil { + t.Fatalf("a level that is not a number was sent: %d", *p.Slots[3].SocPermille) + } +} + // Reason codes are stable machine strings. The box has no idea what language // anyone reads, and the app owns every word the user sees. func TestPlanReasonsAreStableCodes(t *testing.T) { diff --git a/go/protocol/messages.go b/go/protocol/messages.go index 5d755a1c2..245c5f270 100644 --- a/go/protocol/messages.go +++ b/go/protocol/messages.go @@ -471,6 +471,10 @@ type PlanSlot struct { // slot's price is not known. PriceMinor *int64 `cbor:"priceMinor"` Reason PlanReason `cbor:"reason"` + // SocPermille is the battery's planned level at the end of the slot, in + // thousandths, absent when the planner gave none. The same name and unit + // as the FTW Cloud plan class. An older app ignores it. + SocPermille *int64 `cbor:"socPermille,omitempty"` } // Plan is what the box intends to do.