diff --git a/.changeset/plan-push-on-replan.md b/.changeset/plan-push-on-replan.md new file mode 100644 index 00000000..85ec9bc6 --- /dev/null +++ b/.changeset/plan-push-on-replan.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +Push the plan to every streaming phone the moment the box replans, with each charger's windows, plug state and shortfall on it. A car plugged in now shows its new plan in the app without the app asking again. Viewers, which may not read the plan, get nothing. diff --git a/go/cmd/ftw/app_link.go b/go/cmd/ftw/app_link.go index 4cd92892..8e028d33 100644 --- a/go/cmd/ftw/app_link.go +++ b/go/cmd/ftw/app_link.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "errors" + "hash/fnv" "log/slog" "math" "net/http" @@ -561,6 +562,68 @@ func (a *appPlans) CeilingW() *int64 { return &ceiling } +// appPlanChargers is each charger's share of the plan, read from the same plan +// snapshot and the same windows GET /api/loadpoints serves. Keyed by the +// charger; CarID stays empty until Core keeps cars. +type appPlanChargers struct { + planner *mpc.Service + mgr *loadpoint.Manager + ctrl *loadpoint.Controller +} + +func (a *appPlanChargers) PlanChargers(now time.Time) []appproto.PlanCharger { + snap := a.planner.PlanSnapshot() + // While a newer plan is being made the box goes on acting on the one it + // has, so its windows stay, marked pending. The HTTP route empties them. + acting := snap + if snap.Pending && !snap.Unproven { + acting.Outdated = false + } + states := a.mgr.States() + out := make([]appproto.PlanCharger, 0, len(states)) + for _, st := range states { + ch := appproto.PlanCharger{ + ID: st.ID, PluggedIn: st.PluggedIn, + Pending: snap.Pending, Outdated: snap.Outdated, + Windows: []appproto.PlanChargerWindow{}, + GoalRev: goalRev(st), + } + if a.ctrl != nil && !acting.Outdated { + ch.GridDeferred = a.ctrl.GridDeferred(st.ID) + } + windows, totalWh := acting.LoadpointPlanWindows(st.ID, now, api.MaxPlanWindows) + for _, w := range windows { + ch.Windows = append(ch.Windows, appproto.PlanChargerWindow{ + StartMs: w.Start.UnixMilli(), EndMs: w.End.UnixMilli(), Wh: int64(math.Round(w.EnergyWh)), + }) + } + ch.TotalWh = int64(math.Round(totalWh)) + if snap.Plan != nil && !snap.Pending && !snap.Outdated { + if wh, ok := snap.Plan.LoadpointShortfallWh[st.ID]; ok && !math.IsNaN(wh) && !math.IsInf(wh, 0) { + short := int64(math.Round(max(0, wh))) + ch.ShortfallWh = &short + } + } + out = append(out, ch) + } + return out +} + +// goalRev changes when a charger's goal, ready time, schedule or solar-only +// choice changes, wherever that was done, so a phone knows to read it again. +func goalRev(st loadpoint.State) uint32 { + raw, _ := json.Marshal(struct { + Schedule loadpoint.Schedule + TargetSoC float64 + TargetTimeMs int64 + SurplusOnly bool + FinishAtLimit bool + }{st.Schedule, st.TargetSoC, st.TargetTime.UnixMilli(), st.SurplusOnly, st.FinishAtVehicleLimit}) + h := fnv.New32a() + _, _ = h.Write(raw) + return h.Sum32() +} + // startAppLink connects the box to the app relay when its loaded config leaves // the link enabled. Config defaults an omitted app_link section on. // @@ -630,6 +693,19 @@ func startAppLink( caps = append(caps, appproto.CapDerEv) } + // The plan reaches every streaming phone the moment the planner + // replaces it, with each charger's windows, so a car plugged in shows + // its plan without a read. + var planChargers appproto.PlanChargers + var planFeed *appproto.PlanFeed + if planner != nil { + planFeed = appproto.NewPlanFeed(appproto.PlanSettle) + planner.OnPlanChange(planFeed.Changed) + if lpMgr != nil { + planChargers = &appPlanChargers{planner: planner, mgr: lpMgr, ctrl: lpCtrl} + } + } + // Prices ride on the price service, and only when it has a zone and a // store to have fetched into. With either missing there is nothing to // serve, so the capability stays unsaid rather than advertised and empty. @@ -682,6 +758,8 @@ func startAppLink( History: history, Prices: priceReader, Loadpoints: loadpoints, + PlanChargers: planChargers, + PlanFeed: planFeed, API: gateway, Caller: caller, Grants: grants, diff --git a/go/cmd/ftw/app_link_plan_push_test.go b/go/cmd/ftw/app_link_plan_push_test.go new file mode 100644 index 00000000..ee174827 --- /dev/null +++ b/go/cmd/ftw/app_link_plan_push_test.go @@ -0,0 +1,286 @@ +package main + +import ( + "context" + "path/filepath" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/srcfl/ftw/go/internal/apiauth" + "github.com/srcfl/ftw/go/internal/appproto" + "github.com/srcfl/ftw/go/internal/appuplink" + "github.com/srcfl/ftw/go/internal/control" + "github.com/srcfl/ftw/go/internal/events" + "github.com/srcfl/ftw/go/internal/loadpoint" + "github.com/srcfl/ftw/go/internal/mpc" + "github.com/srcfl/ftw/go/internal/state" + "github.com/srcfl/ftw/go/internal/telemetry" +) + +// A car plugged in reaches a streaming phone as a plan with its windows in the +// time a solve takes, wired the way the box wires it: the real planner with +// its production reactive check (10 s, 30 s between reactive replans), the +// loadpoint manager on the event bus, the plug-in replan, the feed and the +// handler. The plug-in lands right after a replan, inside the gap the +// reactive check would wait out. A viewer gets nothing. +func TestAPlugInReachesAStreamingPhoneAsAPlan(t *testing.T) { + st, err := state.Open(filepath.Join(t.TempDir(), "state.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { st.Close() }) + now := time.Now().UTC().Truncate(time.Hour) + var prices []state.PricePoint + for i := range 6 { + prices = append(prices, state.PricePoint{ + Zone: "SE3", SlotTsMs: now.Add(time.Duration(i) * time.Hour).UnixMilli(), SlotLenMin: 60, + SpotOreKwh: float64(140 - i*20), TotalOreKwh: float64(190 - i*20), Source: "test", FetchedAtMs: now.UnixMilli(), + }) + } + if err := st.SavePrices(prices); err != nil { + t.Fatal(err) + } + + // A battery online with a fresh level, which the planner waits for. + tel := telemetry.NewStore() + soc := 0.5 + tel.Update("bat", telemetry.DerBattery, 0, &soc, nil) + tel.DriverHealthMut("bat").RecordSuccess() + svc := mpc.New(st, tel, "SE3", mpc.Params{ + Mode: mpc.ModeSelfConsumption, SoCLevels: 11, ActionLevels: 5, + CapacityWh: 10000, InitialSoC: 0.5, SoCMin: 0.1, SoCMax: 0.95, + MaxChargeW: 3000, MaxDischargeW: 3000, ChargeEfficiency: 0.95, DischargeEfficiency: 0.95, + }) + svc.Horizon = 5 * time.Hour + svc.BaseLoad = 500 + + mgr := loadpoint.NewManager() + mgr.Load([]loadpoint.Config{{ID: "easee", DriverName: "easee", VehicleCapacityWh: 60000, MinChargeW: 4100, MaxChargeW: 11000}}) + deadline := len(prices)*4 - 1 + svc.Loadpoints = func(int) []*mpc.LoadpointSpec { + var specs []*mpc.LoadpointSpec + for _, lp := range mgr.States() { + if lp.PluggedIn { + specs = append(specs, &mpc.LoadpointSpec{ + ID: lp.ID, CapacityWh: 60000, Levels: 11, SoCMax: 1, InitialSoC: 0.3, PluggedIn: true, + TargetSoC: 0.6, TargetSlotIdx: deadline, MaxChargeW: 11000, AllowedStepsW: []float64{0, 11000}, + }) + } + } + return specs + } + + // The charger's driver is healthy, so the manager confirms a cable, and + // its clock is the test's, so the cable's two seconds out pass at once. + bus := events.NewBus() + mgr.SetBus(bus) + replanOnPlugIn(bus, svc) + tel.DriverHealthMut("easee").RecordSuccess() + bus.Publish(events.HealthTick{Health: map[string]telemetry.DriverHealth{"easee": *tel.DriverHealth("easee")}, Now: time.Now()}) + var ahead atomic.Int64 + mgr.SetNowFn(func() time.Time { return time.Now().Add(time.Duration(ahead.Load())) }) + mgr.Observe("easee", false, 0, 0, false) + + feed := appproto.NewPlanFeed(appproto.PlanSettle) + svc.OnPlanChange(feed.Changed) + chargers := &appPlanChargers{planner: svc, mgr: mgr} + owner := streamingSession(t, chargers, feed, apiauth.RoleOwner) + viewer := streamingSession(t, chargers, feed, apiauth.RoleViewer) + + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(func() { cancel(); svc.Stop() }) + svc.Start(ctx) + first := owner.waitPlan(t, func(p appproto.Plan) bool { + return len(p.Chargers) == 1 && !p.Chargers[0].PluggedIn && !p.Chargers[0].Pending && p.Rev > 0 + }) + if len(first.Chargers[0].Windows) != 0 { + t.Fatalf("an unplugged car has windows: %+v", first.Chargers[0]) + } + + // The feed sends a change at once unless it pushed in the last settle; + // let the start-up plan's settle pass, as any quiet moment would. + time.Sleep(2 * appproto.PlanSettle) + ahead.Store(int64(3 * time.Second)) + plugged := time.Now() + mgr.Observe("easee", true, 0, 0, true) + cable := owner.waitPlan(t, func(p appproto.Plan) bool { return len(p.Chargers) == 1 && p.Chargers[0].PluggedIn }) + tookCable := time.Since(plugged) + got := owner.waitPlan(t, func(p appproto.Plan) bool { + return len(p.Chargers) == 1 && p.Chargers[0].PluggedIn && !p.Chargers[0].Pending && len(p.Chargers[0].Windows) > 0 + }) + took := time.Since(plugged) + t.Logf("plug-in to the cable on a phone: %v (pending %v); to the plan: %v, %d windows, %d Wh", + tookCable, cable.Chargers[0].Pending, took, len(got.Chargers[0].Windows), got.Chargers[0].TotalWh) + if tookCable > 200*time.Millisecond { + t.Fatalf("the cable reached the phone %v after the plug-in", tookCable) + } + if took > appproto.PlanSettle+time.Second { + t.Fatalf("the plan reached the phone %v after the plug-in", took) + } + if got.Rev <= first.Rev || got.Chargers[0].Outdated { + t.Fatalf("pushed rev %d after %d, car %+v", got.Rev, first.Rev, got.Chargers[0]) + } + + // A viewer's session is on the same feed; give it the settle to be sure. + time.Sleep(2 * appproto.PlanSettle) + if n := viewer.plans(t); len(n) != 0 { + t.Fatalf("a viewer got %d plans", len(n)) + } +} + +type pushedSession struct { + sent *sentFrames +} + +// streamingSession is one phone that said hello and subscribed. +func streamingSession(t *testing.T, chargers appproto.PlanChargers, feed *appproto.PlanFeed, role string) pushedSession { + t.Helper() + sent := &sentFrames{} + h, err := appproto.New(appproto.Config{ + Clock: appproto.SystemClock{StartedAt: time.Now(), Source: "ntp"}, + Site: emptySite{}, + // Not newAppBoxInfo: the box's zone is read once per process, and + // a test that sets TZ later must still be the first to read it. + Info: appBoxInfo{id: "ftw-0001", build: "test", tz: "UTC"}, + Modes: &appModes{}, + Plans: &appPlans{planner: chargers.(*appPlanChargers).planner, ctrl: &control.State{}, ctrlMu: &sync.Mutex{}}, + PlanChargers: chargers, + PlanFeed: feed, + Codec: appuplink.Codec(), + Sender: sent, + Caller: apiauth.Caller{Kind: apiauth.KindApp, Role: role, Scopes: appproto.ScopesForRole(role)}, + Grants: roleGrant(role), + Commands: appproto.NewCmdLog(), + }) + if err != nil { + t.Fatal(err) + } + t.Cleanup(h.Close) + for _, msg := range []struct { + t string + b any + }{ + {appproto.MsgHello, appproto.Hello{Proto: appproto.ProtoRange{Min: 0, Max: appproto.ProtoMax}}}, + {appproto.MsgSub, appproto.Sub{Bucket: 512, Hz: 1}}, + } { + b, err := appproto.Marshal(msg.b) + if err != nil { + t.Fatal(err) + } + frame, err := appuplink.Codec().EncodeFrame(appproto.Frame{Lane: appproto.LaneControl, Bucket: 512, Envelope: appproto.Envelope{T: msg.t, B: b}}) + if err != nil { + t.Fatal(err) + } + if err := h.Handle(context.Background(), frame); err != nil { + t.Fatal(err) + } + } + if !h.Subscribed() { + t.Fatal("not streaming") + } + return pushedSession{sent: sent} +} + +func (s pushedSession) plans(t *testing.T) []appproto.Plan { + t.Helper() + var out []appproto.Plan + for _, raw := range s.sent.all() { + f, err := appuplink.Codec().DecodeFrame(raw) + if err != nil { + t.Fatal(err) + } + if f.Envelope.T != appproto.MsgPlan { + continue + } + var p appproto.Plan + if err := appproto.Unmarshal(f.Envelope.B, &p); err != nil { + t.Fatal(err) + } + out = append(out, p) + } + return out +} + +func (s pushedSession) waitPlan(t *testing.T, match func(appproto.Plan) bool) appproto.Plan { + t.Helper() + until := time.Now().Add(10 * time.Second) + for time.Now().Before(until) { + for _, p := range s.plans(t) { + if match(p) { + return p + } + } + time.Sleep(time.Millisecond) + } + t.Fatalf("no matching plan in %d pushed", len(s.plans(t))) + return appproto.Plan{} +} + +type emptySite struct{} + +func (emptySite) Snapshot() appproto.Snapshot { return appproto.Snapshot{} } + +type roleGrant string + +func (r roleGrant) Grant() (appproto.Enrolment, bool) { + return appproto.Enrolment{Role: string(r), Epoch: 1}, true +} + +// While a newer plan is being made the box goes on acting on the plan it has, +// so a phone keeps its windows, marked pending, and no shortfall: a plan being +// remade says nothing about whether the car makes it. +func TestAPendingPlanKeepsTheWindowsTheBoxActsOn(t *testing.T) { + st, err := state.Open(filepath.Join(t.TempDir(), "state.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { st.Close() }) + now := time.Now().UTC().Truncate(15 * time.Minute) + if err := st.SavePrices([]state.PricePoint{{Zone: "SE4", SlotTsMs: now.UnixMilli(), SlotLenMin: 60, SpotOreKwh: 50, TotalOreKwh: 100}}); err != nil { + t.Fatal(err) + } + cloud := 0.0 + if err := st.SaveForecasts([]state.ForecastPoint{{SlotTsMs: now.UnixMilli(), SlotLenMin: 15, CloudCoverPct: &cloud, Source: "test", FetchedAtMs: now.UnixMilli()}}); err != nil { + t.Fatal(err) + } + svc := mpc.New(st, nil, "SE4", mpc.Params{Mode: mpc.ModeSelfConsumption, SoCLevels: 11, ActionLevels: 5, CapacityWh: 10000, InitialSoC: .5, SoCMin: .1, SoCMax: .95, MaxChargeW: 3000, MaxDischargeW: 3000, ChargeEfficiency: .95, DischargeEfficiency: .95}) + svc.Horizon = time.Hour + svc.InstallPlan(mpc.Plan{GeneratedAtMs: time.Now().UnixMilli(), Actions: []mpc.Action{{ + SlotStartMs: now.UnixMilli(), SlotLenMin: 60, LoadpointPowerW: map[string]float64{"easee": 6000}, + }}, LoadpointShortfallWh: map[string]float64{"easee": 1000}}, svc.Defaults, "easee") + mgr := loadpoint.NewManager() + mgr.Load([]loadpoint.Config{{ID: "easee", DriverName: "easee", VehicleCapacityWh: 60000}}) + mgr.Observe("easee", true, 0, 0, true) + chargers := &appPlanChargers{planner: svc, mgr: mgr} + + before := chargers.PlanChargers(time.Now())[0] + if before.Pending || len(before.Windows) != 1 || before.ShortfallWh == nil { + t.Fatalf("before: %+v", before) + } + + entered, release := make(chan struct{}), make(chan struct{}) + var once sync.Once + svc.PV = func(time.Time, float64) float64 { once.Do(func() { close(entered) }); <-release; return 0 } + t.Cleanup(func() { + select { + case <-release: + default: + close(release) + } + }) + svc.RequestReplan("test") + select { + case <-entered: + case <-time.After(5 * time.Second): + t.Fatal("the replan never reached the forecast") + } + during := chargers.PlanChargers(time.Now())[0] + if !during.Pending || len(during.Windows) != 1 || during.Windows[0] != before.Windows[0] || during.ShortfallWh != nil { + t.Fatalf("during: %+v", during) + } + if during.GoalRev != before.GoalRev { + t.Fatal("the goal moved with the plan") + } +} diff --git a/go/cmd/ftw/main.go b/go/cmd/ftw/main.go index d8994b0e..8c5d4caa 100644 --- a/go/cmd/ftw/main.go +++ b/go/cmd/ftw/main.go @@ -2689,6 +2689,7 @@ func main() { if lpMgr != nil { lpMgr.SetBus(bus) } + replanOnPlugIn(bus, mpcSvc) // Late-bind onto the Deps literal that was built earlier with a nil // notifSvc (the deps struct is assembled before this block runs). deps.Notifications = notifSvc diff --git a/go/cmd/ftw/plan_edges.go b/go/cmd/ftw/plan_edges.go new file mode 100644 index 00000000..33b5cf88 --- /dev/null +++ b/go/cmd/ftw/plan_edges.go @@ -0,0 +1,24 @@ +package main + +import ( + "github.com/srcfl/ftw/go/internal/events" + "github.com/srcfl/ftw/go/internal/mpc" +) + +// replanOnPlugIn asks for a new plan the moment a charger confirms a cable, +// past the reactive check and its gap, so a car plugged in gets its plan in +// the time a solve takes. The planner says when the plan turns pending and +// when the new one lands, and the plan feed takes both to the phones. +// +// The connection event already waits two seconds after an unplug, so a cable +// that flaps does not storm the optimizer, and a newer request replaces one +// still waiting. A hold enters no plan, and goals and schedules request their +// own replans where they are saved. +func replanOnPlugIn(bus *events.Bus, planner *mpc.Service) { + if bus == nil || planner == nil { + return + } + bus.Subscribe(events.KindChargingConnected, func(events.Event) { + planner.RequestReplan("loadpoint_plugged") + }) +} diff --git a/go/internal/api/api_loadpoint_plan.go b/go/internal/api/api_loadpoint_plan.go index 6c8db228..f7fafc46 100644 --- a/go/internal/api/api_loadpoint_plan.go +++ b/go/internal/api/api_loadpoint_plan.go @@ -42,7 +42,7 @@ func (s *Server) decorateLoadpointsWithPlan(states []loadpoint.State) { if s.deps.MPC == nil { continue } - windows, totalWh := snapshot.LoadpointPlanWindows(states[i].ID, now, maxPlanWindows) + windows, totalWh := snapshot.LoadpointPlanWindows(states[i].ID, now, MaxPlanWindows) if len(windows) == 0 { continue } @@ -61,7 +61,8 @@ func (s *Server) decorateLoadpointsWithPlan(states []loadpoint.State) { } } -// maxPlanWindows bounds the list a client gets. A 48 h horizon in 15 min -// slots cannot produce more than a few dozen contiguous windows; the cap -// keeps a pathological plan from bloating every /api/loadpoints poll. -const maxPlanWindows = 32 +// MaxPlanWindows bounds the list a client gets, here and on the plan the app +// session pushes. A 48 h horizon in 15 min slots cannot produce more than a +// few dozen contiguous windows; the cap keeps a pathological plan from +// bloating every /api/loadpoints poll. +const MaxPlanWindows = 32 diff --git a/go/internal/appproto/handler.go b/go/internal/appproto/handler.go index baa31e6e..0f8f68c3 100644 --- a/go/internal/appproto/handler.go +++ b/go/internal/appproto/handler.go @@ -54,6 +54,12 @@ type Config struct { // window. PlannerPrefs PlannerPrefs + // PlanChargers puts each charger's share on the plan, or nil on a box with + // no chargers. PlanFeed pushes the plan when the box replans, or nil for + // a box that pushes it only after a command. + PlanChargers PlanChargers + PlanFeed *PlanFeed + // 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. @@ -156,6 +162,12 @@ type Handler struct { lastSent map[string]int64 lastSources map[string]SourceWire lastBlocked []string + // planMu holds a plan from read to send, so a newer plan never lands + // before an older one. lastPlan is the hash of the plan this session + // last got, without its uptime, so a push repeats nothing. + planMu sync.Mutex + lastPlan [sha256.Size]byte + // lastRev is the controlRev this client was last told. A command carries // the rev it expected, so a client left holding a stale one would have // every command refused with no way to find out the new number — the @@ -229,7 +241,7 @@ func New(cfg Config) (*Handler, error) { } ctx, cancel := context.WithCancel(context.Background()) - return &Handler{ + h := &Handler{ cfg: cfg, log: cfg.Logger.With("component", "appproto"), modes: wireModes(control.ModeCatalog()), @@ -242,7 +254,13 @@ func New(cfg Config) (*Handler, error) { cadenceEvery: 1, cadenceLeft: 1, lastSent: map[string]int64{}, - }, nil + } + // An exchange sends nothing unasked; a watch reads the plan from FTW + // Cloud instead. + if cfg.PlanFeed != nil && !cfg.Exchange { + cfg.PlanFeed.join(h) + } + return h, nil } // Close ends the handler's own work when the session goes. @@ -253,6 +271,9 @@ func New(cfg Config) (*Handler, error) { // passthrough. func (h *Handler) Close() { h.cancel() + if h.cfg.PlanFeed != nil { + h.cfg.PlanFeed.leave(h) + } h.histMu.Lock() if h.histCancel != nil { h.histCancel() @@ -1100,7 +1121,13 @@ func (h *Handler) onPlanGet(id *uint32) error { return h.sendPlan(id) } -func (h *Handler) sendPlan(id *uint32) error { +func (h *Handler) sendPlan(id *uint32) error { return h.writePlan(id, false) } + +// writePlan reads the plan and sends it. With skipSame it sends nothing when +// this session already has this plan. +func (h *Handler) writePlan(id *uint32, skipSame bool) error { + h.planMu.Lock() + defer h.planMu.Unlock() body := planFrom( h.cfg.Plans.Latest(), h.cfg.Plans.Rev(), @@ -1108,14 +1135,28 @@ func (h *Handler) sendPlan(id *uint32) error { h.cfg.Clock.UptimeMs(), h.cfg.Clock.Now(), ) + if h.cfg.PlanChargers != nil { + body.Chargers = h.cfg.PlanChargers.PlanChargers(h.cfg.Clock.Now()) + } + same := body + same.UptimeMs = 0 + raw, err := Marshal(same) + if err != nil { + return err + } + key := sha256.Sum256(raw) + if skipSame && key == h.lastPlan { + return nil + } // The optimizer plans further than the wire carries: 193 fifteen-minute // slots encode to ~17 kB and the largest bulk bucket is 16 kB. Send the // nearest slots that fit and drop the far tail — the app shows twelve - // hours, and slots two days out are a guess about a guess. What must - // never happen is the alternative: an encode error here used to kill the - // whole session, which the app experienced as "the box hangs up whenever - // I open the plan". + // hours, and slots two days out are a guess about a guess. Then the far + // windows of each charger, for the same reason. What must never happen is + // the alternative: an encode error here used to kill the whole session, + // which the app experienced as "the box hangs up whenever I open the + // plan". for { env, err := newEnvelope(MsgPlan, id, body) if err != nil { @@ -1126,13 +1167,33 @@ func (h *Handler) sendPlan(id *uint32) error { return err } if bulkBucketFor(len(raw)) != 0 { - return h.send(Frame{Lane: LaneBulk, Bucket: bulkBucketFor(len(raw)), Envelope: env}) + if err := h.send(Frame{Lane: LaneBulk, Bucket: bulkBucketFor(len(raw)), Envelope: env}); err != nil { + return err + } + h.lastPlan = key + return nil } - if len(body.Slots) <= 1 { - return fmt.Errorf("appproto: a single plan slot does not fit any bulk bucket") + switch { + case len(body.Slots) > 1: + body.Slots = body.Slots[:len(body.Slots)*3/4] + case trimWindows(body.Chargers): + default: + return fmt.Errorf("appproto: a plan of one slot and one window per charger does not fit any bulk bucket") + } + } +} + +// trimWindows drops the far quarter of each charger's windows, keeping the +// next one, and reports whether anything went. +func trimWindows(chargers []PlanCharger) bool { + trimmed := false + for i := range chargers { + if n := len(chargers[i].Windows); n > 1 { + chargers[i].Windows = chargers[i].Windows[:max(1, n*3/4)] + trimmed = true } - body.Slots = body.Slots[:len(body.Slots)*3/4] } + return trimmed } // -------------------------------------------------------------------------- diff --git a/go/internal/appproto/plan_feed.go b/go/internal/appproto/plan_feed.go new file mode 100644 index 00000000..532161f2 --- /dev/null +++ b/go/internal/appproto/plan_feed.go @@ -0,0 +1,111 @@ +package appproto + +import ( + "sync" + "time" +) + +// PlanSettle is the quiet time after a push. Changes inside it are sent once, +// when it ends, so a burst of replans reaches a phone as the first change at +// once and one follow-up. +const PlanSettle = 100 * time.Millisecond + +// PlanFeed pushes the plan to every streaming session the moment what a plan +// reader sees moves: a replan starts and the plan is pending, a plan is +// published, or a replan fails. +// +// The first change goes out at once. Changes inside the settle that follows +// are sent once when it ends, and that send starts another settle. The feed +// runs no periodic timer, only that one wait after a push. One feed serves +// the whole box; each handler joins it when built and leaves when closed. +type PlanFeed struct { + settle time.Duration + + mu sync.Mutex + members map[*Handler]struct{} + // quiet is set from a push to the end of its settle; dirty says a + // change came meanwhile. + quiet bool + dirty bool +} + +// NewPlanFeed builds the box's feed. +func NewPlanFeed(settle time.Duration) *PlanFeed { + return &PlanFeed{settle: settle, members: map[*Handler]struct{}{}} +} + +// Changed is the planner's word that what a plan reader sees has moved. +func (f *PlanFeed) Changed() { + f.mu.Lock() + if f.quiet { + f.dirty = true + f.mu.Unlock() + return + } + f.quiet = true + f.mu.Unlock() + f.push() + time.AfterFunc(f.settle, f.settled) +} + +func (f *PlanFeed) settled() { + f.mu.Lock() + if !f.dirty { + f.quiet = false + f.mu.Unlock() + return + } + f.dirty = false + f.mu.Unlock() + f.push() + time.AfterFunc(f.settle, f.settled) +} + +// push hands each session its plan on a goroutine of its own, so neither the +// planner nor another phone waits on a slow socket. A session sends one plan +// at a time and reads it when it sends, so the newest plan lands last. +func (f *PlanFeed) push() { + f.mu.Lock() + members := make([]*Handler, 0, len(f.members)) + for h := range f.members { + members = append(members, h) + } + f.mu.Unlock() + for _, h := range members { + go h.pushPlan() + } +} + +func (f *PlanFeed) join(h *Handler) { + f.mu.Lock() + defer f.mu.Unlock() + f.members[h] = struct{}{} +} + +func (f *PlanFeed) leave(h *Handler) { + f.mu.Lock() + defer f.mu.Unlock() + delete(f.members, h) +} + +// pushPlan sends the plan unasked to a phone that streams and may read it, +// unless that phone already has this very plan. The role is read as it +// stands now, the way every privileged request reads it: a phone demoted to +// a viewer stops getting plans at the next one. +func (h *Handler) pushPlan() { + h.mu.Lock() + streaming := h.subscribed + h.mu.Unlock() + if !streaming || h.cfg.Info.Boot() != nil { + return + } + caller, ok := h.liveCaller() + if !ok || !caller.Scopes.Has(ScopePlanRead) { + return + } + // A failed send is the session's carrier going away; the carrier ends + // the session, and this handler leaves the feed when it closes. + if err := h.writePlan(nil, true); err != nil { + h.log.Debug("plan push not sent", "err", err) + } +} diff --git a/go/internal/appproto/plan_feed_test.go b/go/internal/appproto/plan_feed_test.go new file mode 100644 index 00000000..5e489985 --- /dev/null +++ b/go/internal/appproto/plan_feed_test.go @@ -0,0 +1,339 @@ +package appproto + +import ( + "encoding/hex" + "fmt" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/srcfl/ftw/go/internal/apiauth" + "github.com/srcfl/ftw/go/internal/mpc" +) + +const testSettle = 20 * time.Millisecond + +// fakeChargers is each charger's share of the plan as a test sets it. +type fakeChargers struct{ chargers atomic.Pointer[[]PlanCharger] } + +func (f *fakeChargers) set(c []PlanCharger) { f.chargers.Store(&c) } +func (f *fakeChargers) PlanChargers(time.Time) []PlanCharger { return *f.chargers.Load() } +func (b *fakeBox) replan(rev uint64, startMs int64) { b.plan, b.planRev = planAt(startMs), rev } + +func planAt(startMs int64) *mpc.Plan { + return &mpc.Plan{ + GeneratedAtMs: startMs, + Actions: []mpc.Action{ + {SlotStartMs: startMs, SlotLenMin: 15, BatteryW: 2000, GridW: 2400, PriceOre: 40, SoC: 0.5}, + {SlotStartMs: startMs + 900_000, SlotLenMin: 15, BatteryW: 0, GridW: 400, PriceOre: 90, SoC: 0.55}, + }, + } +} + +// newFeedRig is a phone's session on a box with a charger and a feed. +func newFeedRig(t *testing.T, feed *PlanFeed, caller apiauth.Caller, grants *fakeGrants, exchange bool) (*Handler, *fakeBox, *recorder, *fakeChargers) { + t.Helper() + clock := &fakeClock{uptimeMs: 60_000, now: time.UnixMilli(1_760_000_000_000)} + box := &fakeBox{identity: Identity{ID: "ftw-0001", Build: "test", TZ: "Europe/Stockholm"}} + box.replan(1, clock.now.UnixMilli()) + chargers := &fakeChargers{} + chargers.set([]PlanCharger{{ID: "easee", Windows: []PlanChargerWindow{}}}) + rec := &recorder{} + h, err := New(Config{ + Clock: clock, Site: box, Info: box, Modes: box, Plans: box, + PlanChargers: chargers, PlanFeed: feed, Exchange: exchange, + Codec: testCodec{}, Sender: rec, Caller: caller, Grants: grants, Commands: NewCmdLog(), + }) + if err != nil { + t.Fatalf("New: %v", err) + } + t.Cleanup(h.Close) + return h, box, rec, chargers +} + +func plans(rec *recorder) []sent { + var out []sent + for _, f := range rec.snapshot() { + if f.env.T == MsgPlan { + out = append(out, f) + } + } + return out +} + +// waitPlans waits for n pushed plans, then a while longer, so a test also +// sees a push that should not have come. +func waitPlans(t *testing.T, rec *recorder, n int) []sent { + t.Helper() + until := time.Now().Add(2 * time.Second) + for len(plans(rec)) < n && time.Now().Before(until) { + time.Sleep(time.Millisecond) + } + return settled(rec) +} + +// settled is every plan pushed once the feed has had five settles to push. +func settled(rec *recorder) []sent { + time.Sleep(5 * testSettle) + return plans(rec) +} + +func TestAReplanPushesThePlanWithTheChargerToAStreamingPhone(t *testing.T) { + feed := NewPlanFeed(testSettle) + h, box, rec, chargers := newFeedRig(t, feed, ownerCaller(), newGrants(), false) + subscribe(t, h, rec) + + // A car is plugged in and the planner publishes a plan that charges it. + box.replan(2, box.plan.GeneratedAtMs+60_000) + chargers.set([]PlanCharger{{ID: "easee", PluggedIn: true, Windows: []PlanChargerWindow{{StartMs: 1, EndMs: 2, Wh: 7400}}, TotalWh: 7400}}) + start := time.Now() + feed.Changed() + + got := waitPlans(t, rec, 1) + if len(got) != 1 { + t.Fatalf("want one pushed plan, got %d (%s)", len(got), rec.types()) + } + if took := time.Since(start); took > testSettle+time.Second { + t.Fatalf("plan pushed after %v", took) + } + if got[0].env.ID != nil { + t.Fatal("a pushed plan answers no request") + } + if got[0].lane != LaneBulk { + t.Fatalf("plan on lane %d", got[0].lane) + } + p := body[Plan](t, got[0]) + if p.Rev != 2 || len(p.Slots) != 2 { + t.Fatalf("plan rev %d with %d slots", p.Rev, len(p.Slots)) + } + if len(p.Chargers) != 1 || !p.Chargers[0].PluggedIn || len(p.Chargers[0].Windows) != 1 || p.Chargers[0].Windows[0].Wh != 7400 { + t.Fatalf("chargers %+v", p.Chargers) + } +} + +// The first change goes out at once; a burst inside the settle that follows +// is one more push, of the newest plan. +func TestTheFirstChangeGoesAtOnceAndABurstIsOneFollowUp(t *testing.T) { + feed := NewPlanFeed(time.Second) + h, box, rec, _ := newFeedRig(t, feed, ownerCaller(), newGrants(), false) + subscribe(t, h, rec) + + start := time.Now() + box.replan(2, box.plan.GeneratedAtMs+60_000) + feed.Changed() + until := time.Now().Add(500 * time.Millisecond) + for len(plans(rec)) == 0 && time.Now().Before(until) { + time.Sleep(time.Millisecond) + } + if took := time.Since(start); len(plans(rec)) != 1 || took > 200*time.Millisecond { + t.Fatalf("first push after %v: %d plans", took, len(plans(rec))) + } + for rev := uint64(3); rev <= 5; rev++ { + box.replan(rev, box.plan.GeneratedAtMs+60_000) + feed.Changed() + } + time.Sleep(2500 * time.Millisecond) + got := plans(rec) + if len(got) != 2 { + t.Fatalf("want the first and one follow-up, got %d", len(got)) + } + if p := body[Plan](t, got[1]); p.Rev != 5 { + t.Fatalf("follow-up rev %d, want the newest", p.Rev) + } +} + +// A plan this phone already has is not sent again: a failed replan, then +// another, says the same thing. +func TestTheSamePlanIsNotPushedTwice(t *testing.T) { + feed := NewPlanFeed(testSettle) + h, box, rec, _ := newFeedRig(t, feed, ownerCaller(), newGrants(), false) + subscribe(t, h, rec) + + box.replan(2, box.plan.GeneratedAtMs+60_000) + feed.Changed() + waitPlans(t, rec, 1) + feed.Changed() + if got := settled(rec); len(got) != 1 { + t.Fatalf("the same plan went %d times", len(got)) + } + // A plan.get is answered whatever the phone has. + deliver(t, h, MsgPlanGet, ptrU32(9), nil) + if got := plans(rec); len(got) != 2 { + t.Fatalf("plan.get answered %d times", len(got)-1) + } +} + +// Pushes race on goroutines of their own; the newest plan still lands last. +func TestTheNewestPlanLandsLast(t *testing.T) { + feed := NewPlanFeed(time.Millisecond) + h, box, rec, _ := newFeedRig(t, feed, ownerCaller(), newGrants(), false) + subscribe(t, h, rec) + + var plansMu sync.Mutex + h.cfg.Plans = lockedPlans{box: box, mu: &plansMu} + var wg sync.WaitGroup + for rev := uint64(2); rev <= 60; rev++ { + plansMu.Lock() + box.replan(rev, box.plan.GeneratedAtMs+60_000) + plansMu.Unlock() + wg.Add(1) + go func() { defer wg.Done(); h.pushPlan() }() + } + wg.Wait() + got := plans(rec) + if p := body[Plan](t, got[len(got)-1]); p.Rev != 60 { + t.Fatalf("last plan on the wire is rev %d", p.Rev) + } +} + +// lockedPlans reads the fake box's plan under the lock the test writes it with. +type lockedPlans struct { + box *fakeBox + mu *sync.Mutex +} + +func (l lockedPlans) Latest() *mpc.Plan { l.mu.Lock(); defer l.mu.Unlock(); return l.box.plan } +func (l lockedPlans) Rev() uint64 { l.mu.Lock(); defer l.mu.Unlock(); return l.box.planRev } +func (l lockedPlans) CeilingW() *int64 { return nil } + +func TestAPhoneWithoutThePlanScopeGetsNoPush(t *testing.T) { + feed := NewPlanFeed(testSettle) + h, _, rec, _ := newFeedRig(t, feed, viewerCaller(), newViewerGrants(), false) + subscribe(t, h, rec) + + feed.Changed() + if got := settled(rec); len(got) != 0 { + t.Fatalf("a viewer got %d plans", len(got)) + } +} + +func TestADemotedPhoneStopsGettingPushes(t *testing.T) { + feed := NewPlanFeed(testSettle) + grants := newGrants() + h, _, rec, _ := newFeedRig(t, feed, ownerCaller(), grants, false) + subscribe(t, h, rec) + + grants.setRole(apiauth.RoleViewer) + feed.Changed() + if got := settled(rec); len(got) != 0 { + t.Fatalf("a demoted phone got %d plans", len(got)) + } +} + +func TestNoPushBeforeTheStreamOrAfterTheSessionCloses(t *testing.T) { + feed := NewPlanFeed(testSettle) + h, _, rec, _ := newFeedRig(t, feed, ownerCaller(), newGrants(), false) + + feed.Changed() + if got := settled(rec); len(got) != 0 { + t.Fatalf("pushed %d plans before sub", len(got)) + } + + subscribe(t, h, rec) + h.Close() + feed.Changed() + if got := settled(rec); len(got) != 0 { + t.Fatalf("pushed %d plans after close", len(got)) + } +} + +func TestAWatchExchangeIsNotOnTheFeed(t *testing.T) { + feed := NewPlanFeed(testSettle) + newFeedRig(t, feed, ownerCaller(), newGrants(), true) + feed.mu.Lock() + defer feed.mu.Unlock() + if len(feed.members) != 0 { + t.Fatal("an exchange joined the feed") + } +} + +// The far tail of a long plan gives way to the chargers, never the session. +func TestALongPlanWithTwoChargersStillFitsABulkFrame(t *testing.T) { + feed := NewPlanFeed(testSettle) + h, box, rec, chargers := newFeedRig(t, feed, ownerCaller(), newGrants(), false) + subscribe(t, h, rec) + + start := box.plan.GeneratedAtMs + long := &mpc.Plan{GeneratedAtMs: start} + for i := range 193 { + long.Actions = append(long.Actions, mpc.Action{SlotStartMs: start + int64(i)*900_000, SlotLenMin: 15, BatteryW: -2500, GridW: -1200, PriceOre: 123.4, SoC: 0.5}) + } + box.plan, box.planRev = long, 9 + var two []PlanCharger + for _, id := range []string{"easee", "zaptec-garage"} { + ch := PlanCharger{ID: id, PluggedIn: true, TotalWh: 99_999} + for i := range 32 { + ch.Windows = append(ch.Windows, PlanChargerWindow{StartMs: start + int64(i)*1_800_000, EndMs: start + int64(i)*1_800_000 + 900_000, Wh: 2_750}) + } + short := int64(12_000) + ch.ShortfallWh = &short + two = append(two, ch) + } + chargers.set(two) + feed.Changed() + + got := waitPlans(t, rec, 1) + if len(got) != 1 { + t.Fatalf("want one push, got %d (%s)", len(got), rec.types()) + } + p := body[Plan](t, got[0]) + if len(p.Chargers) != 2 || len(p.Chargers[1].Windows) != 32 || *p.Chargers[1].ShortfallWh != 12_000 { + t.Fatalf("chargers cut: %d", len(p.Chargers)) + } + if len(p.Slots) < 96 { + t.Fatalf("only %d slots left", len(p.Slots)) + } +} + +// Many chargers can outgrow the largest frame even with one slot left: the +// far windows go too, never the session. +func TestManyChargersGiveUpTheirFarWindowsBeforeTheSession(t *testing.T) { + feed := NewPlanFeed(testSettle) + h, box, rec, chargers := newFeedRig(t, feed, ownerCaller(), newGrants(), false) + subscribe(t, h, rec) + + start := box.plan.GeneratedAtMs + var many []PlanCharger + for i := range 24 { + ch := PlanCharger{ID: fmt.Sprintf("charger-%02d", i), CarID: "0123456789abcdef0123456789abcdef", PluggedIn: true, TotalWh: 99_999} + for j := range 32 { + ch.Windows = append(ch.Windows, PlanChargerWindow{StartMs: start + int64(j)*1_800_000, EndMs: start + int64(j)*1_800_000 + 900_000, Wh: 2_750}) + } + many = append(many, ch) + } + chargers.set(many) + deliver(t, h, MsgPlanGet, ptrU32(4), nil) + got := plans(rec) + if len(got) != 1 { + t.Fatalf("plan.get answered %d times (%s)", len(got), rec.types()) + } + p := body[Plan](t, got[0]) + if len(p.Chargers) != 24 || len(p.Chargers[0].Windows) == 0 || len(p.Chargers[0].Windows) == 32 { + t.Fatalf("%d chargers, first with %d windows", len(p.Chargers), len(p.Chargers[0].Windows)) + } +} + +// planChargersVector is a plan with two chargers as the box encodes it. The same hex +// is decoded in srcfl/ftw-app's PlanPushTests, so a change here is a change +// to the apps' wire. +const planChargersVector = "a6637265760765736c6f747381a765677269645719012c66726561736f6e70657870656e736976655f696d706f72746773746172744d731b000001a131ea48806862617474657279573904af6a6475726174696f6e4d731a000dbba06a70726963654d696e6f7218576b736f635065726d696c6c65190280657374616c65f4686365696c696e6757f668636861726765727382a962696465656173656567676f616c5265761acafebabe6770656e64696e67f467746f74616c5768191ce86777696e646f777381a3627768191ce865656e644d731b000001a1325825806773746172744d731b000001a131ea4880686f75746461746564f469706c7567676564496ef56b73686f727466616c6c57681904b06c677269644465666572726564f5a9626964666761726167656563617249647820303132333435363738396162636465663031323334353637383961626364656667676f616c526576076770656e64696e67f567746f74616c5768006777696e646f777380686f75746461746564f569706c7567676564496ef46c677269644465666572726564f468757074696d654d731a0036ee80" + +func TestThePlanWithChargersVector(t *testing.T) { + short, price, soc := int64(1200), int64(87), int64(640) + p := Plan{ + Rev: 7, UptimeMs: 3_600_000, + Slots: []PlanSlot{{StartMs: 1_791_838_800_000, DurationMs: 900_000, BatteryW: -1200, GridW: 300, PriceMinor: &price, Reason: ReasonExpensiveImport, SocPermille: &soc}}, + Chargers: []PlanCharger{ + {ID: "easee", PluggedIn: true, GridDeferred: true, GoalRev: 3_405_691_582, Windows: []PlanChargerWindow{{StartMs: 1_791_838_800_000, EndMs: 1_791_846_000_000, Wh: 7400}}, TotalWh: 7400, ShortfallWh: &short}, + {ID: "garage", CarID: "0123456789abcdef0123456789abcdef", Pending: true, Outdated: true, GoalRev: 7, Windows: []PlanChargerWindow{}}, + }, + } + b, err := Marshal(p) + if err != nil { + t.Fatal(err) + } + if got := hex.EncodeToString(b); got != planChargersVector { + t.Fatalf("plan bytes moved:\n got %s\nwant %s", got, planChargersVector) + } +} diff --git a/go/internal/appproto/ports.go b/go/internal/appproto/ports.go index 4f092c94..ee696d6e 100644 --- a/go/internal/appproto/ports.go +++ b/go/internal/appproto/ports.go @@ -235,6 +235,12 @@ type PlanReader interface { CeilingW() *int64 } +// PlanChargers is each charger's share of the plan as the box holds it now, read +// the way GET /api/loadpoints reads it, so a pushed plan and a read agree. +type PlanChargers interface { + PlanChargers(now time.Time) []PlanCharger +} + // PricePoint is one stored price slot as the box holds it. // // Still float, and still per kWh, because that is what the store keeps. The diff --git a/go/internal/appproto/protocol.go b/go/internal/appproto/protocol.go index 66261447..c79562eb 100644 --- a/go/internal/appproto/protocol.go +++ b/go/internal/appproto/protocol.go @@ -67,50 +67,52 @@ func bulkBucketFor(payloadBytes int) int { return protocol.BulkBucketFor(payload // Messages. type ( - BoxMode = protocol.BoxMode - Hello = protocol.Hello - ProtoRange = protocol.ProtoRange - AppInfo = protocol.AppInfo - HelloOK = protocol.HelloOK - BoxInfo = protocol.BoxInfo - Clock = protocol.Clock - ModeInfo = protocol.ModeInfo - BootProgress = protocol.BootProgress - Sub = protocol.Sub - SourceWire = protocol.SourceWire - FieldDef = protocol.FieldDef - Snap = protocol.Snap - Delta = protocol.Delta - Tick = protocol.Tick - Guard = protocol.Guard - Cmd = protocol.Cmd - Expect = protocol.Expect - CmdAck = protocol.CmdAck - CmdResult = protocol.CmdResult - Observed = protocol.Observed - PlanReason = protocol.PlanReason - PlanSlot = protocol.PlanSlot - Plan = protocol.Plan - PriceGet = protocol.PriceGet - PriceSlot = protocol.PriceSlot - Price = protocol.Price - HistQuery = protocol.HistQuery - HistTileRef = protocol.HistTileRef - HistChunk = protocol.HistChunk - HistEnd = protocol.HistEnd - HistGap = protocol.HistGap - APIReq = protocol.APIReq - APIHeadMsg = protocol.APIHeadMsg - APIChunk = protocol.APIChunk - APIEnd = protocol.APIEnd - ErrorBody = protocol.ErrorBody - Event = protocol.Event - SessionTerminate = protocol.SessionTerminate - CloudSession = protocol.CloudSession - ExchangeHelloOK = protocol.ExchangeHelloOK - Now = protocol.Now - NowSource = protocol.NowSource - NowLoadpoint = protocol.NowLoadpoint + BoxMode = protocol.BoxMode + Hello = protocol.Hello + ProtoRange = protocol.ProtoRange + AppInfo = protocol.AppInfo + HelloOK = protocol.HelloOK + BoxInfo = protocol.BoxInfo + Clock = protocol.Clock + ModeInfo = protocol.ModeInfo + BootProgress = protocol.BootProgress + Sub = protocol.Sub + SourceWire = protocol.SourceWire + FieldDef = protocol.FieldDef + Snap = protocol.Snap + Delta = protocol.Delta + Tick = protocol.Tick + Guard = protocol.Guard + Cmd = protocol.Cmd + Expect = protocol.Expect + CmdAck = protocol.CmdAck + CmdResult = protocol.CmdResult + Observed = protocol.Observed + PlanReason = protocol.PlanReason + PlanSlot = protocol.PlanSlot + Plan = protocol.Plan + PlanCharger = protocol.PlanCharger + PlanChargerWindow = protocol.PlanChargerWindow + PriceGet = protocol.PriceGet + PriceSlot = protocol.PriceSlot + Price = protocol.Price + HistQuery = protocol.HistQuery + HistTileRef = protocol.HistTileRef + HistChunk = protocol.HistChunk + HistEnd = protocol.HistEnd + HistGap = protocol.HistGap + APIReq = protocol.APIReq + APIHeadMsg = protocol.APIHeadMsg + APIChunk = protocol.APIChunk + APIEnd = protocol.APIEnd + ErrorBody = protocol.ErrorBody + Event = protocol.Event + SessionTerminate = protocol.SessionTerminate + CloudSession = protocol.CloudSession + ExchangeHelloOK = protocol.ExchangeHelloOK + Now = protocol.Now + NowSource = protocol.NowSource + NowLoadpoint = protocol.NowLoadpoint ) const ( diff --git a/go/internal/mpc/plan_change_test.go b/go/internal/mpc/plan_change_test.go new file mode 100644 index 00000000..420bf68e --- /dev/null +++ b/go/internal/mpc/plan_change_test.go @@ -0,0 +1,48 @@ +package mpc + +import ( + "context" + "sync/atomic" + "testing" +) + +// A phone shows the plan the box holds, so the box says each time that moves: +// a replan starting, a published plan, and a newest replan that ends without +// one. A request that a newer one replaced says nothing when it ends; the +// newer one will. Every listener hears every change. +func TestOnPlanChangeHearsEveryChangeAReaderSees(t *testing.T) { + svc := newCancellationTestService(t, nil) + var a, b atomic.Int32 + svc.OnPlanChange(func() { a.Add(1) }) + svc.OnPlanChange(func() { b.Add(1) }) + + // Started, then published. + if svc.Replan(context.Background()) == nil || a.Load() != 2 { + t.Fatalf("published plan: %d calls, want 2", a.Load()) + } + + // Started, then failed. + svc.Zone = "no-prices" + svc.RequestReplan("saved-goal") + waitForRequestedReplans(t, svc) + if got := a.Load(); got != 4 || !svc.PlanSnapshot().Outdated { + t.Fatalf("failed replan: %d calls, want 4", got) + } + + older := svc.beginReplan(context.Background(), "older") + newer := svc.beginReplan(context.Background(), "newer") + if got := a.Load(); got != 6 || !svc.PlanSnapshot().Pending { + t.Fatalf("two starts: %d calls, want 6", got) + } + svc.finishReplan(older) + if got := a.Load(); got != 6 { + t.Fatalf("a replaced request spoke: %d calls", got) + } + svc.finishReplan(newer) + if got := a.Load(); got != 7 { + t.Fatalf("the newest request ending unpublished: %d calls, want 7", got) + } + if a.Load() != b.Load() { + t.Fatalf("listeners heard %d and %d changes", a.Load(), b.Load()) + } +} diff --git a/go/internal/mpc/service.go b/go/internal/mpc/service.go index 59b176d0..e0c89b66 100644 --- a/go/internal/mpc/service.go +++ b/go/internal/mpc/service.go @@ -239,6 +239,9 @@ type Service struct { // decisionIDFactory is a test seam. Production uses a random UUID for every // accepted plan. It is read only while mu is held at the publish gate. decisionIDFactory func() string + // planChanged are told when what a plan reader sees moves. Guarded by + // mu; only ever appended to. + planChanged []func() // MinArbitrageSpreadOreKwh flows in from config.Planner. Copied into // Params so the DP applies the arbitrage cycle deadband. See @@ -456,6 +459,25 @@ func (s *Service) UpdatePlannerScalars(p Params, baseLoad float64, horizon, inte s.mu.Unlock() } +// OnPlanChange adds fn to those called each time what a plan reader sees +// moves: a replan starts and the plan becomes pending, a plan is published, +// or the newest replan ends without one. fn runs on the replanning or +// requesting goroutine without the service lock and must not block. +func (s *Service) OnPlanChange(fn func()) { + if s == nil { + return + } + s.mu.Lock() + defer s.mu.Unlock() + s.planChanged = append(s.planChanged, fn) +} + +func tell(fns []func()) { + for _, fn := range fns { + fn() + } +} + // Latest returns the most recently computed plan (nil before first run). func (s *Service) Latest() *Plan { if s == nil { @@ -469,11 +491,14 @@ func (s *Service) Latest() *Plan { // PlanSnapshot keeps the visible plan and its freshness in one read. Outdated // remains true if the latest request fails; Pending only describes active work. type PlanSnapshot struct { - Plan *Plan - ReplanAt time.Time - Reason string - Pending bool - Outdated bool + Plan *Plan + ReplanAt time.Time + Reason string + Pending bool + Outdated bool + // Unproven is set when the plan needs fresh physical inputs or PV + // control before the box may act on it. Outdated is set with it. + Unproven bool Error string Warnings []string loadpointID string @@ -496,6 +521,7 @@ func (s *Service) PlanSnapshot() PlanSnapshot { s.mu.RUnlock() if !s.planExecutionAllowed(out.Plan, proof, currentContract) { out.Outdated = true + out.Unproven = true out.Reason = "Plan requires fresh physical inputs or PV control" } return out @@ -1306,7 +1332,9 @@ func (s *Service) requestReplan(reason string, change func()) { s.queuedReplan = &request startWorker := !s.requestedReplanRunning s.requestedReplanRunning = true + changed := s.planChanged s.mu.Unlock() + tell(changed) if previous != nil { s.finishReplan(*previous) } @@ -1347,8 +1375,14 @@ func (s *Service) replan(ctx context.Context, reason string) *Plan { func (s *Service) beginReplan(ctx context.Context, reason string) replanRequest { s.mu.Lock() - defer s.mu.Unlock() - return s.beginReplanLocked(ctx, reason) + request := s.beginReplanLocked(ctx, reason) + var changed []func() + if request.accepted { + changed = s.planChanged + } + s.mu.Unlock() + tell(changed) + return request } // beginReplanLocked assigns the generation and snapshots the effective @@ -1412,6 +1446,7 @@ func (s *Service) finishReplan(request replanRequest) { defer s.replanWG.Done() contextErr := request.ctx.Err() request.cancel() + var changed []func() s.mu.Lock() if request.generation == s.latestReplanGeneration { s.activeReplanCancel = nil @@ -1427,9 +1462,11 @@ func (s *Service) finishReplan(request replanRequest) { // it when the replacement fails. A later retry must publish a new // plan before dispatch resumes; queuing work alone is not enough. s.failedReplanGeneration = request.generation + changed = s.planChanged } } s.mu.Unlock() + tell(changed) } func (s *Service) canceledReplan(request replanRequest, stage string) *Plan { @@ -2072,7 +2109,9 @@ func (s *Service) runReplan(request replanRequest) *Plan { reason := request.reason replanAtMs := s.lastReplanAt.UnixMilli() saveDiag := s.SaveDiag + changed := s.planChanged s.mu.Unlock() + tell(changed) if captured.Record != nil { captured.Record(baseForecastSlots, fallbackSlots, plan.DecisionID, replanAtMs) } diff --git a/go/protocol/messages.go b/go/protocol/messages.go index 245c5f27..2a157271 100644 --- a/go/protocol/messages.go +++ b/go/protocol/messages.go @@ -492,6 +492,51 @@ type Plan struct { Stale bool `cbor:"stale"` // CeilingW is the import ceiling being defended, nil when there is none. CeilingW *int64 `cbor:"ceilingW"` + // Chargers is each charger's share of this plan, as GET /api/loadpoints + // reports it, so a plan the box pushes redraws the charger without a + // read. Absent on a box with no chargers or no planner. An older app + // ignores it. + Chargers []PlanCharger `cbor:"chargers,omitempty"` +} + +// PlanCharger is one charger's share of the plan. Keyed by the charger, +// never by a car: a car visits chargers (docs/cars.md, srcfl/ftw#1647). +type PlanCharger struct { + // ID is the charger's loadpoint id. + ID string `cbor:"id"` + // CarID is the session's car, empty while the box does not know which + // one is plugged in. Always empty until Core keeps cars. A change of + // it is a plan input: whatever applies session.car.set requests a + // replan, and the plan reaches the phone as every replan does. + CarID string `cbor:"carId,omitempty"` + PluggedIn bool `cbor:"pluggedIn"` + // Pending is set while a newer plan is being made; the windows are then + // the plan the box goes on acting on until the new one lands. Outdated + // is set with it, and alone when the plan is not one the box acts on; + // without Pending the windows are then empty. + Pending bool `cbor:"pending"` + Outdated bool `cbor:"outdated"` + // GridDeferred says grid-funded charging waits for prices that reach + // the deadline, as grid_deferred on the HTTP route. + GridDeferred bool `cbor:"gridDeferred"` + // GoalRev changes when the charger's goal, ready time, schedule or + // solar-only choice changes, wherever that was done. + GoalRev uint32 `cbor:"goalRev"` + // Windows are the charging windows still ahead, the next one first. + Windows []PlanChargerWindow `cbor:"windows"` + // TotalWh is what the plan still means to deliver across its horizon. + TotalWh int64 `cbor:"totalWh"` + // ShortfallWh is how far short of the goal the plan leaves the car, in + // battery watt-hours. Absent when the plan has no goal for it. + ShortfallWh *int64 `cbor:"shortfallWh,omitempty"` +} + +// PlanChargerWindow is one stretch the plan charges on a charger. Wall +// clock. +type PlanChargerWindow struct { + StartMs int64 `cbor:"startMs"` + EndMs int64 `cbor:"endMs"` + Wh int64 `cbor:"wh"` } // --------------------------------------------------------------------------