From a6d19b6551d20a290a98344cd7604f3788b9371a Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Fri, 16 Oct 2026 06:43:37 +0200 Subject: [PATCH 1/4] feat(cars): the car apart from the charger, in Core The box keeps its owners' cars in one state.db row and decides which car each charging session is: the charger's identity, the car's own report within a minute, the only or usual car, one tap after car.which, or a visitor. The session's car gives the loadpoint its capacity, goal, surplus choice and level, and the planner reads them. A car reading stays exact until 1 kWh has reached it. The search-based pairing and its refusals on ambiguity are gone. The loadpoint routes and ops act on the session's car for one release. Migration makes Car 1 from the loadpoint and a car from each vehicles[] profile; nothing the owner set is lost. docs/cars.md is the contract; two reviews shaped it. Co-Authored-By: Claude Opus 5.5 --- .changeset/cars-in-core.md | 5 + docs/architecture.md | 14 +- go/cmd/ftw/app_link.go | 33 +- go/cmd/ftw/cars_wiring.go | 149 +++ go/cmd/ftw/cars_wiring_test.go | 111 +++ go/cmd/ftw/cloud_link.go | 10 +- go/cmd/ftw/cloud_link_test.go | 14 + go/cmd/ftw/main.go | 205 ++-- go/cmd/ftw/vehicle_refresh.go | 33 +- go/cmd/ftw/vehicle_refresh_test.go | 33 +- go/internal/api/api.go | 162 ++-- go/internal/api/api_cloud.go | 4 +- go/internal/api/api_control_feedback.go | 10 +- .../api/api_loadpoint_planning_soc_test.go | 56 +- .../api/api_loadpoint_soc_source_test.go | 159 +++- .../api/api_notifications_push_test.go | 6 +- go/internal/api/loadpoint_vehicle.go | 9 + go/internal/appproto/cars.go | 452 +++++++++ go/internal/appproto/cars_test.go | 354 +++++++ go/internal/appproto/command.go | 10 + go/internal/appproto/ev_test.go | 17 +- go/internal/appproto/exchange.go | 26 +- go/internal/appproto/exchange_test.go | 58 +- go/internal/appproto/handler.go | 15 + go/internal/appproto/ports.go | 23 + go/internal/appproto/protocol.go | 10 + go/internal/cars/cars.go | 745 +++++++++++++++ go/internal/cars/cars_test.go | 463 ++++++++++ go/internal/cars/edges_test.go | 293 ++++++ go/internal/cars/harness_test.go | 208 +++++ go/internal/cars/ops.go | 785 ++++++++++++++++ go/internal/cars/review_test.go | 410 ++++++++ go/internal/cars/tick.go | 872 ++++++++++++++++++ go/internal/cars/view.go | 235 +++++ .../drivers/lua_vehicle_soc_freshness_test.go | 12 +- go/internal/events/bus.go | 15 + go/internal/loadpoint/car.go | 200 ++++ go/internal/loadpoint/loadpoint.go | 235 +++-- .../loadpoint_anchor_vehicle_test.go | 13 +- go/internal/loadpoint/planning_soc_test.go | 130 ++- go/internal/loadpoint/schedule.go | 9 + .../loadpoint/session_capacity_test.go | 34 +- go/internal/loadpoint/session_state.go | 6 +- go/internal/loadpoint/session_state_test.go | 4 +- .../loadpoint/vehicle_anchor_order_test.go | 21 +- go/internal/loadpoint/vehicle_completion.go | 15 +- .../loadpoint/vehicle_completion_test.go | 10 +- go/internal/loadpoint/vehicle_profile_test.go | 64 +- go/internal/notifications/catalogue.go | 1 + go/internal/notifications/service.go | 17 +- go/internal/notifications/service_test.go | 19 + go/internal/ocpp/handlers.go | 14 +- go/internal/ocpp/handlers_v201.go | 3 +- go/internal/telemetry/vehicle.go | 289 ++---- .../telemetry/vehicle_source_time_test.go | 22 +- go/internal/telemetry/vehicle_test.go | 422 +++------ go/internal/units/consistency_test.go | 6 +- 57 files changed, 6472 insertions(+), 1078 deletions(-) create mode 100644 .changeset/cars-in-core.md create mode 100644 go/cmd/ftw/cars_wiring.go create mode 100644 go/cmd/ftw/cars_wiring_test.go create mode 100644 go/internal/appproto/cars.go create mode 100644 go/internal/appproto/cars_test.go create mode 100644 go/internal/cars/cars.go create mode 100644 go/internal/cars/cars_test.go create mode 100644 go/internal/cars/edges_test.go create mode 100644 go/internal/cars/harness_test.go create mode 100644 go/internal/cars/ops.go create mode 100644 go/internal/cars/review_test.go create mode 100644 go/internal/cars/tick.go create mode 100644 go/internal/cars/view.go create mode 100644 go/internal/loadpoint/car.go diff --git a/.changeset/cars-in-core.md b/.changeset/cars-in-core.md new file mode 100644 index 000000000..2e2adcf14 --- /dev/null +++ b/.changeset/cars-in-core.md @@ -0,0 +1,5 @@ +--- +"ftw": minor +--- + +The box keeps cars apart from its chargers. A car's level is its own: a reading from the car stays exact until 1 kWh has reached it, however old the reading, so a nine-minute-old 14 % with nothing delivered since shows as "from the car". The box knows which car is on a charger from the charger's identifier, the car's own report, the only car, or one tap after it asks (`car.which`), and the plan takes that car's capacity, goal, surplus choice and power limit. On first start the box makes Car 1 from each charger's settings and a car from each vehicle profile, and binds each vehicle driver by VIN or to the only car. The charger routes and ops act on the session's car for this release, so apps already installed keep working. diff --git a/docs/architecture.md b/docs/architecture.md index f4395e4b5..3c8b51d4a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -53,11 +53,19 @@ session checkpoint failed. Neither means the saved schedule disappeared. Core assigns each saved vehicle-limit goal an `intent_id` and a one-shot `first_deadline_ms`; clients send user choices, not those bookkeeping fields. A fresh vehicle Complete can finish a one-shot goal across restart and later -plug sessions. Completion requires one vehicle source, one connected loadpoint, -a reading after the observed connection and no measured charging. An ambiguous -match cannot finish the goal. A charger declining current is reported as a refusal, never +plug sessions. Completion requires a live report from the session car's own +source, made after the observed connection, and no measured charging. A car +the box has not named cannot finish the goal. A charger declining current is reported as a refusal, never as an invented battery level or proof that the target was reached. +### Cars + +A car belongs to its owner and visits chargers. [`go/internal/cars`](../go/internal/cars) +keeps the owners' cars, decides which car each charging session is, and +hands that car's capacity, goal, surplus choice and level to the loadpoint, +which keeps the session's energy and the estimate the planner reads. A car's +reading stays exact until 1 kWh has reached the car. See [cars.md](cars.md). + ### History measurement quality An offline flow leaves working devices recording. Missing inputs make their diff --git a/go/cmd/ftw/app_link.go b/go/cmd/ftw/app_link.go index a5335ef98..c9bb45ec0 100644 --- a/go/cmd/ftw/app_link.go +++ b/go/cmd/ftw/app_link.go @@ -17,6 +17,7 @@ import ( "github.com/srcfl/ftw/go/internal/appenroll" "github.com/srcfl/ftw/go/internal/appproto" "github.com/srcfl/ftw/go/internal/appuplink" + "github.com/srcfl/ftw/go/internal/cars" "github.com/srcfl/ftw/go/internal/config" "github.com/srcfl/ftw/go/internal/control" "github.com/srcfl/ftw/go/internal/loadpoint" @@ -418,6 +419,7 @@ type appLoadpoints struct { mgr *loadpoint.Manager ctrl *loadpoint.Controller mpc *mpc.Service + cars *cars.Runtime } func (a *appLoadpoints) WaitForPersistence(ctx context.Context) error { @@ -485,16 +487,28 @@ func (a *appLoadpoints) SetSoC(id string, soc float64) bool { // Readings is every charger as the manager holds it. The car's level counts // only while it is plugged in: unplugged, the manager's number is the last -// session's, and a watch would draw it as now. +// session's, and a watch would draw it as now. A visitor's car has none. func (a *appLoadpoints) Readings() []appproto.LoadpointReading { states := a.mgr.States() out := make([]appproto.LoadpointReading, 0, len(states)) for _, st := range states { - out = append(out, appproto.LoadpointReading{ + r := appproto.LoadpointReading{ ID: st.ID, Plugged: st.PluggedIn, - SoC: st.CurrentSoC, SoCKnown: st.PluggedIn, + SoC: st.CurrentSoC, SoCKnown: st.PluggedIn && !st.SoCUnknown, PowerW: st.CurrentPowerW, - }) + } + if a.cars != nil { + r.Car = a.cars.ChargerCar(st.ID) + if v, ok := a.cars.Session(st.ID); ok && v.Source != "" && r.SoCKnown { + // The level the car shows, and where it came from. + r.SoC, r.SoCSource, r.SoCEstimate = v.Shown, v.Source, v.Estimate + if !v.At.IsZero() { + age := max(0, int64(time.Since(v.At)/time.Second)) + r.SoCAgeS = &age + } + } + } + out = append(out, r) } return out } @@ -650,6 +664,7 @@ func startAppLink( planner *mpc.Service, lpMgr *loadpoint.Manager, lpCtrl *loadpoint.Controller, + carsRT *cars.Runtime, priceSvc *prices.Service, ctrl *control.State, ctrlMu *sync.Mutex, @@ -705,9 +720,16 @@ func startAppLink( // hides the buttons rather than drawing dead ones. var loadpoints appproto.Loadpoints if lpMgr != nil && lpCtrl != nil { - loadpoints = &appLoadpoints{mgr: lpMgr, ctrl: lpCtrl, mpc: planner} + loadpoints = &appLoadpoints{mgr: lpMgr, ctrl: lpCtrl, mpc: planner, cars: carsRT} caps = append(caps, appproto.CapDerEv) } + // Cars ride with the chargers (docs/cars.md). The box says der.car when + // it keeps them, and the car ops answer. + var carPort appproto.Cars + if carsRT != nil && loadpoints != nil { + carPort = carsRT + caps = append(caps, appproto.CapDerCar) + } // The plan reaches every streaming phone the moment the planner // replaces it, with each charger's windows, so a car plugged in shows @@ -790,6 +812,7 @@ func startAppLink( Loadpoints: loadpoints, PlanChargers: planChargers, PlanFeed: planFeed, + Cars: carPort, API: gateway, Caller: caller, Grants: grants, diff --git a/go/cmd/ftw/cars_wiring.go b/go/cmd/ftw/cars_wiring.go new file mode 100644 index 000000000..210bb081c --- /dev/null +++ b/go/cmd/ftw/cars_wiring.go @@ -0,0 +1,149 @@ +package main + +import ( + "slices" + + "github.com/srcfl/ftw/go/internal/cars" + "github.com/srcfl/ftw/go/internal/config" + "github.com/srcfl/ftw/go/internal/drivers" + "github.com/srcfl/ftw/go/internal/loadpoint" +) + +// carProfiles turns config.yaml's vehicles[] into the cars runtime's +// profiles. Each profile the box has not seen becomes a car (docs/cars.md). +func carProfiles(vehicles []config.Vehicle) []cars.Profile { + out := make([]cars.Profile, 0, len(vehicles)) + for _, v := range vehicles { + target := v.TargetSoC + if target <= 0 && v.TargetSoCPct > 0 { + target = v.TargetSoCPct / 100 + } + out = append(out, cars.Profile{ + ID: v.ID, Name: v.Name, CapacityWh: v.CapacityWh, + Identifiers: v.Identifiers, SurplusOnly: v.SurplusOnly, TargetSoC: target, + }) + } + return out +} + +// evCarInputs is what a plan takes from the session's car. +type evCarInputs struct { + // CapacityWh is the car's, 60 kWh when nobody knows it. + CapacityWh float64 + // InitialSoC is the level the app shows: the car's own number while it + // is exact, its last reading plus the energy since after. + InitialSoC float64 + // ChargeLimit caps the plan only while the level is the car's own and + // still exact. 0 is unknown. + ChargeLimit float64 + // Source names where the level comes from, for the planner's log. + Source string + // MaxW is the car's own charger, 0 when unknown. + MaxW float64 +} + +// evPlanCar reads the session's car off the loadpoint state the cars runtime +// keeps (docs/cars.md). Before cars the planner read the charger's configured +// capacity here, whatever car was plugged in. +func evPlanCar(st loadpoint.State, rt *cars.Runtime) evCarInputs { + in := evCarInputs{CapacityWh: st.VehicleCapacityWh, InitialSoC: st.CurrentSoC, Source: "session", MaxW: st.CarMaxW} + if rt == nil { + return in + } + if view, ok := rt.Session(st.ID); ok && view.Source != "" && !st.SoCUnknown { + in.Source = "car:" + view.Source + in.InitialSoC = view.Shown + } + in.ChargeLimit = rt.PlanningLimit(st.ID) + return in +} + +// limitPower holds a loadpoint's planned power to the session car's own +// charger: the lower of the charger's and the car's maximum. A step above the +// car's maximum is one the car cannot take. When the charger's lowest step +// is above it, the plan keeps that one step: the car takes what it can, and +// a plan with no step would never charge. +func (in evCarInputs) limitPower(st loadpoint.State, steps []float64) (minW, maxW float64, out []float64) { + minW, maxW = st.MinChargeW, st.MaxChargeW + if in.MaxW <= 0 || (maxW > 0 && in.MaxW >= maxW) { + return minW, maxW, steps + } + maxW = in.MaxW + lowest, kept := 0.0, false + for _, w := range steps { + if w <= maxW { + out = append(out, w) + kept = kept || w > 0 + } + if w > 0 && (lowest == 0 || w < lowest) { + lowest = w + } + } + if !kept && lowest > 0 { + out = append(out, lowest) + maxW = lowest + } + if minW > maxW { + minW = maxW + } + return minW, maxW, out +} + +// configuredVehicleDrivers names the enabled drivers whose catalog entry +// declares the vehicle capability: the sources a car can be bound to before +// they report. +func configuredVehicleDrivers(configured []config.Driver) []string { + var out []string + for _, d := range configured { + if d.Disabled { + continue + } + e, err := drivers.ParseCatalogFile(d.Lua) + if err != nil || !slices.Contains(e.Capabilities, "vehicle") { + continue + } + out = append(out, d.Name) + } + return out +} + +// loadpointSettingsChanges lists the loadpoint settings a config.yaml change +// moved, for the car each charger shows (docs/cars.md). +func loadpointSettingsChanges(oldCfg, newCfg *config.Config) []cars.SettingsChange { + if oldCfg == nil || newCfg == nil { + return nil + } + before := map[string]config.Loadpoint{} + for _, lp := range oldCfg.Loadpoints { + before[lp.ID] = lp + } + var out []cars.SettingsChange + for _, lp := range newCfg.Loadpoints { + prev, ok := before[lp.ID] + if !ok { + continue + } + c := cars.SettingsChange{LP: lp.ID} + if lp.VehicleCapacityWh != prev.VehicleCapacityWh && lp.VehicleCapacityWh > 0 { + c.CapacityWh = &lp.VehicleCapacityWh + } + if plugin, prevPlugin := pluginFraction(lp), pluginFraction(prev); plugin != prevPlugin && plugin > 0 { + c.PluginSoC = &plugin + } + if lp.SurplusOnly != prev.SurplusOnly { + v := lp.SurplusOnly + c.SurplusOnly = &v + } + if c.CapacityWh != nil || c.PluginSoC != nil || c.SurplusOnly != nil { + out = append(out, c) + } + } + return out +} + +func pluginFraction(lp config.Loadpoint) float64 { + if lp.PluginSoC > 0 { + return lp.PluginSoC + } + return lp.PluginSoCPct / 100 +} diff --git a/go/cmd/ftw/cars_wiring_test.go b/go/cmd/ftw/cars_wiring_test.go new file mode 100644 index 000000000..afce35364 --- /dev/null +++ b/go/cmd/ftw/cars_wiring_test.go @@ -0,0 +1,111 @@ +package main + +import ( + "encoding/json" + "math" + "sync" + "testing" + "time" + + "github.com/srcfl/ftw/go/internal/cars" + "github.com/srcfl/ftw/go/internal/events" + "github.com/srcfl/ftw/go/internal/loadpoint" + "github.com/srcfl/ftw/go/internal/telemetry" + "github.com/srcfl/ftw/go/protocol" +) + +type memRows struct { + mu sync.Mutex + rows map[string]string +} + +func (m *memRows) LoadConfig(k string) (string, bool) { + m.mu.Lock() + defer m.mu.Unlock() + v, ok := m.rows[k] + return v, ok +} + +func (m *memRows) SaveConfig(k, v string) error { + m.mu.Lock() + defer m.mu.Unlock() + m.rows[k] = v + return nil +} + +// Before cars the planner sized every plan from the charger's configured +// capacity (main.go read cfg.Loadpoints[].VehicleCapacityWh), whatever car +// was plugged in. Two cars on one Easee: the box asks which, a tap names the +// Volvo, and the plan's inputs are the Volvo's capacity, goal, level and the +// power its own charger takes. +func TestThePlanFollowsTheTappedCar(t *testing.T) { + now := time.Date(2026, 10, 15, 16, 30, 0, 0, time.UTC) + mgr := loadpoint.NewManager() + mgr.SetNowFn(func() time.Time { return now }) + mgr.SetLocation(time.UTC) + mgr.Load([]loadpoint.Config{{ID: "easee", DriverName: "easee", VehicleCapacityWh: 76000, MinChargeW: 4140, MaxChargeW: 11000, + AllowedStepsW: []float64{4140, 7360, 11000}}}) + var goal loadpoint.Schedule + if err := json.Unmarshal([]byte(`{"soc":0.8,"recurring":true,"time_of_day_min":420}`), &goal); err != nil { + t.Fatal(err) + } + if err := mgr.HydrateSchedules(func(string) (loadpoint.Schedule, bool) { return goal, true }); err != nil { + t.Fatal(err) + } + bus := events.NewBus() + var asked []events.CarWhich + bus.Subscribe(events.KindCarWhich, func(e events.Event) { asked = append(asked, e.(events.CarWhich)) }) + rt := cars.New(cars.Deps{Store: &memRows{rows: map[string]string{}}, Loadpoints: mgr, Bus: bus, + Now: func() time.Time { return now }, Vehicles: func() []telemetry.VehicleObservation { return nil }}) + if err := rt.Start(nil); err != nil { + t.Fatal(err) + } + volvo, err := rt.Add(protocol.Car{ID: "0123456789abcdef0123456789abcdef", Name: "Volvo", CapacityWh: 40000, MaxW: 7400, + Goal: &protocol.CarGoal{TargetPermille: 600, ReadyByMin: 390, Days: 0x7F}}) + if err != nil { + t.Fatal(err) + } + + mgr.ObserveSession("easee", true, 0, 0, true, "easee:EH1", "s1") + for i := 0; i < 3; i++ { + now = now.Add(31 * time.Second) + mgr.RollSchedules(now) + rt.Tick() + } + if len(asked) != 1 { + t.Fatalf("car.which raised %d times, want once", len(asked)) + } + if _, err := rt.SetSessionCar("easee", rt.ChargerCar("easee").Session, volvo.ID); err != nil { + t.Fatal(err) + } + now = now.Add(time.Second) + mgr.RollSchedules(now) + rt.Tick() + + st, _ := mgr.State("easee") + in := evPlanCar(st, rt) + if in.CapacityWh != 40000 || math.Abs(in.InitialSoC-.2) > 1e-9 || in.Source != "car:assumed" || in.ChargeLimit != 0 { + t.Fatalf("plan inputs %+v, want the Volvo's", in) + } + if st.TargetSoC != .6 || st.TargetTime.IsZero() || loadpoint.PlanningTarget(st, in.ChargeLimit) != .6 { + t.Fatalf("goal %v by %v, want the Volvo's 60 %%", st.TargetSoC, st.TargetTime) + } + minW, maxW, steps := in.limitPower(st, st.AllowedStepsW) + if maxW != 7400 || minW != 4140 || len(steps) != 2 || steps[1] != 7360 { + t.Fatalf("power %v–%v %v, want the Volvo's own 7.4 kW", minW, maxW, steps) + } +} + +// A car whose own charger takes less than the charger's lowest step still +// gets a plan: that step, which the car's charger limits, rather than none. +func TestACarBelowTheChargersLowestStepKeepsOneStep(t *testing.T) { + st := loadpoint.State{MinChargeW: 4140, MaxChargeW: 11000} + minW, maxW, steps := evCarInputs{MaxW: 3700}.limitPower(st, []float64{4140, 7360, 11000}) + if len(steps) != 1 || steps[0] != 4140 || maxW != 4140 || minW != 4140 { + t.Fatalf("power %v–%v %v, want the charger's lowest step", minW, maxW, steps) + } + minW, maxW, steps = evCarInputs{MaxW: 7400}.limitPower(st, []float64{4140, 7360, 11000}) + if len(steps) != 2 || maxW != 7400 || minW != 4140 { + t.Fatalf("power %v–%v %v", minW, maxW, steps) + } +} diff --git a/go/cmd/ftw/cloud_link.go b/go/cmd/ftw/cloud_link.go index 1c676c98b..2e1039452 100644 --- a/go/cmd/ftw/cloud_link.go +++ b/go/cmd/ftw/cloud_link.go @@ -177,11 +177,17 @@ func (c *cloudSite) loadpoint(st loadpoint.State, now time.Time) cloudwire.Loadp ID: st.ID, Src: st.DriverName, Plugged: st.PluggedIn, SocSource: st.SoCSource, Recurring: !st.Schedule.Empty(), PowerW: roundW(st.CurrentPowerW), SessionWh: roundW(st.DeliveredWhSession), - Session: c.session(st.ID, st.PluggedIn), + Session: st.Session, CommandedReason: st.CommandedReason, SurplusOnly: st.SurplusOnly, GridDeferred: st.GridDeferred, PlanPending: st.PlanPending, Boost: st.BatteryBoost.Active, } - if st.PluggedIn { + if lp.Session == "" { + // No car runtime has named this plug-in: the count of plug-ins + // since the box started, as before cars. + lp.Session = c.session(st.ID, st.PluggedIn) + } + if st.PluggedIn && !st.SoCUnknown { + // A visitor's car has no level; its delivered energy is not one. lp.SocPermille = ptr(units.PermilleFromFraction(st.CurrentSoC)) if st.SoCSource == "vehicle" { lp.SocAgeS = ptr(st.VehicleSoCAgeS) diff --git a/go/cmd/ftw/cloud_link_test.go b/go/cmd/ftw/cloud_link_test.go index 67cba77f8..7c8ce3277 100644 --- a/go/cmd/ftw/cloud_link_test.go +++ b/go/cmd/ftw/cloud_link_test.go @@ -247,3 +247,17 @@ func TestCloudLiveCarriesThePhasesUnderTheMeter(t *testing.T) { t.Fatalf("the fuse follows %v; it is config", got) } } + +// FTW Cloud's lp.session is the plug-in's id from the cars runtime, the +// same across a restart, which a tap after car.which names. +func TestCloudSessionIsTheCarsRuntimes(t *testing.T) { + c := &cloudSite{} + st := loadpoint.State{ID: "garage", DriverName: "easee", PluggedIn: true, Session: "7d1f0c2a9b3e4f50"} + if lp := c.loadpoint(st, time.Now()); lp.Session != "7d1f0c2a9b3e4f50" { + t.Fatalf("session %q", lp.Session) + } + st.Session = "" + if lp := c.loadpoint(st, time.Now()); lp.Session == "" { + t.Fatal("without the cars runtime the plug-in count is the session") + } +} diff --git a/go/cmd/ftw/main.go b/go/cmd/ftw/main.go index 922a4ef33..58413531b 100644 --- a/go/cmd/ftw/main.go +++ b/go/cmd/ftw/main.go @@ -40,6 +40,7 @@ import ( "github.com/srcfl/ftw/go/internal/appuplink" "github.com/srcfl/ftw/go/internal/arp" "github.com/srcfl/ftw/go/internal/battery" + "github.com/srcfl/ftw/go/internal/cars" "github.com/srcfl/ftw/go/internal/components" "github.com/srcfl/ftw/go/internal/config" "github.com/srcfl/ftw/go/internal/control" @@ -872,7 +873,18 @@ func main() { // schedule writes the empty JSON ("{}"), which HydrateSchedules // treats as no-config so a future reload doesn't resurrect it. const lpSchedKeyPrefix = "loadpoint_schedule:" + // The cars (docs/cars.md). Declared before the savers: a goal or a + // surplus choice set on a charger is its car's, and the car keeps it. + // Built once the schedules are hydrated, below. + var carsRT *cars.Runtime lpMgr.SetScheduleSaver(func(id string, s loadpoint.Schedule) error { + if carsRT != nil { + row, err := carsRT.GoalSaved(id, s) + if err != nil || row == nil { + return err + } + s = *row + } key := lpSchedKeyPrefix + id if s.Empty() { return st.SaveConfig(key, "{}") @@ -905,6 +917,12 @@ func main() { // flag on every restart. const lpSurplusKeyPrefix = "loadpoint_surplus_only:" lpMgr.SetSurplusOnlySaver(func(id string, v bool) error { + if carsRT != nil { + row, err := carsRT.SurplusSaved(id, v) + if err != nil || !row { + return err + } + } key := lpSurplusKeyPrefix + id val := "false" if v { @@ -933,6 +951,36 @@ func main() { // wired up. nil until that point — the reload closure guards. var mpcSvc *mpc.Service + // The cars start after the schedules and surplus choices are hydrated + // and before the first control tick, so a restored session keeps its + // car's capacity. On first start they are made from the loadpoints and + // the vehicles[] profiles. + carsRT = cars.New(cars.Deps{ + Store: st, + Loadpoints: lpMgr, + Vehicles: func() []telemetry.VehicleObservation { return telemetry.VehicleObservations(tel) }, + Identity: func(driver string) (string, string) { + if env := reg.Env(driver); env != nil { + make, sn, _, _ := env.FullIdentity() + return make, sn + } + return "", "" + }, + Bus: bus, + Replan: func(reason string) { + if mpcSvc != nil { + mpcSvc.RequestReplan(reason) + } + }, + }) + carsRT.SetVehicleDrivers(configuredVehicleDrivers(cfg.Drivers)) + if err := carsRT.Start(carProfiles(cfg.Vehicles)); err != nil { + // The runtime stays, empty and passive: each charger keeps its own + // settings, as before cars. + slog.Error("cars: could not start; chargers keep their own settings", "err", err) + } + lpMgr.SetLevelObserver(carsRT.LevelSet) + // Pre-declared for the same reason as mpcSvc — the hot-reload // Applier needs to mutate loadSvc.SiteMeter when the operator // moves the `is_site_meter: true` flag between drivers, and @@ -1186,6 +1234,8 @@ func main() { // anchor, current SoC estimate) — see loadpoint.Manager.Load. lpMgr.Load(buildLoadpointConfigs(newCfg.Loadpoints)) hydrateLoadpointSurplusOnly() + carsRT.SetVehicleDrivers(configuredVehicleDrivers(newCfg.Drivers)) + carsRT.Reload(carProfiles(newCfg.Vehicles), loadpointSettingsChanges(oldCfg, newCfg)...) // The OCPP quarantine follows the loadpoints just reloaded: // a pending charger a new entry names is adopted on this save, @@ -1397,36 +1447,16 @@ func main() { break } } - var vehicle *config.Vehicle - if lpID != "" { - if v := cfg.VehicleByIdentifier(vehicleID); v != nil { - vc := *v - vehicle = &vc - } - } cfgMu.RUnlock() if lpID == "" { return } - if vehicle == nil { - slog.Info("ocpp: session identity matches no vehicle profile — loadpoint keeps its own settings", - "charger", chargerID, "identity", vehicleID, "source", source) - return - } - name := vehicle.Name - if name == "" { - name = vehicle.ID - } - lpMgr.ApplyVehicleProfile(lpID, name, vehicle.CapacityWh) - lpMgr.SetSurplusOnly(lpID, vehicle.SurplusOnly) - if vehicle.TargetSoC > 0 { - lpMgr.SetTarget(lpID, vehicle.TargetSoC, time.Time{}) - } - slog.Info("ocpp: vehicle profile applied", - "charger", chargerID, "lp", lpID, "vehicle", vehicle.ID, - "source", source, "capacity_wh", vehicle.CapacityWh, - "surplus_only", vehicle.SurplusOnly, - "target_soc", vehicle.TargetSoC) + // The charger's word is the first rule of the + // identification order (docs/cars.md). An identity no car + // lists makes the session a visitor's once some car lists + // identifiers; while none does, it names nobody. + slog.Info("ocpp: the charger named the car", "charger", chargerID, "lp", lpID, "source", source) + carsRT.ChargerIdentified(lpID, vehicleID) }) // Charging needs: on an ISO 15118 session the car states what // it wants — energy, departure, and on DC its own capacity and @@ -1454,6 +1484,7 @@ func main() { } if needs.PresentSoC != nil { lpMgr.AnchorVehicleSoC(lpID, *needs.PresentSoC) + carsRT.ChargerReportedLevel(lpID, *needs.PresentSoC) } target, haveTarget := needs.TargetSoC() switch { @@ -1656,37 +1687,16 @@ func main() { (!effectiveTargetTime.After(time.Now()) && !st.FinishAtVehicleLimit) { continue } - // Pull capacity off the configured loadpoint. - var capWh float64 = 60000 // 60 kWh fallback - for _, c := range cfg.Loadpoints { - if c.ID == st.ID && c.VehicleCapacityWh > 0 { - capWh = c.VehicleCapacityWh - break - } - } - // Prefer a car SoC over the session estimate. A live reading - // (five minutes, not driver-stale) is ground truth and may - // also supply the charge limit. A cached or driver-stale - // reading inside the one-hour anchor window still starts - // the plan when it is newer than the manual confirmation, - // or when the level was never confirmed. Energy delivered - // since that measurement is included. An older manual - // correction stays. A stale charge limit does not cap the - // plan. The same choice is exposed as planning_soc. - initSoC := st.CurrentSoC - socSource := "session" - var vehicleChargeLimit float64 // 0 = unknown - delivering := st.CurrentPowerW > loadpoint.DeliveringW - if pick := telemetry.PlanningVehiclePick(tel, delivering, time.Now()); pick.Driver != "" { - var fromVehicle bool - initSoC, fromVehicle = lpMgr.PlanningStartSoC(st.ID, pick.SoC, pick.UpdatedAt, true) - if fromVehicle { - socSource = "vehicle:" + pick.Driver - } - if !pick.Stale { - vehicleChargeLimit = pick.ChargeLimit - } - } + // Capacity, level and limits come from the session's car + // (docs/cars.md). The loadpoint's state carries the car's + // capacity, 60 kWh when nobody knows it, and its level: the + // car's last reading plus the energy delivered to it since, + // which the cars runtime anchors each tick. That is the + // level the app shows. The car's charge limit caps the plan + // only while its level is the car's own and still exact. + car := evPlanCar(st, carsRT) + capWh, initSoC, socSource := car.CapacityWh, car.InitialSoC, car.Source + vehicleChargeLimit := car.ChargeLimit // 0 = unknown // Map target time → slot index using the DP's // actual slot length (hour-of-prices vs. 15-min // quarters vary by market). Both planners clamp a @@ -1728,6 +1738,7 @@ func main() { noBatteryToEV := !(ctrl.BatteryCoversEV || boostActive) ctrlMu.Unlock() charging, chargeDuration := lpMgr.ChargingPeriod(st.ID) + minW, maxW, steps := car.limitPower(st, planningStepsForLoadpoint(st, cfg)) specs = append(specs, &mpc.LoadpointSpec{ ID: st.ID, CapacityWh: capWh, @@ -1738,9 +1749,9 @@ func main() { PluggedIn: true, TargetSoC: targetSoC, TargetSlotIdx: targetSlot, - MinChargeW: st.MinChargeW, - MaxChargeW: st.MaxChargeW, - AllowedStepsW: planningStepsForLoadpoint(st, cfg), + MinChargeW: minW, + MaxChargeW: maxW, + AllowedStepsW: steps, ChargeEfficiency: loadpoint.DefaultChargeEfficiency, SurplusOnly: st.SurplusOnly, NoBatteryToEV: noBatteryToEV, @@ -2089,16 +2100,13 @@ func main() { // clamp (or arbitrage-mode planning) detaches Tesla and // nothing software-side can wake it. lpController.SetVehicleStatus(func(lpID string) (string, string, bool) { - st, ok := lpMgr.State(lpID) - if !ok || !st.PluggedIn { - return "", "", false - } - delivering := st.CurrentPowerW > loadpoint.DeliveringW - pick := telemetry.PickBestVehicleForLoadpoint(tel, delivering, time.Now()) - if pick.Driver == "" { + // The session's car's own source, live. A car is woken only + // when the box knows it is the one on this charger. + report, ok := carsRT.LiveReport(lpID) + if !ok { return "", "", false } - return pick.Driver, pick.ChargingState, true + return report.Driver, report.ChargingState, true }) lpController.SetVehicleRefreshTarget(func(lpID string) (string, error) { @@ -2106,23 +2114,19 @@ func main() { if !ok || !lp.PluggedIn { return "", nil } - connected := 0 - for _, other := range lpMgr.States() { - if other.PluggedIn { - connected++ - } - } cfgMu.RLock() defer cfgMu.RUnlock() - return configuredVehicleRefreshDriver(cfg.Drivers, connected) + return configuredVehicleRefreshDriver(cfg.Drivers, carsRT.Sources(lpID)) }) lpController.SetVehicleChargeState(func(lpID string) (loadpoint.VehicleChargeState, bool) { - pick := telemetry.PickVehicleForCompletion(tel, time.Now()) - if pick.Driver == "" || pick.Stale || !lpMgr.VehicleObservationApplies(lpID, pick.UpdatedAt) { + // Only the session's car can say its goal is done, from a live + // report made during this connection. + report, ok := carsRT.LiveReport(lpID) + if !ok || !lpMgr.VehicleObservationApplies(lpID, report.At) { return loadpoint.VehicleChargeState{}, false } - return loadpoint.VehicleChargeState{SoC: pick.SoC, Limit: pick.ChargeLimit, State: pick.ChargingState}, true + return loadpoint.VehicleChargeState{SoC: report.SoC, Limit: report.ChargeLimit, State: report.ChargingState}, true }) // Wire the EV-available surplus computation for the @@ -2393,7 +2397,7 @@ func main() { agents := &agentPorts{logs: logRing, cfg: cfg, cfgMu: cfgMu, st: st, notices: cloudNotify} appEnroll, appUplink, appLinkEnabled, appLinkErr := startAppLink( ctx, cfg, identityKeyPath, boxID, Version, - st, tel, mpcSvc, lpMgr, lpController, priceSvc, ctrl, ctrlMu, plannerPrefs, + st, tel, mpcSvc, lpMgr, lpController, carsRT, priceSvc, ctrl, ctrlMu, plannerPrefs, controlRev, appLinkWatchdog, appAPI, webPush, appCloud, agents, ) switch { @@ -2485,6 +2489,7 @@ func main() { LoadModel: loadSvc, ForecastLearning: forecastTrackerSvc, Loadpoints: lpMgr, + Cars: carsRT, LoadpointCtrl: lpController, OCPPChargers: ocppChargersFn, EVSend: evSend, @@ -2750,7 +2755,6 @@ func main() { // startup replan ran with whatever fallback SoC was available; once // the Tesla / vehicle driver gets ground truth from the car, the // plan should incorporate it (especially for EV target deadlines). - var vehicleReplanFired bool // Per-loadpoint last observed draw for the EV-stop edge replan. // A falling edge (was >100 W, now <50 W while still plugged in) // signals the EV finished or paused itself — the slot's plan no @@ -2934,29 +2938,12 @@ func main() { lpController.TickWithDispatch(ctx, tickNow, freshness.Allowed()) } - // Anchor each plugged-in loadpoint to the car reading the plan - // would start from. Runs after Tick's Observe so the anchor - // wins over that tick's inference. The reading is applied at - // the time the car measured it, and energy delivered since - // then moves the estimate. A measurement newer than the - // manual confirmation replaces it, including a cached or - // driver-stale cloud report inside VehicleAnchorMaxAge. A - // newer manual confirmation is left alone. When the session - // cannot place the measurement in its energy history, the - // reading still replaces the older level as the current SoC. - for _, st := range lpMgr.States() { - if !st.PluggedIn { - continue - } - delivering := st.CurrentPowerW > loadpoint.DeliveringW - pick := telemetry.PickVehicleForAnchor(tel, delivering, time.Now()) - if pick.Driver == "" || !lpMgr.VehicleSoCSupersedes(st.ID, pick.UpdatedAt) { - continue - } - if !lpMgr.AnchorVehicleSoCAt(st.ID, pick.SoC, pick.UpdatedAt) { - lpMgr.AnchorVehicleSoC(st.ID, pick.SoC) - } - } + // The cars follow the chargers just observed: plug-ins and + // unplugs, which car each session is, and each car's own + // readings, which anchor the session it is on (docs/cars.md). + // Runs after Tick's Observe so a reading wins over that tick's + // inference. + carsRT.Tick() if !freshness.Allowed() { // Clear before persisting: persistTelemetryTick snapshots @@ -3203,18 +3190,6 @@ func main() { } } - // First-vehicle-SoC replan trigger: as soon as any - // DerVehicle driver is online and reporting SoC, redo the - // plan once with measured-truth instead of the pluginSoC - // estimate the startup replan used. - if mpcSvc != nil && !vehicleReplanFired { - if pick := telemetry.PickBestVehicle(tel, time.Now()); pick.Driver != "" { - vehicleReplanFired = true - go mpcSvc.Replan(ctx) - slog.Info("fresh vehicle SoC received; MPC replan requested", "driver", pick.Driver, "soc", pick.SoC) - } - } - // ---- Persist the tick: history snapshot + flushed metrics ---- // One transaction for both — separate commits doubled the WAL // commit rate for no isolation benefit (SD-card wear). diff --git a/go/cmd/ftw/vehicle_refresh.go b/go/cmd/ftw/vehicle_refresh.go index 9a746c08c..818305dd3 100644 --- a/go/cmd/ftw/vehicle_refresh.go +++ b/go/cmd/ftw/vehicle_refresh.go @@ -8,37 +8,26 @@ import ( "github.com/srcfl/ftw/go/internal/drivers" ) -// Until a loadpoint has an explicit vehicle binding, one configured vehicle -// and one connected charger are the only unambiguous telemetry recipient. -// Freshness remains a requirement for planning and charge_start, not for wake. -func configuredVehicleRefreshDriver(configured []config.Driver, connected int) (string, error) { - if connected != 1 { - return "", fmt.Errorf("vehicle refresh requires one connected loadpoint") - } - var candidate *config.Driver - var entry drivers.CatalogEntry +// configuredVehicleRefreshDriver is the source of the session's car that may +// be woken for a fresh reading: the first of its vehicle drivers, in config +// order, that is writable and declares telemetry_wake. The cars runtime binds +// each driver to one car (docs/cars.md), so another car's driver is never a +// candidate however many cars or chargers the site has. Freshness remains a +// requirement for planning and charge_start, not for wake. +func configuredVehicleRefreshDriver(configured []config.Driver, sources []string) (string, error) { for i := range configured { d := &configured[i] - if d.Disabled { + if d.Disabled || d.ObserveOnly || !slices.Contains(sources, d.Name) { continue } e, err := drivers.ParseCatalogFile(d.Lua) if err != nil { return "", fmt.Errorf("vehicle refresh: cannot read driver metadata") } - if !slices.Contains(e.Capabilities, "vehicle") { + if !slices.Contains(e.Capabilities, "vehicle") || e.ReadOnly || !e.TelemetryWake { continue } - if candidate != nil { - return "", fmt.Errorf("vehicle refresh is ambiguous with multiple configured vehicles") - } - candidate, entry = d, e - } - if candidate == nil { - return "", nil - } - if candidate.ObserveOnly || entry.ReadOnly || !entry.TelemetryWake { - return "", nil + return d.Name, nil } - return candidate.Name, nil + return "", nil } diff --git a/go/cmd/ftw/vehicle_refresh_test.go b/go/cmd/ftw/vehicle_refresh_test.go index 98e44b002..ce7fa56ef 100644 --- a/go/cmd/ftw/vehicle_refresh_test.go +++ b/go/cmd/ftw/vehicle_refresh_test.go @@ -11,7 +11,7 @@ import ( "time" ) -func TestVehicleRefreshSelectsOnlyUnambiguousAuthorizedConfiguration(t *testing.T) { +func TestVehicleRefreshUsesTheSessionCarsOwnSource(t *testing.T) { dir := t.TempDir() vehicle := func(name string, readOnly, wake bool) config.Driver { path := filepath.Join(dir, name+".lua") @@ -35,24 +35,29 @@ func TestVehicleRefreshSelectsOnlyUnambiguousAuthorizedConfiguration(t *testing. } tesla := vehicle("tesla", false, true) other := vehicle("other", true, false) + // Before cars, two vehicles or two connected chargers refused the + // refresh: nothing said which car was on which charger. Now the session's + // car names its own sources, and only those are candidates. cases := []struct { - name string - drivers []config.Driver - connected int - want string - wantErr bool + name string + drivers []config.Driver + sources []string + want string + wantErr bool }{ - {"missing or stale soc", []config.Driver{tesla}, 1, "tesla", false}, - {"two vehicles", []config.Driver{tesla, other}, 1, "", true}, - {"two chargers", []config.Driver{tesla}, 2, "", true}, - {"read only", []config.Driver{other}, 1, "", false}, - {"no wake declaration", []config.Driver{vehicle("legacy", false, false)}, 1, "", false}, - {"disabled other", []config.Driver{tesla, {Name: other.Name, Lua: other.Lua, Disabled: true}}, 1, "tesla", false}, - {"observe only", []config.Driver{{Name: tesla.Name, Lua: tesla.Lua, ObserveOnly: true}}, 1, "", false}, + {"missing or stale soc", []config.Driver{tesla}, []string{"tesla"}, "tesla", false}, + {"another car's vehicle beside it", []config.Driver{tesla, other}, []string{"tesla"}, "tesla", false}, + {"the session's car has no source", []config.Driver{tesla}, nil, "", false}, + {"a visitor's session", []config.Driver{tesla, other}, []string{}, "", false}, + {"read only", []config.Driver{other}, []string{"other"}, "", false}, + {"no wake declaration", []config.Driver{vehicle("legacy", false, false)}, []string{"legacy"}, "", false}, + {"disabled", []config.Driver{{Name: tesla.Name, Lua: tesla.Lua, Disabled: true}}, []string{"tesla"}, "", false}, + {"observe only", []config.Driver{{Name: tesla.Name, Lua: tesla.Lua, ObserveOnly: true}}, []string{"tesla"}, "", false}, + {"unreadable metadata", []config.Driver{{Name: "gone", Lua: filepath.Join(dir, "gone.lua")}}, []string{"gone"}, "", true}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - got, err := configuredVehicleRefreshDriver(tc.drivers, tc.connected) + got, err := configuredVehicleRefreshDriver(tc.drivers, tc.sources) if got != tc.want || (err != nil) != tc.wantErr { t.Fatalf("got=%q err=%v", got, err) } diff --git a/go/internal/api/api.go b/go/internal/api/api.go index 3b6500a52..d2aa2c459 100644 --- a/go/internal/api/api.go +++ b/go/internal/api/api.go @@ -33,6 +33,7 @@ import ( // this package through an interface and must never import it. "github.com/srcfl/ftw/go/internal/appproto" "github.com/srcfl/ftw/go/internal/battery" + "github.com/srcfl/ftw/go/internal/cars" "github.com/srcfl/ftw/go/internal/components" "github.com/srcfl/ftw/go/internal/config" "github.com/srcfl/ftw/go/internal/configreload" @@ -60,6 +61,7 @@ import ( "github.com/srcfl/ftw/go/internal/telemetry" "github.com/srcfl/ftw/go/internal/units" v2xpolicy "github.com/srcfl/ftw/go/internal/v2x" + "github.com/srcfl/ftw/go/protocol" ) const ( @@ -167,6 +169,10 @@ type Deps struct { // Optional: EV loadpoint state consumed by the API and MPC. Loadpoints *loadpoint.Manager + // Cars are the owners' cars apart from the chargers (docs/cars.md). + // Nil on a box without them: the charger routes then show no car. + Cars CarViews + // LoadpointCtrl is the dispatch controller. The diagnostics // endpoint POST /api/loadpoints/{id}/manual_hold uses it to // install a temporary override that bypasses the MPC budget @@ -521,6 +527,7 @@ 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("GET /api/cars", Read, s.handleCars) // 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. @@ -3715,9 +3722,7 @@ func (s *Server) loadpointsPayload(now time.Time) map[string]any { return map[string]any{"enabled": false, "loadpoints": []any{}, "vehicle_limit_goal_supported": true} } states := s.deps.Loadpoints.States() - if s.deps.Tel != nil { - decorateLoadpointsWithVehicle(states, s.deps.Tel) - } + decorateLoadpointsWithCar(states, s.deps.Cars, now) s.applyPlanningSoC(states, now) s.decorateLoadpointsWithManual(states) s.decorateLoadpointsWithBatteryBoost(states) @@ -3729,95 +3734,100 @@ func (s *Server) loadpointsPayload(now time.Time) map[string]any { } } -// applyPlanningSoC records the SoC a new plan starts from. It uses the -// same picker and timestamp rule as the MPC loadpoint spec, so the -// charging view's "Planning from" line matches the planner even when the -// displayed car reading is older than the live-control window. +// applyPlanningSoC records the level a new plan starts from: the session +// car's level, which the planner reads from the same loadpoint state. A +// visitor's session has none. func (s *Server) applyPlanningSoC(states []loadpoint.State, now time.Time) { - if s.deps == nil || s.deps.Loadpoints == nil { - return - } for i := range states { - if !states[i].PluggedIn { + if !states[i].PluggedIn || states[i].SoCUnknown { continue } - var pick telemetry.VehiclePick - if s.deps.Tel != nil { - delivering := states[i].CurrentPowerW > loadpoint.DeliveringW - pick = telemetry.PlanningVehiclePick(s.deps.Tel, delivering, now) - } - soc, fromVehicle := s.deps.Loadpoints.PlanningStartSoC(states[i].ID, pick.SoC, pick.UpdatedAt, pick.Driver != "") + // decorateLoadpointsWithCar has already put the level the car + // shows into CurrentSoC: the planner starts from the same number. + soc := states[i].CurrentSoC states[i].PlanningSoC = &soc - states[i].PlanningFromVehicle = fromVehicle + if s.deps != nil && s.deps.Cars != nil { + if v, ok := s.deps.Cars.Session(states[i].ID); ok { + states[i].PlanningFromVehicle = v.Source == protocol.CarSoCVehicle + } + } } } -// decorateLoadpointsWithVehicle overlays the best-matching DerVehicle -// reading onto each plugged-in loadpoint state. Mutates the input -// slice in place. Picker (rank + freshness + bounds) lives in -// telemetry.PickBestVehicleForLoadpoint so main.go's MPC plumbing and -// this presentation path agree on which vehicle is "the one". -// -// Pairing is decided per loadpoint: when a loadpoint is currently -// delivering power, the pick is gated on charging_state ∈ -// {Charging, Starting}. That prevents a second vehicle (parked at -// home, returning SoC, but not on this charger) from winning the -// pick on freshness alone and flipping the loadpoint's SoC source -// every tick — the failure mode observed with two Teslas in the same -// household. -// -// CurrentSoC is intentionally NOT overwritten with the BMS reading. -// The loadpoint controller uses CurrentSoC as its inference state; -// overlaying it from the BMS would mean the UI shows BMS truth while -// the controller's plan was computed from the inferred value the -// previous tick — a presentation lie. VehicleSoC exposes the BMS -// value separately; the frontend renders both and labels which one -// the controller used. -func decorateLoadpointsWithVehicle(states []loadpoint.State, tel *telemetry.Store) { - if len(tel.ReadingsByType(telemetry.DerVehicle)) == 0 { - // No vehicle drivers — mark every plugged-in lp as inferred, - // unless the manager already says why the estimate is where it - // is ("completed": pinned to target after the car declined). - for i := range states { - if states[i].PluggedIn && states[i].SoCSource == "" { - states[i].SoCSource = "inferred" - } +// CarViews is the cars runtime as the routes need it. +type CarViews interface { + Cars() []protocol.Car + Removed() []cars.Tombstone + Session(lpID string) (cars.SessionView, bool) + SetViewCarCapacity(lpID string, capacityWh float64) error +} + +// handleCars is GET /api/cars: every car the box keeps, in the registry's +// names spelled in snake_case, and the ids an owner removed. A caller +// without ftw.cars.read (a viewer, a watch) sees each car's id, name, level, +// goal and surplus choice, and nothing that says which car it is. +func (s *Server) handleCars(w http.ResponseWriter, r *http.Request) { + list, removed := []protocol.Car{}, []cars.Tombstone{} + if s.deps.Cars != nil { + list, removed = s.deps.Cars.Cars(), s.deps.Cars.Removed() + } + if caller, ok := apiauth.FromRequest(r); !ok || !caller.Scopes.Has(protocol.ScopeCarsRead) { + for i, c := range list { + list[i] = protocol.Car{ID: c.ID, Name: c.Name, SocPermille: c.SocPermille, SocSource: c.SocSource, + SocAgeS: c.SocAgeS, EnergySinceWh: c.EnergySinceWh, SocEstimate: c.SocEstimate, + ChargeLimitPermille: c.ChargeLimitPermille, Goal: c.Goal, SurplusOnly: c.SurplusOnly} } - return } - now := time.Now() + writeJSON(w, http.StatusOK, map[string]any{"cars": list, "removed": removed}) +} + +// decorateLoadpointsWithCar shows each plugged-in charger's level as its +// session's car has it. The car's own reading counts as the car's +// ("vehicle") until more than 1 kWh has flowed since it, however old it is; +// past that, or from a level someone set, the level is "inferred". A +// visitor's session stays "unknown", and a level the manager pinned at +// completion stays "completed". CurrentSoC becomes the level the car shows, +// which the planner starts from too; the loadpoint keeps its own estimate +// for the controller. +func decorateLoadpointsWithCar(states []loadpoint.State, views CarViews, now time.Time) { for i := range states { - if !states[i].PluggedIn { + st := &states[i] + if !st.PluggedIn || st.SoCUnknown || st.SoCSource == "completed" { continue } - delivering := states[i].CurrentPowerW > loadpoint.DeliveringW - pick := telemetry.PickBestVehicleForLoadpoint(tel, delivering, now) - freshVehicle := pick.Driver != "" - if !freshVehicle { - pick = telemetry.PickBestVehicleForDisplay(tel, delivering, now) - if pick.Driver == "" { - if states[i].SoCSource == "" { - states[i].SoCSource = "inferred" - } - continue - } + var v cars.SessionView + ok := false + if views != nil { + v, ok = views.Session(st.ID) } - states[i].VehicleDriver = pick.Driver - states[i].VehicleSoC = pick.SoC - states[i].VehicleChargeLimit = pick.ChargeLimit - states[i].VehicleChargingState = pick.ChargingState - states[i].VehicleStale = pick.Stale - if !pick.UpdatedAt.IsZero() { - age := now.Sub(pick.UpdatedAt) - if age < 0 { - age = 0 + if !ok || v.Source == "" { + if st.SoCSource == "" { + st.SoCSource = "inferred" } - states[i].VehicleSoCAgeS = int64(age / time.Second) + continue + } + // The mapping in docs/cars.md: a shipped app reads "vehicle" as the + // car's own number, so an estimate is "inferred", and the level is + // the one the car shows and the planner starts from. + st.CurrentSoC = v.Shown + switch { + case v.Source == protocol.CarSoCVehicle && !v.Estimate: + st.SoCSource = "vehicle" + case v.Source == protocol.CarSoCAssumed: + st.SoCSource = "assumed" + default: + st.SoCSource = "inferred" + } + if v.Source != protocol.CarSoCVehicle { + continue } - if freshVehicle { - states[i].SoCSource = "vehicle" - } else if states[i].SoCSource == "" { - states[i].SoCSource = "inferred" + st.VehicleDriver = v.Driver + st.VehicleSoC = v.SoC + st.VehicleChargeLimit = v.ChargeLimit + st.VehicleChargingState = v.ChargingState + st.VehicleStale = v.Estimate + if !v.At.IsZero() { + st.VehicleSoCAgeS = int64(max(0, now.Sub(v.At)) / time.Second) } } } diff --git a/go/internal/api/api_cloud.go b/go/internal/api/api_cloud.go index 539dd0129..39a5d21ae 100644 --- a/go/internal/api/api_cloud.go +++ b/go/internal/api/api_cloud.go @@ -220,9 +220,7 @@ func (s *Server) CloudLoadpoints(now time.Time) (states []loadpoint.State, feedb return nil, nil, false } states = s.deps.Loadpoints.States() - if s.deps.Tel != nil { - decorateLoadpointsWithVehicle(states, s.deps.Tel) - } + decorateLoadpointsWithCar(states, s.deps.Cars, now) s.applyPlanningSoC(states, now) s.decorateLoadpointsWithManual(states) s.decorateLoadpointsWithBatteryBoost(states) diff --git a/go/internal/api/api_control_feedback.go b/go/internal/api/api_control_feedback.go index 0e7d1ced2..73826f215 100644 --- a/go/internal/api/api_control_feedback.go +++ b/go/internal/api/api_control_feedback.go @@ -533,6 +533,9 @@ func batteryChargeLevelLimit(f *ControlFeedback) string { // evidence to the existing endpoint without changing stored loadpoint state. type loadpointFeedbackView struct { loadpoint.State + // Level is current_soc, left out for a visitor's car: nobody knows its + // level, and the box does not guess one (docs/cars.md). + Level *float64 `json:"current_soc,omitempty"` ControlFeedback *ControlFeedback `json:"control_feedback,omitempty"` } @@ -546,7 +549,12 @@ func (s *Server) loadpointsWithFeedback(states []loadpoint.State) []loadpointFee } out := make([]loadpointFeedbackView, 0, len(states)) for _, st := range states { - out = append(out, loadpointFeedbackView{st, byDriver[st.DriverName]}) + view := loadpointFeedbackView{State: st, ControlFeedback: byDriver[st.DriverName]} + if !st.SoCUnknown { + level := st.CurrentSoC + view.Level = &level + } + out = append(out, view) } return out } diff --git a/go/internal/api/api_loadpoint_planning_soc_test.go b/go/internal/api/api_loadpoint_planning_soc_test.go index 6ca0657d5..27b31799c 100644 --- a/go/internal/api/api_loadpoint_planning_soc_test.go +++ b/go/internal/api/api_loadpoint_planning_soc_test.go @@ -1,56 +1,54 @@ package api import ( - "encoding/json" "math" "testing" "time" + "github.com/srcfl/ftw/go/internal/cars" "github.com/srcfl/ftw/go/internal/loadpoint" - "github.com/srcfl/ftw/go/internal/telemetry" + "github.com/srcfl/ftw/go/protocol" ) +// The charging view's "Planning from" line is the level the planner starts +// from. Both now read the loadpoint's level, which the cars runtime anchors +// to the session car's own reading (docs/cars.md), so they cannot differ. func TestLoadpointPlanningSoCMatchesPlannerChoice(t *testing.T) { mgr := loadpoint.NewManager() mgr.Load([]loadpoint.Config{{ID: "garage", VehicleCapacityWh: 60000, PluginSoC: 0.27}}) - manualAt := time.Now().Add(-2 * time.Hour) - mgr.SetNowFn(func() time.Time { return manualAt }) + readAt := time.Now().Add(-34 * time.Minute) mgr.Observe("garage", true, 0, 0, true) - if !mgr.SetCurrentSoC("garage", 0.27) { - t.Fatal("SetCurrentSoC") + if !mgr.AnchorVehicleSoCAt("garage", .78, readAt) && !mgr.SeedSessionLevel("garage", + loadpoint.LevelSeed{Known: true, SoC: .78, Confirmed: true, ConfirmedAt: readAt, FromCar: true}) { + t.Fatal("anchor") } - tel := telemetry.NewStore() - soc := .78 - tel.Update("vag", telemetry.DerVehicle, 0, &soc, json.RawMessage(`{"soc":78,"soc_fresh":false,"stale":true,"charging_state":"Stopped"}`)) - tel.EmitMetric("vag", "vehicle_soc_age_s", (34 * time.Minute).Seconds(), "s", "", "") - tel.DriverHealthMut("vag").RecordSuccess() - _, receivedAt, _ := tel.LatestMetric("vag", "vehicle_soc_age_s") - now := receivedAt.Add(time.Second) - - srv := New(&Deps{Loadpoints: mgr, Tel: tel}) + views := fakeCars{"garage": cars.SessionView{CarID: "c1", Source: protocol.CarSoCVehicle, At: readAt, SoC: .78}} + srv := New(&Deps{Loadpoints: mgr, Cars: views}) states := mgr.States() - srv.applyPlanningSoC(states, now) + srv.applyPlanningSoC(states, time.Now()) if states[0].PlanningSoC == nil || math.Abs(*states[0].PlanningSoC-.78) > 1e-9 || !states[0].PlanningFromVehicle { - t.Fatalf("planning view = %+v, want 0.78 from the car", states[0].PlanningSoC) + t.Fatalf("planning view = %v, want 0.78 from the car", states[0].PlanningSoC) } - tel = telemetry.NewStore() - tel.Update("vag", telemetry.DerVehicle, 0, &soc, json.RawMessage(`{"soc":78,"soc_fresh":false,"stale":true,"charging_state":"Stopped"}`)) - tel.EmitMetric("vag", "vehicle_soc_age_s", (telemetry.VehicleAnchorMaxAge + time.Minute).Seconds(), "s", "", "") - tel.DriverHealthMut("vag").RecordSuccess() - _, receivedAt, _ = tel.LatestMetric("vag", "vehicle_soc_age_s") - srv = New(&Deps{Loadpoints: mgr, Tel: tel}) + // A level someone set is the plan's start too, but not the car's word. + if !mgr.SetCurrentSoC("garage", .27) { + t.Fatal("SetCurrentSoC") + } + views["garage"] = cars.SessionView{CarID: "c1", Source: protocol.CarSoCManual, SoC: .27} states = mgr.States() - srv.applyPlanningSoC(states, receivedAt.Add(time.Second)) + srv.applyPlanningSoC(states, time.Now()) if states[0].PlanningSoC == nil || math.Abs(*states[0].PlanningSoC-.27) > 1e-9 || states[0].PlanningFromVehicle { - t.Fatalf("stale-beyond-window planning view = %+v fromVehicle=%v", states[0].PlanningSoC, states[0].PlanningFromVehicle) + t.Fatalf("manual planning view = %v fromVehicle=%v", states[0].PlanningSoC, states[0].PlanningFromVehicle) } - srv = New(&Deps{Loadpoints: mgr, Tel: telemetry.NewStore()}) + // A visitor has no level, so no plan starts from one. + if !mgr.SeedSessionLevel("garage", loadpoint.LevelSeed{}) { + t.Fatal("visitor seed") + } states = mgr.States() - srv.applyPlanningSoC(states, now) - if states[0].PlanningSoC == nil || math.Abs(*states[0].PlanningSoC-.27) > 1e-9 || states[0].PlanningFromVehicle { - t.Fatalf("no-driver planning view = %+v fromVehicle=%v", states[0].PlanningSoC, states[0].PlanningFromVehicle) + srv.applyPlanningSoC(states, time.Now()) + if states[0].PlanningSoC != nil { + t.Fatalf("visitor planning view = %v, want none", *states[0].PlanningSoC) } } diff --git a/go/internal/api/api_loadpoint_soc_source_test.go b/go/internal/api/api_loadpoint_soc_source_test.go index da742b2e5..1862c9004 100644 --- a/go/internal/api/api_loadpoint_soc_source_test.go +++ b/go/internal/api/api_loadpoint_soc_source_test.go @@ -2,23 +2,39 @@ package api import ( "encoding/json" + "net/http" + "net/http/httptest" "testing" + "time" + "github.com/srcfl/ftw/go/internal/apiauth" + "github.com/srcfl/ftw/go/internal/cars" "github.com/srcfl/ftw/go/internal/loadpoint" - "github.com/srcfl/ftw/go/internal/telemetry" + "github.com/srcfl/ftw/go/protocol" ) +// fakeCars answers Session from a table, as the cars runtime would. +type fakeCars map[string]cars.SessionView + +func (f fakeCars) Cars() []protocol.Car { return nil } +func (f fakeCars) Removed() []cars.Tombstone { return nil } +func (f fakeCars) Session(lp string) (cars.SessionView, bool) { + v, ok := f[lp] + return v, ok +} +func (f fakeCars) SetViewCarCapacity(string, float64) error { return nil } + // The manager reports "completed" when the completion latch pinned the -// estimate to the target. The vehicle decoration used to overwrite that -// with "inferred" on every site without a vehicle driver, so the modal -// could never say why the slider sat where it sat (#1059). +// estimate to the target. The decoration used to overwrite that with +// "inferred" on every site without a vehicle driver, so the modal could +// never say why the slider sat where it sat (#1059). func TestDecorateLoadpointsKeepsCompletedSoCSource(t *testing.T) { states := []loadpoint.State{ {ID: "a", PluggedIn: true, SoCSource: "completed"}, {ID: "b", PluggedIn: true}, {ID: "c", PluggedIn: false}, } - decorateLoadpointsWithVehicle(states, telemetry.NewStore()) + decorateLoadpointsWithCar(states, fakeCars{}, time.Now()) if states[0].SoCSource != "completed" { t.Errorf("a: SoCSource = %q, want completed kept", states[0].SoCSource) } @@ -30,16 +46,129 @@ func TestDecorateLoadpointsKeepsCompletedSoCSource(t *testing.T) { } } -func TestDecorateLoadpointsDisplaysCachedVWAfterRestart(t *testing.T) { - tel := telemetry.NewStore() - soc := .98 - tel.Update("audi-vag", telemetry.DerVehicle, 0, &soc, json.RawMessage(`{"soc":98,"soc_fresh":false,"stale":true,"charging_state":"Stopped"}`)) - tel.EmitMetric("audi-vag", "vehicle_soc_age_s", 156022, "s", "", "") - tel.DriverHealthMut("audi-vag").RecordSuccess() - states := []loadpoint.State{{ID: "easee", PluggedIn: true, CurrentSoC: .48}} - decorateLoadpointsWithVehicle(states, tel) +// Fredrik's box on 15 October: the Tesla read 14 % nine minutes ago and +// nothing has flowed since. Before cars the reading aged out after five +// minutes and the level showed as "inferred". It is still the car's level. +func TestDecorateLoadpointsKeepsAnOldReadingWithNoEnergySinceFromTheCar(t *testing.T) { + now := time.Date(2026, 10, 15, 16, 40, 0, 0, time.UTC) + read := now.Add(-9 * time.Minute) + views := fakeCars{"easee": {CarID: "c1", Source: protocol.CarSoCVehicle, At: read, SoC: .14, Shown: .14, ChargeLimit: .8, + Driver: "tesla-ha", ChargingState: "Stopped", DriverAt: read}} + states := []loadpoint.State{{ID: "easee", PluggedIn: true, CurrentSoC: .14}} + decorateLoadpointsWithCar(states, views, now) got := states[0] - if got.VehicleDriver != "audi-vag" || got.VehicleSoC != .98 || !got.VehicleStale || got.VehicleSoCAgeS < 156022 || got.CurrentSoC != .48 || got.SoCSource != "inferred" { - t.Fatalf("cached display/unchanged control: %+v", got) + if got.SoCSource != "vehicle" || got.VehicleStale || got.VehicleDriver != "tesla-ha" || got.VehicleSoC != .14 || + got.VehicleSoCAgeS != 540 || got.VehicleChargeLimit != .8 || got.CurrentSoC != .14 { + t.Fatalf("a nine-minute-old reading with nothing delivered since: %+v", got) + } +} + +// Past the box's limit of energy since the reading, the same level is an +// estimate. The controller's level is never overwritten by the display. +func TestDecorateLoadpointsShowsAnEstimatePastTheEnergyLimit(t *testing.T) { + now := time.Now() + views := fakeCars{"easee": {CarID: "c1", Source: protocol.CarSoCVehicle, Estimate: true, At: now.Add(-time.Hour), + SoC: .14, Shown: .22, Driver: "tesla-ha"}} + states := []loadpoint.State{{ID: "easee", PluggedIn: true, CurrentSoC: .22}} + decorateLoadpointsWithCar(states, views, now) + if got := states[0]; got.SoCSource != "inferred" || !got.VehicleStale || got.CurrentSoC != .22 || got.VehicleSoC != .14 { + t.Fatalf("estimate: %+v", got) + } +} + +// While a reading is exact, the level shown and planned from is the car's own +// number, not the loadpoint's estimate of 0.9 kWh more. +func TestDecorateLoadpointsShowsTheCarsOwnNumberWhileExact(t *testing.T) { + views := fakeCars{"easee": {CarID: "c1", Source: protocol.CarSoCVehicle, At: time.Now(), SoC: .14, Shown: .14}} + states := []loadpoint.State{{ID: "easee", PluggedIn: true, CurrentSoC: .151}} + decorateLoadpointsWithCar(states, views, time.Now()) + if got := states[0]; got.CurrentSoC != .14 || got.SoCSource != "vehicle" { + t.Fatalf("exact: %+v", got) + } +} + +// A visitor's car has no level, and the route leaves current_soc out +// rather than showing the energy delivered as one. +func TestLoadpointRouteLeavesAVisitorsLevelOut(t *testing.T) { + states := []loadpoint.State{{ID: "easee", PluggedIn: true, SoCUnknown: true, SoCSource: "unknown", CurrentSoC: .05}} + decorateLoadpointsWithCar(states, fakeCars{}, time.Now()) + if states[0].SoCSource != "unknown" { + t.Fatalf("visitor source = %q", states[0].SoCSource) + } + s := &Server{deps: &Deps{}} + view := s.loadpointsWithFeedback(states)[0] + if view.Level != nil { + t.Fatalf("visitor level = %v, want none", *view.Level) + } +} + +// recordingCars is fakeCars that keeps what the routes asked of it. +type recordingCars struct { + fakeCars + cars []protocol.Car + capacity map[string]float64 +} + +func (r *recordingCars) Cars() []protocol.Car { + return append([]protocol.Car(nil), r.cars...) +} +func (r *recordingCars) SetViewCarCapacity(lp string, wh float64) error { + r.capacity[lp] = wh + return nil +} + +// GET /api/cars lists the cars in the registry's names spelled in +// snake_case, as every route spells them. +func TestCarsRouteListsTheCars(t *testing.T) { + soc := int64(140) + views := &recordingCars{cars: []protocol.Car{{ID: "c1", Name: "Car 1", VIN: "XP7YGCEK7SB736799", CapacityWh: 76000, SocPermille: &soc, + SocSource: protocol.CarSoCVehicle, Goal: &protocol.CarGoal{TargetPermille: 800, ReadyByMin: 420, Days: 0x7F}, + Sources: []string{"tesla-ha"}}}} + srv := New(&Deps{Cars: views}) + rr := httptest.NewRecorder() + srv.Handler().ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/api/cars", nil)) + if rr.Code != http.StatusOK { + t.Fatalf("status %d: %s", rr.Code, rr.Body.String()) + } + var got struct { + Cars []map[string]any `json:"cars"` + } + if err := json.Unmarshal(rr.Body.Bytes(), &got); err != nil || len(got.Cars) != 1 { + t.Fatalf("body %s: %v", rr.Body.String(), err) + } + c := got.Cars[0] + goal, _ := c["goal"].(map[string]any) + if c["soc_permille"] != 140.0 || c["soc_source"] != "vehicle" || c["capacity_wh"] != 76000.0 || goal["ready_by_min"] != 420.0 || + c["vin"] != "XP7YGCEK7SB736799" { + t.Fatalf("an owner's car %v", c) + } + + // A viewer sees the level and the goal, and nothing that says which car. + rr = httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/cars", nil) + req = req.WithContext(apiauth.WithCaller(req.Context(), apiauth.Caller{Role: apiauth.RoleViewer, + Scopes: apiauth.NewScopeSet(apiauth.RoleScopes[apiauth.RoleViewer]...)})) + srv.Handler().ServeHTTP(rr, req) + got.Cars = nil + if err := json.Unmarshal(rr.Body.Bytes(), &got); err != nil || len(got.Cars) != 1 { + t.Fatalf("viewer body %s: %v", rr.Body.String(), err) + } + c = got.Cars[0] + if c["soc_permille"] != 140.0 || c["name"] != "Car 1" || c["vin"] != nil || c["sources"] != nil || c["capacity_wh"] != 0.0 { + t.Fatalf("a viewer's car %v", c) + } +} + +// For one release the battery size route sets the size of the car the +// charger shows, and still writes the config so a rollback finds it. +func TestTheBatterySizeRouteSetsTheCarsCapacity(t *testing.T) { + srv, _ := vehicleServer(t) + views := &recordingCars{capacity: map[string]float64{}} + srv.deps.Cars = views + if rr := postVehicle(srv, "garage", `{"capacity_wh":77400}`); rr.Code != http.StatusOK { + t.Fatalf("status %d: %s", rr.Code, rr.Body.String()) + } + if views.capacity["garage"] != 77400 || srv.deps.Cfg.Loadpoints[0].VehicleCapacityWh != 77400 { + t.Fatalf("car %v, config %v", views.capacity, srv.deps.Cfg.Loadpoints[0].VehicleCapacityWh) } } diff --git a/go/internal/api/api_notifications_push_test.go b/go/internal/api/api_notifications_push_test.go index 904bb7c38..c0510440c 100644 --- a/go/internal/api/api_notifications_push_test.go +++ b/go/internal/api/api_notifications_push_test.go @@ -387,12 +387,14 @@ func TestRulesPutSeedsDefaultsOnFirstTouch(t *testing.T) { t.Fatalf("defaults not seeded: %+v", notif) } // Everything is opt-in except the FTW Cloud and agent notices, which - // every owner should hear without having asked. + // every owner should hear without having asked, and car.which: with + // several cars and no answer, the box plans for a visitor until someone + // taps, and only this asks (docs/cars.md). onByDefault := map[string]bool{ notifications.PushCloudGranted: true, notifications.PushCloudRevoked: true, notifications.PushAgentAdded: true, notifications.PushAgentRequest: true, notifications.PushAgentExpiring: true, notifications.PushApprovalKeySet: true, - notifications.PushOwnerAdded: true, + notifications.PushOwnerAdded: true, notifications.PushCarWhich: true, } enabled := 0 for _, e := range notif.Events { diff --git a/go/internal/api/loadpoint_vehicle.go b/go/internal/api/loadpoint_vehicle.go index 1da7f79e4..a25634f55 100644 --- a/go/internal/api/loadpoint_vehicle.go +++ b/go/internal/api/loadpoint_vehicle.go @@ -62,6 +62,15 @@ func (s *Server) handleLoadpointVehicle(w http.ResponseWriter, r *http.Request) } s.deps.Loadpoints.Load(points) } + if s.deps.Cars != nil { + // The size is the car's (docs/cars.md). The config keeps it too, so + // a box rolled back to before cars finds it where it was; the car + // changes only once the config holds it. + if err := s.deps.Cars.SetViewCarCapacity(id, body.CapacityWh); err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "Battery size was saved for the charger but not for its car. Try again."}) + return + } + } s.replanForScheduleChange(id) writeJSON(w, http.StatusOK, map[string]any{"ok": true, "vehicle_capacity_wh": body.CapacityWh, "capacity_source": "configured"}) } diff --git a/go/internal/appproto/cars.go b/go/internal/appproto/cars.go new file mode 100644 index 000000000..beb4c9829 --- /dev/null +++ b/go/internal/appproto/cars.go @@ -0,0 +1,452 @@ +package appproto + +import ( + "errors" + + "github.com/srcfl/ftw/go/internal/cars" + "github.com/srcfl/ftw/go/internal/units" + "github.com/srcfl/ftw/go/protocol" +) + +// carOp runs the car ops (docs/cars.md). Arguments follow the box's +// convention: snake_case, id for what the op acts on, a level as a fraction. +// Each reads its arguments and refuses a bad one before taking the command, +// and reports the car or the charger's car as the box holds it afterwards. +func (h *Handler) carOp(cmd Cmd, uptimeMs int64) error { + c := h.cfg.Cars + if c == nil { + return h.sendCmdResult(CmdResult{ + CmdID: cmd.CmdID, State: CmdRejected, + Error: &ErrorBody{Code: ErrUnavailable, Retryable: ErrorRetryable[ErrUnavailable], + Args: map[string]any{"subsystem": "cars"}}, + }) + } + id, _ := cmd.Args["id"].(string) + charger, _ := cmd.Args["charger_id"].(string) + + var act func() (CmdResult, error) + switch cmd.Op { + case OpCarGoalSet: + if (id == "") == (charger == "") { + return h.rejectArg(cmd, "id", cmd.Args["id"]) + } + goal, clear, bad := goalArgs(cmd.Args) + if bad != "" { + return h.rejectArg(cmd, bad, cmd.Args[bad]) + } + surplus, bad := optBool(cmd.Args, "surplus_only") + if bad != "" { + return h.rejectArg(cmd, bad, cmd.Args[bad]) + } + act = func() (CmdResult, error) { + if charger != "" { + held, err := c.SetSessionGoal(charger, goal, clear, surplus) + return chargerResult(cmd, held, err) + } + car, err := c.SetGoal(id, goal, clear, surplus) + return carResult(cmd, car, err) + } + case OpCarSoCSet: + if (id == "") == (charger == "") { + return h.rejectArg(cmd, "id", cmd.Args["id"]) + } + soc, ok := argNum(cmd.Args, "soc") + if !ok || soc < 0 || soc > 1 { + return h.rejectArg(cmd, "soc", cmd.Args["soc"]) + } + act = func() (CmdResult, error) { + if charger != "" { + held, err := c.SetSessionLevel(charger, soc) + res, err := chargerResult(cmd, held, err) + if res.State == CmdApplied && h.cfg.Loadpoints != nil { + if level, ok := h.cfg.Loadpoints.ObservedSoC(charger); ok { + res.Observed = &Observed{Value: level, Src: ObservedSrcCore, UptimeMs: h.cfg.Clock.UptimeMs()} + } + } + return res, err + } + car, err := c.SetLevel(id, soc) + res, err := carResult(cmd, car, err) + if res.Car != nil && res.Car.SocPermille != nil { + res.Observed = &Observed{Value: float64(*res.Car.SocPermille) / 1000, Src: ObservedSrcCore, UptimeMs: h.cfg.Clock.UptimeMs()} + } + return res, err + } + case OpSessionCarSet: + session, _ := cmd.Args["session"].(string) + car, ok := cmd.Args["car_id"].(string) + switch { + case id == "": + return h.rejectArg(cmd, "id", cmd.Args["id"]) + case session == "": + return h.rejectArg(cmd, "session", cmd.Args["session"]) + case !ok: + return h.rejectArg(cmd, "car_id", cmd.Args["car_id"]) + } + act = func() (CmdResult, error) { + held, err := c.SetSessionCar(id, session, car) + return chargerResult(cmd, held, err) + } + case OpCarAdd: + car, bad := carArgs(cmd.Args) + if bad != "" { + return h.rejectArg(cmd, bad, cmd.Args[bad]) + } + act = func() (CmdResult, error) { + added, err := c.Add(car) + return carResult(cmd, added, err) + } + case OpCarUpdate: + u, bad := carUpdateArgs(cmd.Args) + if bad != "" { + return h.rejectArg(cmd, bad, cmd.Args[bad]) + } + act = func() (CmdResult, error) { + car, err := c.UpdateCar(id, u) + return carResult(cmd, car, err) + } + case OpCarRemove: + if id == "" { + return h.rejectArg(cmd, "id", cmd.Args["id"]) + } + act = func() (CmdResult, error) { + if err := c.Remove(id); err != nil { + return carResult(cmd, protocol.Car{}, err) + } + return CmdResult{CmdID: cmd.CmdID, State: CmdApplied}, nil + } + } + + if took, err := h.acceptCmd(cmd, uptimeMs); !took || err != nil { + return err + } + res, err := act() + if err != nil { + // A save the box could not confirm: it may hold the change or not. + return h.settleAndReport(cmd.CmdID, CmdResult{ + CmdID: cmd.CmdID, State: CmdUnconfirmed, + Error: &ErrorBody{Code: ErrUnavailable, Retryable: ErrorRetryable[ErrUnavailable], + Args: map[string]any{"op": cmd.Op, "reason": "persistence_unconfirmed"}}, + }) + } + return h.settleAndReport(cmd.CmdID, res) +} + +// carResult reports a car op: the car read back, or a refusal. err is +// returned only for a save that failed. +func carResult(cmd Cmd, car protocol.Car, err error) (CmdResult, error) { + if err != nil { + if res, ok := carRefusal(cmd, err); ok { + return res, nil + } + return CmdResult{}, err + } + brief := briefCar(car) + return CmdResult{CmdID: cmd.CmdID, State: CmdApplied, Car: &brief}, nil +} + +// briefCar is the car a cmd.result carries: its id, level, goal and surplus +// choice. A result rides lane 0, whose smallest bucket is 256 B; the app +// reads the rest from GET /api/cars. +func briefCar(c protocol.Car) protocol.Car { + return protocol.Car{ID: c.ID, SocPermille: c.SocPermille, SocSource: c.SocSource, SocAgeS: c.SocAgeS, + EnergySinceWh: c.EnergySinceWh, SocEstimate: c.SocEstimate, ChargeLimitPermille: c.ChargeLimitPermille, + Goal: c.Goal, SurplusOnly: c.SurplusOnly} +} + +func chargerResult(cmd Cmd, held protocol.ChargerCar, err error) (CmdResult, error) { + if err != nil { + if res, ok := carRefusal(cmd, err); ok { + return res, nil + } + return CmdResult{}, err + } + return CmdResult{CmdID: cmd.CmdID, State: CmdApplied, Charger: &held}, nil +} + +// carRefusal names why, in machine form, after the ack, as the loadpoint ops +// do (docs/cars.md, Ops). ok is false for an error that is not a refusal. +func carRefusal(cmd Cmd, err error) (CmdResult, bool) { + var code, reason string + switch { + case errors.Is(err, cars.ErrUnknownCar): + code, reason = ErrNotFound, "car" + case errors.Is(err, cars.ErrUnknownCharger): + code, reason = ErrNotFound, "charger" + case errors.Is(err, cars.ErrSessionEnded): + code, reason = ErrPrecondition, "session" + case errors.Is(err, cars.ErrUnplugged): + code, reason = ErrUnavailable, "unplugged" + case errors.Is(err, cars.ErrDuplicate): + code, reason = ErrDuplicate, "duplicate" + case errors.Is(err, cars.ErrElsewhere): + code, reason = ErrPrecondition, "elsewhere" + case errors.Is(err, cars.ErrTooMany): + code, reason = protocol.ErrCapExceeded, "cars" + case errors.Is(err, cars.ErrInvalid): + code, reason = ErrPrecondition, "invalid" + default: + return CmdResult{}, false + } + return CmdResult{ + CmdID: cmd.CmdID, State: CmdRejected, + Error: &ErrorBody{Code: code, Retryable: ErrorRetryable[code], Args: map[string]any{"op": cmd.Op, "reason": reason}}, + }, true +} + +// goalArgs reads car.goal.set's goal, flat as the box's other ops take +// theirs: target, a fraction, and ready_by_min, with days and +// finish_at_limit, replace it; clear: true removes it. With none of them the +// goal stays as it was. +func goalArgs(args map[string]any) (goal *protocol.CarGoal, clear bool, bad string) { + if v, present := args["clear"]; present { + b, ok := v.(bool) + if !ok { + return nil, false, "clear" + } + clear = b + } + set := false + for _, k := range []string{"target", "ready_by_min", "days", "finish_at_limit"} { + if _, present := args[k]; present { + set = true + } + } + if !set { + return nil, clear, "" + } + if clear { + return nil, false, "clear" + } + target, ok := argNum(args, "target") + if !ok || target < 0 || target > 1 { + return nil, false, "target" + } + g := protocol.CarGoal{TargetPermille: units.PermilleFromFraction(target)} + if g.ReadyByMin, ok = argInt(args, "ready_by_min"); !ok || g.ReadyByMin < 0 || g.ReadyByMin >= 24*60 { + return nil, false, "ready_by_min" + } + if _, present := args["days"]; present { + days, ok := argInt(args, "days") + if !ok || days < 0 || days > 0x7F { + return nil, false, "days" + } + g.Days = uint8(days) + } + if v, present := args["finish_at_limit"]; present { + b, ok := v.(bool) + if !ok { + return nil, false, "finish_at_limit" + } + g.FinishAtLimit = b + } + return &g, false, "" +} + +// goalArg reads car.add's goal, a field of the car: absent is none, and a +// map of target_permille, ready_by_min, days and finish_at_limit sets it. +func goalArg(args map[string]any) (goal *protocol.CarGoal, clear bool, bad string) { + raw, present := args["goal"] + if !present { + return nil, false, "" + } + if raw == nil { + return nil, true, "" + } + m := stringKeys(raw) + if m == nil { + return nil, false, "goal" + } + g := protocol.CarGoal{} + var ok bool + if g.TargetPermille, ok = argInt(m, "target_permille"); !ok || g.TargetPermille < 0 || g.TargetPermille > 1000 { + return nil, false, "goal" + } + if g.ReadyByMin, ok = argInt(m, "ready_by_min"); !ok || g.ReadyByMin < 0 || g.ReadyByMin >= 24*60 { + return nil, false, "goal" + } + if _, present := m["days"]; present { + days, ok := argInt(m, "days") + if !ok || days < 0 || days > 0x7F { + return nil, false, "goal" + } + g.Days = uint8(days) + } + if v, present := m["finish_at_limit"]; present { + b, ok := v.(bool) + if !ok { + return nil, false, "goal" + } + g.FinishAtLimit = b + } + return &g, false, "" +} + +// stringKeys reads a CBOR map, which may arrive with any-typed keys. +func stringKeys(raw any) map[string]any { + switch m := raw.(type) { + case map[string]any: + return m + case map[any]any: + out := make(map[string]any, len(m)) + for k, v := range m { + if s, ok := k.(string); ok { + out[s] = v + } + } + return out + } + return nil +} + +func optBool(args map[string]any, k string) (*bool, string) { + raw, present := args[k] + if !present { + return nil, "" + } + b, ok := raw.(bool) + if !ok { + return nil, k + } + return &b, "" +} + +// carArgs reads car.add: id, which the owner's app mints, and the car's own +// fields without the level. +func carArgs(args map[string]any) (protocol.Car, string) { + var c protocol.Car + if c.ID, _ = args["id"].(string); c.ID == "" { + return c, "id" + } + u, bad := carUpdateArgs(args) + if bad != "" { + return c, bad + } + if u.Name != nil { + c.Name = *u.Name + } + if u.Make != nil { + c.Make = *u.Make + } + if u.VIN != nil { + c.VIN = *u.VIN + } + if u.CapacityWh != nil { + c.CapacityWh = int64(*u.CapacityWh) + } + if u.MaxW != nil { + c.MaxW = int64(*u.MaxW) + } + if u.Phases != nil { + c.Phases = int64(*u.Phases) + } + if u.Identifiers != nil { + c.Identifiers = *u.Identifiers + } + if u.Sources != nil { + c.Sources = *u.Sources + } + surplus, bad := optBool(args, "surplus_only") + if bad != "" { + return c, bad + } + if surplus != nil { + c.SurplusOnly = *surplus + } + goal, _, bad := goalArg(args) + if bad != "" { + return c, bad + } + c.Goal = goal + return c, "" +} + +// carUpdateArgs reads car.update: id, new_id for a car the box minted, and +// the fields that change. +func carUpdateArgs(args map[string]any) (cars.Update, string) { + var u cars.Update + if id, _ := args["id"].(string); id == "" { + return u, "id" + } + str := func(k string) (*string, string) { + raw, present := args[k] + if !present { + return nil, "" + } + s, ok := raw.(string) + if !ok { + return nil, k + } + return &s, "" + } + var bad string + for _, f := range []struct { + k string + dst **string + }{{"new_id", &u.NewID}, {"name", &u.Name}, {"make", &u.Make}, {"vin", &u.VIN}} { + if *f.dst, bad = str(f.k); bad != "" { + return u, bad + } + } + num := func(k string) (*float64, string) { + if _, present := args[k]; !present { + return nil, "" + } + v, ok := argInt(args, k) + if !ok || v < 0 { + return nil, k + } + f := float64(v) + return &f, "" + } + if u.CapacityWh, bad = num("capacity_wh"); bad != "" { + return u, bad + } + if u.MaxW, bad = num("max_w"); bad != "" { + return u, bad + } + if p, b := num("phases"); b != "" { + return u, b + } else if p != nil { + phases := int(*p) + u.Phases = &phases + } + for _, k := range []string{"identifiers", "sources"} { + if _, present := args[k]; !present { + continue + } + list, bad := stringList(args, k) + if bad != "" { + return u, bad + } + if list == nil { + list = []string{} + } + if k == "identifiers" { + u.Identifiers = &list + } else { + u.Sources = &list + } + } + return u, "" +} + +func stringList(args map[string]any, k string) ([]string, string) { + raw, present := args[k] + if !present || raw == nil { + return nil, "" + } + items, ok := raw.([]any) + if !ok { + return nil, k + } + out := make([]string, 0, len(items)) + for _, it := range items { + s, ok := it.(string) + if !ok { + return nil, k + } + out = append(out, s) + } + return out, "" +} diff --git a/go/internal/appproto/cars_test.go b/go/internal/appproto/cars_test.go new file mode 100644 index 000000000..7334dca10 --- /dev/null +++ b/go/internal/appproto/cars_test.go @@ -0,0 +1,354 @@ +package appproto + +import ( + "testing" + + "github.com/srcfl/ftw/go/internal/cars" + "github.com/srcfl/ftw/go/protocol" +) + +// fakeCars answers the car ops as the cars runtime does. +type fakeCars struct { + cars map[string]protocol.Car + plugged bool + // elsewhere is a car another charger has. + elsewhere string + tapped string + calls int + sessionGoal *protocol.CarGoal +} + +func newFakeCars() *fakeCars { + return &fakeCars{cars: map[string]protocol.Car{"c1": {ID: "c1", Name: "Tesla"}, "c2": {ID: "c2", Name: "Volvo"}}, plugged: true} +} + +func (f *fakeCars) Add(c protocol.Car) (protocol.Car, error) { + f.calls++ + if c.ID == "" { + c.ID = "minted" + } + f.cars[c.ID] = c + return c, nil +} + +func (f *fakeCars) UpdateCar(id string, u cars.Update) (protocol.Car, error) { + f.calls++ + c, ok := f.cars[id] + if !ok { + return protocol.Car{}, cars.ErrUnknownCar + } + if u.Name != nil { + c.Name = *u.Name + } + f.cars[id] = c + return c, nil +} + +func (f *fakeCars) Remove(id string) error { + f.calls++ + if _, ok := f.cars[id]; !ok { + return cars.ErrUnknownCar + } + delete(f.cars, id) + return nil +} + +func (f *fakeCars) SetGoal(id string, goal *protocol.CarGoal, clear bool, surplus *bool) (protocol.Car, error) { + f.calls++ + c, ok := f.cars[id] + if !ok { + return protocol.Car{}, cars.ErrUnknownCar + } + if goal != nil || clear { + c.Goal = goal + } + if surplus != nil { + c.SurplusOnly = *surplus + } + f.cars[id] = c + return c, nil +} + +func (f *fakeCars) SetLevel(id string, soc float64) (protocol.Car, error) { + f.calls++ + c, ok := f.cars[id] + if !ok { + return protocol.Car{}, cars.ErrUnknownCar + } + permille := int64(soc*1000 + 0.5) + c.SocPermille, c.SocSource = &permille, protocol.CarSoCManual + f.cars[id] = c + return c, nil +} + +func (f *fakeCars) SetSessionGoal(charger string, goal *protocol.CarGoal, clear bool, surplus *bool) (protocol.ChargerCar, error) { + f.calls++ + f.sessionGoal = goal + return protocol.ChargerCar{Session: "s1", CarIdentifiedBy: protocol.CarIdentifiedByNone}, nil +} + +func (f *fakeCars) SetSessionLevel(charger string, soc float64) (protocol.ChargerCar, error) { + f.calls++ + return protocol.ChargerCar{Session: "s1", CarIdentifiedBy: protocol.CarIdentifiedByNone}, nil +} + +func (f *fakeCars) SetSessionCar(charger, session, car string) (protocol.ChargerCar, error) { + f.calls++ + switch { + case charger != "lp1": + return protocol.ChargerCar{}, cars.ErrUnknownCharger + case !f.plugged: + return protocol.ChargerCar{}, cars.ErrUnplugged + case session != "s1": + return protocol.ChargerCar{}, cars.ErrSessionEnded + case car != "" && f.cars[car].ID == "": + return protocol.ChargerCar{}, cars.ErrUnknownCar + case car != "" && car == f.elsewhere: + return protocol.ChargerCar{}, cars.ErrElsewhere + } + f.tapped = car + return protocol.ChargerCar{Session: session, CarID: car, CarIdentifiedBy: protocol.CarIdentifiedByTap}, nil +} + +func newCarRig(t *testing.T) (*Handler, *fakeCars, *recorder) { + t.Helper() + h, _, rec, _ := newRig(t) + c := newFakeCars() + h.cfg.Cars = c + return h, c, rec +} + +// The one tap: a watch may make it, as it may charge. The result is the +// charger's car as the box holds it, not an echo. +func TestAWatchPicksTheCarWithOneTap(t *testing.T) { + h, c, rec := newCarRig(t) + h.cfg.Caller = watchCaller() + h.cfg.Grants = newWatchGrants() + subscribe(t, h, rec) + + deliver(t, h, MsgCmd, nil, cmdLoadpoint(OpSessionCarSet, "cmd-tap", map[string]any{"id": "lp1", "session": "s1", "car_id": "c2"}, 7, 200_000)) + + rec.only(t, MsgCmdAck) + res := body[CmdResult](t, rec.only(t, MsgCmdResult)) + if res.State != CmdApplied || res.Charger == nil || res.Charger.CarID != "c2" || + res.Charger.CarIdentifiedBy != protocol.CarIdentifiedByTap || c.tapped != "c2" { + t.Fatalf("tap result %+v, tapped %q", res, c.tapped) + } +} + +// The list of cars is the owner's. A watch and a viewer are refused before +// anything is acked; the goal and the level are a phone's to set, so a +// viewer is refused those too. +func TestOnlyAnOwnerKeepsTheListOfCars(t *testing.T) { + for _, c := range []struct { + name string + caller func(*Handler) + op string + args map[string]any + scope string + }{ + {"watch adds", func(h *Handler) { h.cfg.Caller, h.cfg.Grants = watchCaller(), newWatchGrants() }, + OpCarAdd, map[string]any{"id": "0123456789abcdef0123456789abcdef", "name": "Zoe"}, ScopeCarsWrite}, + {"watch removes", func(h *Handler) { h.cfg.Caller, h.cfg.Grants = watchCaller(), newWatchGrants() }, + OpCarRemove, map[string]any{"id": "c1"}, ScopeCarsWrite}, + {"viewer renames", func(h *Handler) { h.cfg.Caller, h.cfg.Grants = viewerCaller(), newViewerGrants() }, + OpCarUpdate, map[string]any{"id": "c1", "name": "X"}, ScopeCarsWrite}, + {"viewer sets a goal", func(h *Handler) { h.cfg.Caller, h.cfg.Grants = viewerCaller(), newViewerGrants() }, + OpCarGoalSet, map[string]any{"id": "c1", "surplus_only": true}, ScopeDispatchWrite}, + } { + t.Run(c.name, func(t *testing.T) { + h, fake, rec := newCarRig(t) + c.caller(h) + subscribe(t, h, rec) + deliver(t, h, MsgCmd, nil, cmdLoadpoint(c.op, "cmd-"+c.name, c.args, 7, 200_000)) + res := body[CmdResult](t, rec.only(t, MsgCmdResult)) + if res.State != CmdRejected || res.Error == nil || res.Error.Code != ErrScopeDenied || + res.Error.Args["needScope"] != c.scope { + t.Fatalf("result %+v", res) + } + if fake.calls != 0 || rec.has(MsgCmdAck) { + t.Fatal("a refused car op reached the cars") + } + }) + } +} + +func TestAnOwnerAddsSetsAGoalAndALevel(t *testing.T) { + h, c, rec := newCarRig(t) + subscribe(t, h, rec) + deliver(t, h, MsgCmd, nil, cmdLoadpoint(OpCarAdd, "cmd-add", map[string]any{ + "id": "0123456789abcdef0123456789abcdef", "name": "Zoe", "capacity_wh": int64(52000), + "identifiers": []any{"rfid-7"}, "goal": map[any]any{"target_permille": int64(800), "ready_by_min": int64(420), "days": int64(31)}, + }, 7, 200_000)) + res := body[CmdResult](t, rec.only(t, MsgCmdResult)) + // The result carries the car's id, level and goal, to fit lane 0; the + // app reads its name, VIN and identifiers from GET /api/cars. + if res.State != CmdApplied || res.Car == nil || res.Car.ID != "0123456789abcdef0123456789abcdef" || + res.Car.Goal == nil || res.Car.Goal.Days != 31 || res.Car.Name != "" || res.Car.Identifiers != nil { + t.Fatalf("add %+v", res) + } + if added := c.cars["0123456789abcdef0123456789abcdef"]; added.Name != "Zoe" || added.CapacityWh != 52000 || len(added.Identifiers) != 1 { + t.Fatalf("the box got %+v", added) + } + + rec.reset() + deliver(t, h, MsgCmd, nil, cmdLoadpoint(OpCarGoalSet, "cmd-goal", map[string]any{"id": "c1", "clear": true, "surplus_only": true}, 7, 200_000)) + res = body[CmdResult](t, rec.only(t, MsgCmdResult)) + if res.State != CmdApplied || res.Car == nil || res.Car.Goal != nil || !res.Car.SurplusOnly { + t.Fatalf("goal %+v", res) + } + + rec.reset() + deliver(t, h, MsgCmd, nil, cmdLoadpoint(OpCarSoCSet, "cmd-soc", map[string]any{"id": "c1", "soc": 0.14}, 7, 200_000)) + res = body[CmdResult](t, rec.only(t, MsgCmdResult)) + if res.State != CmdApplied || res.Observed == nil || res.Observed.Value != 0.14 || res.Car.SocSource != protocol.CarSoCManual { + t.Fatalf("level %+v", res) + } + + // A visitor's session: its own goal, through charger_id. + rec.reset() + deliver(t, h, MsgCmd, nil, cmdLoadpoint(OpCarGoalSet, "cmd-session-goal", map[string]any{"charger_id": "lp1", + "target": 0.5, "ready_by_min": int64(360), "days": int64(31)}, 7, 200_000)) + res = body[CmdResult](t, rec.only(t, MsgCmdResult)) + if res.State != CmdApplied || res.Charger == nil || c.sessionGoal == nil || c.sessionGoal.TargetPermille != 500 || + c.sessionGoal.ReadyByMin != 360 || c.sessionGoal.Days != 31 { + t.Fatalf("session goal %+v", res) + } + if c.calls != 4 { + t.Fatalf("%d calls", c.calls) + } +} + +// A refusal after the ack names why in machine form: no such car, nothing +// plugged in. A bad argument is refused before the ack. +func TestCarOpRefusals(t *testing.T) { + h, c, rec := newCarRig(t) + subscribe(t, h, rec) + + deliver(t, h, MsgCmd, nil, cmdLoadpoint(OpCarGoalSet, "cmd-x", map[string]any{"id": "nope", "surplus_only": true}, 7, 200_000)) + res := body[CmdResult](t, rec.only(t, MsgCmdResult)) + if res.State != CmdRejected || res.Error.Code != ErrNotFound || res.Error.Args["reason"] != "car" { + t.Fatalf("unknown car %+v", res) + } + + // A tap for a plug-in that has ended. + rec.reset() + deliver(t, h, MsgCmd, nil, cmdLoadpoint(OpSessionCarSet, "cmd-old", map[string]any{"id": "lp1", "session": "s0", "car_id": "c1"}, 7, 200_000)) + res = body[CmdResult](t, rec.only(t, MsgCmdResult)) + if res.State != CmdRejected || res.Error.Code != ErrPrecondition || res.Error.Args["reason"] != "session" { + t.Fatalf("ended plug-in %+v", res) + } + + // A car another charger has. + rec.reset() + c.elsewhere = "c2" + deliver(t, h, MsgCmd, nil, cmdLoadpoint(OpSessionCarSet, "cmd-busy", map[string]any{"id": "lp1", "session": "s1", "car_id": "c2"}, 7, 200_000)) + res = body[CmdResult](t, rec.only(t, MsgCmdResult)) + if res.State != CmdRejected || res.Error.Code != ErrPrecondition || res.Error.Args["reason"] != "elsewhere" { + t.Fatalf("a car on another charger %+v", res) + } + + rec.reset() + c.plugged = false + deliver(t, h, MsgCmd, nil, cmdLoadpoint(OpSessionCarSet, "cmd-y", map[string]any{"id": "lp1", "session": "s1", "car_id": "c1"}, 7, 200_000)) + res = body[CmdResult](t, rec.only(t, MsgCmdResult)) + if res.State != CmdRejected || res.Error.Code != ErrUnavailable || res.Error.Args["reason"] != "unplugged" { + t.Fatalf("unplugged %+v", res) + } + + for _, bad := range []struct { + args map[string]any + arg string + }{ + {map[string]any{"id": "c1", "soc": 1.01}, "soc"}, + {map[string]any{"id": "c1", "soc": "14"}, "soc"}, + {map[string]any{"soc": 0.5}, "id"}, + {map[string]any{"id": "c1", "charger_id": "lp1", "soc": 0.5}, "id"}, + } { + rec.reset() + deliver(t, h, MsgCmd, nil, cmdLoadpoint(OpCarSoCSet, "cmd-bad", bad.args, 7, 200_000)) + res = body[CmdResult](t, rec.only(t, MsgCmdResult)) + if res.State != CmdRejected || res.Error.Code != ErrUnknownOp || res.Error.Args["arg"] != bad.arg || rec.has(MsgCmdAck) { + t.Fatalf("bad args %v: %+v", bad.args, res) + } + } + for _, bad := range []struct { + args map[string]any + arg string + }{ + {map[string]any{"id": "c1", "target": 0.5}, "ready_by_min"}, + {map[string]any{"id": "c1", "target": 1.2, "ready_by_min": int64(360)}, "target"}, + {map[string]any{"id": "c1", "target": 0.5, "ready_by_min": int64(1440)}, "ready_by_min"}, + {map[string]any{"id": "c1", "clear": true, "target": 0.5, "ready_by_min": int64(360)}, "clear"}, + {map[string]any{"id": "c1", "target": 0.5, "ready_by_min": int64(360), "days": int64(128)}, "days"}, + } { + rec.reset() + deliver(t, h, MsgCmd, nil, cmdLoadpoint(OpCarGoalSet, "cmd-bad-goal", bad.args, 7, 200_000)) + res = body[CmdResult](t, rec.only(t, MsgCmdResult)) + if res.State != CmdRejected || res.Error.Code != ErrUnknownOp || res.Error.Args["arg"] != bad.arg || rec.has(MsgCmdAck) { + t.Fatalf("bad goal %v: %+v", bad.args, res) + } + } + for _, bad := range []struct { + args map[string]any + arg string + }{ + {map[string]any{"id": "lp1", "car_id": "c1"}, "session"}, + {map[string]any{"id": "lp1", "session": "s1"}, "car_id"}, + } { + rec.reset() + deliver(t, h, MsgCmd, nil, cmdLoadpoint(OpSessionCarSet, "cmd-bad-tap", bad.args, 7, 200_000)) + res = body[CmdResult](t, rec.only(t, MsgCmdResult)) + if res.State != CmdRejected || res.Error.Args["arg"] != bad.arg || rec.has(MsgCmdAck) { + t.Fatalf("bad tap %v: %+v", bad.args, res) + } + } +} + +// A box without cars answers the car ops as unavailable, not unknown: the +// op exists in this build, and the subsystem is what is missing. +func TestABoxWithoutCarsSaysUnavailable(t *testing.T) { + h, _, rec, _ := newRig(t) + subscribe(t, h, rec) + deliver(t, h, MsgCmd, nil, cmdLoadpoint(OpCarSoCSet, "cmd-z", map[string]any{"id": "c1", "soc": 0.1}, 7, 200_000)) + res := body[CmdResult](t, rec.only(t, MsgCmdResult)) + if res.State != CmdRejected || res.Error.Code != ErrUnavailable || res.Error.Args["subsystem"] != "cars" { + t.Fatalf("result %+v", res) + } +} + +// The car a cmd.result carries fits a 512 B lane 0 bucket however full the +// car. In a 256 B session a full one does not, and the result goes without +// it rather than not at all. +func TestABriefCarFitsLaneZero(t *testing.T) { + soc, age, limit := int64(1000), int64(9_999_999), int64(1000) + full := protocol.Car{ + ID: "0123456789abcdef0123456789abcdef", Name: "A name of sixty-four characters, which is as long as one gets!", Make: "Volkswagen", + VIN: "WVWZZZE1ZMP000001", CapacityWh: 300000, MaxW: 400000, Phases: 3, + SocPermille: &soc, SocSource: protocol.CarSoCVehicle, SocAgeS: &age, EnergySinceWh: -299_999, SocEstimate: true, + ChargeLimitPermille: &limit, Goal: &protocol.CarGoal{TargetPermille: 1000, ReadyByMin: 1439, Days: 0x7f, FinishAtLimit: true}, + SurplusOnly: true, Identifiers: []string{"04:AB:CD:EF:01:23:45", "rfid-7", "EMAID-SE-ABC-123456789-0"}, Sources: []string{"tesla-ha", "vag"}, + } + brief := briefCar(full) + size, err := encodedSize(MsgCmdResult, CmdResult{CmdID: "01J9ZQ7C8K3M5N7P9R1T3V5X7Z", State: CmdApplied, Car: &brief, + Observed: &Observed{Value: 1, Src: ObservedSrcCore, UptimeMs: 1 << 40}}) + if err != nil { + t.Fatal(err) + } + t.Logf("a full brief car in cmd.result: %d bytes", size) + const headerBytes = 6 + if size+headerBytes > 512 { + t.Fatalf("%d bytes do not fit the 512 B bucket", size) + } + + h, c, rec := newCarRig(t) + h.mu.Lock() + h.bucket = 256 + h.mu.Unlock() + c.cars["c1"] = full + deliver(t, h, MsgCmd, nil, cmdLoadpoint(OpCarSoCSet, "cmd-soc", map[string]any{"id": "c1", "soc": 1.0}, 7, 200_000)) + res := body[CmdResult](t, rec.only(t, MsgCmdResult)) + if res.State != CmdApplied || res.Car != nil { + t.Fatalf("in a 256 B session %+v", res) + } +} diff --git a/go/internal/appproto/command.go b/go/internal/appproto/command.go index eeca2f2b9..82410204c 100644 --- a/go/internal/appproto/command.go +++ b/go/internal/appproto/command.go @@ -58,6 +58,16 @@ func defaultOps() map[string]opSpec { // the plan wrong for exactly as long as the box cannot act on it. OpLoadpointSoCSet: {scope: ScopeDispatchWrite, dispatchWrite: false}, OpLoadpointSurplusOnlySet: {scope: ScopeDispatchWrite, dispatchWrite: false}, + // Cars (docs/cars.md). A car's goal, its level and which car is on a + // charger are a phone's to set, as the charger settings above are, + // and for the same reason none waits for the dispatch gate. The list + // of cars is the owner's alone: ftw.cars.write is in no other role. + OpCarGoalSet: {scope: ScopeDispatchWrite, dispatchWrite: false}, + OpCarSoCSet: {scope: ScopeDispatchWrite, dispatchWrite: false}, + OpSessionCarSet: {scope: ScopeDispatchWrite, dispatchWrite: false}, + OpCarAdd: {scope: ScopeCarsWrite, dispatchWrite: false}, + OpCarUpdate: {scope: ScopeCarsWrite, dispatchWrite: false}, + OpCarRemove: {scope: ScopeCarsWrite, dispatchWrite: false}, } } diff --git a/go/internal/appproto/ev_test.go b/go/internal/appproto/ev_test.go index f51b0a58c..f8d86cebf 100644 --- a/go/internal/appproto/ev_test.go +++ b/go/internal/appproto/ev_test.go @@ -3,6 +3,7 @@ package appproto import ( "context" "errors" + "github.com/srcfl/ftw/go/protocol" "maps" "slices" "testing" @@ -49,15 +50,29 @@ type fakeLoadpoints struct { // powerW is what Readings reports the charger drawing. powerW float64 + // car is what each charger carries about its car, and socSource and + // socEstimate where its level came from. + car protocol.ChargerCar + carByID map[string]protocol.ChargerCar + socSource string + socEstimate bool } func (f *fakeLoadpoints) WaitForPersistence(context.Context) error { return f.persistenceErr } +func (f *fakeLoadpoints) carOn(id string) protocol.ChargerCar { + if c, ok := f.carByID[id]; ok { + return c + } + return f.car +} + func (f *fakeLoadpoints) Readings() []LoadpointReading { out := []LoadpointReading{} for _, id := range slices.Sorted(maps.Keys(f.ids)) { out = append(out, LoadpointReading{ - ID: id, Plugged: f.plugged, SoC: f.soc, SoCKnown: f.plugged && f.socKnown, PowerW: f.powerW, + ID: id, Plugged: f.plugged, SoC: f.soc, SoCKnown: f.plugged && f.socKnown, PowerW: f.powerW, Car: f.carOn(id), + SoCSource: f.socSource, SoCEstimate: f.socEstimate, }) } return out diff --git a/go/internal/appproto/exchange.go b/go/internal/appproto/exchange.go index 92a3b7222..b4153f962 100644 --- a/go/internal/appproto/exchange.go +++ b/go/internal/appproto/exchange.go @@ -2,6 +2,8 @@ package appproto import ( "context" + "encoding/hex" + "github.com/srcfl/ftw/go/protocol" "sort" "github.com/srcfl/ftw/go/internal/units" @@ -201,10 +203,22 @@ func (h *Handler) nowLoadpoints() []NowLoadpoint { return out } for _, r := range h.cfg.Loadpoints.Readings() { - lp := NowLoadpoint{ID: r.ID, Plugged: r.Plugged, PowerW: roundW(r.PowerW)} + lp := NowLoadpoint{ID: r.ID, Plugged: r.Plugged, PowerW: roundW(r.PowerW), + CarID: rawCarID(r.Car.CarID), CarIdentifiedBy: r.Car.CarIdentifiedBy, CarCorrected: r.Car.CarCorrected} + for _, id := range r.Car.Choose { + if raw := rawCarID(id); raw != nil { + lp.Choose = append(lp.Choose, raw) + } + } + if r.Car.CarIdentifiedBy == protocol.CarIdentifiedByAsking { + // A watch taps only while the box asks; the session costs + // lane 0 bytes on every other charger. + lp.Session = r.Car.Session + } if r.SoCKnown { soc := units.PermilleFromFraction(r.SoC) lp.SocPermille = &soc + lp.SocSource, lp.SocAgeS, lp.SocEstimate = r.SoCSource, r.SoCAgeS, r.SoCEstimate } out = append(out, lp) } @@ -280,3 +294,13 @@ func (h *Handler) PadFrame() ([]byte, error) { } return h.cfg.Codec.EncodeFrame(Frame{Lane: LaneControl, Bucket: bucket, Envelope: env}) } + +// rawCarID is a car id as the watch's now carries it: its 16 bytes. Nil for +// none, or for an id that is not 32 hex digits. +func rawCarID(id string) []byte { + raw, err := hex.DecodeString(id) + if err != nil || len(raw) != 16 { + return nil + } + return raw +} diff --git a/go/internal/appproto/exchange_test.go b/go/internal/appproto/exchange_test.go index 861249433..e483856cc 100644 --- a/go/internal/appproto/exchange_test.go +++ b/go/internal/appproto/exchange_test.go @@ -1,8 +1,12 @@ package appproto import ( + "encoding/hex" "fmt" "testing" + + "github.com/fxamacker/cbor/v2" + "github.com/srcfl/ftw/go/protocol" ) // newExchangeRig is a watch's exchange session over the EV rig's box. @@ -20,7 +24,9 @@ func newExchangeRig(t *testing.T) (*Handler, *fakeLoadpoints, *fakeBox, *recorde // carries the controlRev a command expects. func TestAnExchangeHelloNamesTheRevAndNoCatalogue(t *testing.T) { h, _, box, rec := newExchangeRig(t) - h.cfg.Cloud = func() *CloudSession { return &CloudSession{Home: "home-1", PK: make([]byte, 65), Boot: make([]byte, 16)} } + h.cfg.Cloud = func() *CloudSession { + return &CloudSession{Home: "home-1", PK: make([]byte, 65), Boot: make([]byte, 16)} + } deliver(t, h, MsgHello, nil, Hello{Proto: ProtoRange{Min: ProtoMin, Max: ProtoMax}}) frame := rec.only(t, MsgHelloOK) @@ -106,6 +112,51 @@ func TestNowFitsLaneZeroOrSaysWhatItLeftOut(t *testing.T) { } } +// A watch shows the car on each charger and offers the tap when the box +// does not know it (docs/cars.md). One charger asking with four cars to +// choose from, the most it carries, still fits lane 0 beside a second +// charger. Two chargers asking at once with four cars each do not; the box +// then leaves the last charger out and flags TRUNC, as for any now. +func TestNowNamesTheCarOnEachCharger(t *testing.T) { + h, lp, _, rec := newExchangeRig(t) + ids := []string{"5f0c1e2d3a4b5c6d7e8f90a1b2c3d4e5", "0123456789abcdef0123456789abcdef", + "89abcdef0123456789abcdef01234567", "fedcba9876543210fedcba9876543210"} + lp.car = protocol.ChargerCar{Session: "7d1f0c2a9b3e4f50", CarIdentifiedBy: protocol.CarIdentifiedByAsking, + CarCorrected: true, CarCorrectedFrom: ids[1], Choose: ids} + lp.socSource, lp.socEstimate = protocol.CarSoCVehicle, true + deliver(t, h, MsgNow, nil, struct{}{}) + frame := rec.only(t, MsgNow) + if frame.size != 512 || frame.flags&FlagTrunc != 0 { + t.Fatalf("now with an asking charger went in %d B with flags %d", frame.size, frame.flags) + } + now := body[Now](t, frame) + got := now.Loadpoints[0] + if got.Session != lp.car.Session || got.CarIdentifiedBy != protocol.CarIdentifiedByAsking || len(got.Choose) != 4 || + hex.EncodeToString(got.Choose[3]) != ids[3] || !got.CarCorrected || got.SocSource != protocol.CarSoCVehicle || !got.SocEstimate { + t.Fatalf("charger %+v", got) + } + raw := body[map[string]any](t, frame) + lps, _ := raw["loadpoints"].([]any) + first := stringKeys(lps[0]) + if _, has := first["carCorrectedFrom"]; has { + t.Fatal("now carries carCorrectedFrom, which has no room in 512 B") + } + one, err := cborBytes(got) + if err != nil { + t.Fatal(err) + } + t.Logf("an asking charger with four cars takes %d B of now", one) + + // A second charger with its car fits beside it. + rec.reset() + lp.ids["lp2"] = true + lp.carByID = map[string]protocol.ChargerCar{"lp2": {Session: "0a1b2c3d4e5f6071", CarID: ids[0], CarIdentifiedBy: protocol.CarIdentifiedByTap}} + deliver(t, h, MsgNow, nil, struct{}{}) + if frame := rec.only(t, MsgNow); frame.flags&FlagTrunc != 0 || len(body[Now](t, frame).Loadpoints) != 2 { + t.Fatalf("two chargers with their cars did not fit: flags %d", frame.flags) + } +} + // A removed watch's read is refused and its session ended, as a command is. func TestARemovedWatchReadsNothing(t *testing.T) { h, _, _, rec := newExchangeRig(t) @@ -138,3 +189,8 @@ func TestThePadIsALaneZeroTick(t *testing.T) { t.Fatalf("the pad is %d B, lane %d, %q", len(raw), f.Lane, f.Envelope.T) } } + +func cborBytes(v any) (int, error) { + b, err := cbor.Marshal(v) + return len(b), err +} diff --git a/go/internal/appproto/handler.go b/go/internal/appproto/handler.go index f089df5b6..fadedd061 100644 --- a/go/internal/appproto/handler.go +++ b/go/internal/appproto/handler.go @@ -50,6 +50,10 @@ type Config struct { // this build and the subsystem is what is missing. Loadpoints Loadpoints + // Cars are the owners' cars (docs/cars.md), or nil on a box without + // them. The car ops then answer E_UNAVAILABLE. + Cars Cars + // PlannerPrefs applies the Plan card's safety factor and battery-export // permission, or nil before the API server is bound. planner.prefs.set // then answers E_UNAVAILABLE, the same 503 a passthrough meets in that @@ -1146,6 +1150,8 @@ func (h *Handler) runCmd(ctx context.Context, cmd Cmd, by string) error { return h.loadpointSoCSet(cmd, uptimeMs) case OpLoadpointSurplusOnlySet: return h.loadpointSurplusOnlySet(cmd, uptimeMs) + case OpCarGoalSet, OpCarSoCSet, OpSessionCarSet, OpCarAdd, OpCarUpdate, OpCarRemove: + return h.carOp(cmd, uptimeMs) default: return fmt.Errorf("appproto: op %q is in the table but has no handler", cmd.Op) } @@ -1412,6 +1418,15 @@ func (h *Handler) sendCmdResult(res CmdResult) error { h.mu.Lock() bucket := h.bucket h.mu.Unlock() + if res.Car != nil || res.Charger != nil { + // A car rides lane 0 only when it fits the session's bucket: a full + // one with a long name does not fit 256 B. Without it the result + // still settles the command, and the app reads the car again. + const headerBytes = 6 + if size, err := encodedSize(MsgCmdResult, res); err != nil || size+headerBytes > bucket { + res.Car, res.Charger = nil, nil + } + } return h.sendControl(MsgCmdResult, nil, res, 0, bucket) } diff --git a/go/internal/appproto/ports.go b/go/internal/appproto/ports.go index 4c2ac2b69..ed5087627 100644 --- a/go/internal/appproto/ports.go +++ b/go/internal/appproto/ports.go @@ -5,9 +5,11 @@ import ( "errors" "time" + "github.com/srcfl/ftw/go/internal/cars" "github.com/srcfl/ftw/go/internal/control" "github.com/srcfl/ftw/go/internal/loadpoint" "github.com/srcfl/ftw/go/internal/mpc" + "github.com/srcfl/ftw/go/protocol" ) // UptimeClock is the only clock this protocol trusts for ages. @@ -201,10 +203,31 @@ type Loadpoints interface { Readings() []LoadpointReading } +// Cars is the box's cars (docs/cars.md): the car ops, behind the same calls +// GET /api/cars reads. +type Cars interface { + Add(c protocol.Car) (protocol.Car, error) + UpdateCar(id string, u cars.Update) (protocol.Car, error) + Remove(id string) error + SetGoal(id string, goal *protocol.CarGoal, clear bool, surplusOnly *bool) (protocol.Car, error) + SetLevel(id string, soc float64) (protocol.Car, error) + // SetSessionGoal and SetSessionLevel act on a charger's session: its + // car, or the session itself while its car is unknown. + SetSessionGoal(chargerID string, goal *protocol.CarGoal, clear bool, surplusOnly *bool) (protocol.ChargerCar, error) + SetSessionLevel(chargerID string, soc float64) (protocol.ChargerCar, error) + SetSessionCar(chargerID, session, carID string) (protocol.ChargerCar, error) +} + // LoadpointReading is one charger as the box holds it now. type LoadpointReading struct { ID string Plugged bool + // Car names the car on the charger, and SoCSource, SoCAgeS and + // SoCEstimate say where its level came from (docs/cars.md). + Car protocol.ChargerCar + SoCSource string + SoCAgeS *int64 + SoCEstimate bool // SoC is the car's level as a fraction in [0,1], meaningful only while // SoCKnown. SoC float64 diff --git a/go/internal/appproto/protocol.go b/go/internal/appproto/protocol.go index 459b1a12d..9b99e0512 100644 --- a/go/internal/appproto/protocol.go +++ b/go/internal/appproto/protocol.go @@ -146,6 +146,12 @@ const ( OpLoadpointBoost = protocol.OpLoadpointBoost OpLoadpointSoCSet = protocol.OpLoadpointSoCSet OpLoadpointSurplusOnlySet = protocol.OpLoadpointSurplusOnlySet + OpCarGoalSet = protocol.OpCarGoalSet + OpCarSoCSet = protocol.OpCarSoCSet + OpSessionCarSet = protocol.OpSessionCarSet + OpCarAdd = protocol.OpCarAdd + OpCarUpdate = protocol.OpCarUpdate + OpCarRemove = protocol.OpCarRemove BoxModeFull = protocol.BoxModeFull BoxModeFloor = protocol.BoxModeFloor BoxModeBooting = protocol.BoxModeBooting @@ -238,6 +244,8 @@ const ( ScopeModeWrite = protocol.ScopeModeWrite ScopeMembersRead = protocol.ScopeMembersRead ScopeMembersWrite = protocol.ScopeMembersWrite + ScopeCarsWrite = protocol.ScopeCarsWrite + CapDerCar = protocol.CapDerCar ErrBooting = protocol.ErrBooting ErrUnknownOp = protocol.ErrUnknownOp ErrCmdExpired = protocol.ErrCmdExpired @@ -253,6 +261,8 @@ const ( ErrUnsupportedMedia = protocol.ErrUnsupportedMedia ErrWholeDocument = protocol.ErrWholeDocument ErrLocalOnly = protocol.ErrLocalOnly + ErrNotFound = protocol.ErrNotFound + ErrDuplicate = protocol.ErrDuplicate SourceLive = protocol.SourceLive SourceLagging = protocol.SourceLagging SourceStale = protocol.SourceStale diff --git a/go/internal/cars/cars.go b/go/internal/cars/cars.go new file mode 100644 index 000000000..c108ce252 --- /dev/null +++ b/go/internal/cars/cars.go @@ -0,0 +1,745 @@ +// Package cars keeps the cars of a box's owners apart from its chargers +// (docs/cars.md). A car belongs to its owner and visits chargers; a charging +// session is one car on one loadpoint, from plug-in to unplug. +// +// The loadpoint manager keeps each session's energy and turns the car's level +// into the estimate the planner and the controller read. This package decides +// which car a session belongs to, keeps each car's level, goal and choices, +// and hands them to the loadpoint. Nothing here commands hardware. +package cars + +import ( + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "log/slog" + "slices" + "strings" + "sync" + "time" + + "github.com/srcfl/ftw/go/internal/events" + "github.com/srcfl/ftw/go/internal/loadpoint" + "github.com/srcfl/ftw/go/internal/telemetry" + "github.com/srcfl/ftw/go/protocol" +) + +// StoreKey is the state.db config row that holds every car. +const StoreKey = "cars" + +// IdentifyWindow is how close to a plug-in the car's own report must come for +// it to name the car, and how long the box waits for the charger or the car +// before it asks which car it is. +const IdentifyWindow = time.Minute + +// levelSaveEvery bounds how often a new reading alone writes state.db. A car +// that reports every few seconds must not wear the SD card; a restart then +// loses at most this much of a level the car will report again. +const levelSaveEvery = time.Minute + +// KV is the state.db config table. +type KV interface { + LoadConfig(key string) (string, bool) + SaveConfig(key, value string) error +} + +// Deps is what the runtime needs from the box. +type Deps struct { + Store KV + Loadpoints *loadpoint.Manager + // Vehicles lists each vehicle driver's newest level report. + Vehicles func() []telemetry.VehicleObservation + // Identity is a driver's make and serial, as it reported them. A vehicle + // driver reports the VIN as its serial. + Identity func(driver string) (make, serial string) + Bus *events.Bus + // Replan asks the planner to plan again. Nil in tests. + Replan func(reason string) + Now func() time.Time +} + +// Car is one car as the box keeps it. +type Car struct { + ID string `json:"id"` + Name string `json:"name"` + Make string `json:"make,omitempty"` + VIN string `json:"vin,omitempty"` + CapacityWh float64 `json:"capacity_wh,omitempty"` + MaxW float64 `json:"max_w,omitempty"` + Phases int `json:"phases,omitempty"` + // PluginSoC is the level the box assumes at plug-in when it has none + // to carry. Zero is units.DefaultPluginSoC. + PluginSoC float64 `json:"plugin_soc,omitempty"` + // Goal is a loadpoint schedule without the site's battery rule. Empty + // is no goal. + Goal loadpoint.Schedule `json:"goal"` + SurplusOnly bool `json:"surplus_only,omitempty"` + Identifiers []string `json:"identifiers,omitempty"` + Sources []string `json:"sources,omitempty"` + // ProfileID is the vehicles[] profile the car came from, and + // ProfileTarget that profile's target without a ready time, applied to + // each session as the profile was. + ProfileID string `json:"profile_id,omitempty"` + // BoxMinted marks a car the box made, with an id of its own, from its + // old settings. The owner's app may give it the app's id. + BoxMinted bool `json:"box_minted,omitempty"` + ProfileTarget float64 `json:"profile_target,omitempty"` + Level Level `json:"level"` + // UnpluggedMs is when the car last left a charger. A level from before + // it may predate a drive, so a new session does not carry it. + UnpluggedMs int64 `json:"unplugged_ms,omitempty"` + // UsualLP is the loadpoint the box made the car from. A plug-in there + // names the car when no other car calls it usual (docs/cars.md, rule 3). + UsualLP string `json:"usual_lp,omitempty"` +} + +// Level is the car's last reading and the energy delivered since. +type Level struct { + SoC float64 `json:"soc"` + Source string `json:"source,omitempty"` + AtMs int64 `json:"at_ms,omitempty"` + // BaseWh is the energy delivered to the car since AtMs in sessions + // that have ended. + BaseWh float64 `json:"base_wh,omitempty"` + // LP and SessionWh place a reading inside a session: the loadpoint and + // its session counter at AtMs. Energy delivered past SessionWh in that + // session is the car's. + LP string `json:"lp,omitempty"` + SessionWh float64 `json:"session_wh,omitempty"` + ChargeLimit float64 `json:"charge_limit,omitempty"` +} + +func (l Level) at() time.Time { + if l.AtMs == 0 { + return time.Time{} + } + return time.UnixMilli(l.AtMs) +} + +// session is one car, or none, on one plugged-in loadpoint. +type session struct { + // ID names this plug-in. A tap names it, and is refused once it ended. + ID string `json:"id"` + CarID string `json:"car,omitempty"` + By string `json:"by"` + CorrectedFrom string `json:"corrected_from,omitempty"` + // Key is the charger's own session identity, when it has one. + Key string `json:"key,omitempty"` + PluggedMs int64 `json:"plugged_ms"` + BoundMs int64 `json:"bound_ms"` + // LastWh is the session's energy at the last tick, kept so an unplug + // can credit it after the charger has reset its counter. + LastWh float64 `json:"last_wh"` + // Asked is set once car.which went out for this plug-in, or once the + // box no longer needs to ask. + Asked bool `json:"asked,omitempty"` + // Tapped is set when a person named the car, or none, and TapFinal + // when that tap overrode an answer the box had found by itself: no rule + // corrects it then. + Tapped bool `json:"tapped,omitempty"` + TapFinal bool `json:"tap_final,omitempty"` + // Prev is the bound car's level from before this session, so a switch + // to another car puts it back. + Prev *Level `json:"prev,omitempty"` + + restored bool + // uncertain marks a session the box found plugged in as it started: a + // reading from before it may be from before the plug-in or after. + uncertain bool + // connMs is the loadpoint's own connection time as last seen. After a + // restart it differs from PluggedMs, the plug-in itself. + connMs int64 + // unpluggedAt is when the charger first said the cable is out. The + // session ends once it has said so for UnplugGrace. + unpluggedAt time.Time + chargerIdent string + chargerIdentAt time.Time + seeded bool + // A visitor's own goal and surplus choice, until unplug. + goal *loadpoint.Schedule + surplus *bool +} + +// record is the StoreKey row. +type record struct { + Version int `json:"version"` + Cars []*Car `json:"cars"` + Sessions map[string]*session `json:"sessions,omitempty"` + // LastCar is the car each loadpoint last had: what its routes act on + // while nothing is plugged in. + LastCar map[string]string `json:"last_car,omitempty"` + // Profiles maps each vehicles[] profile id to the car made from it, and + // ProfileSeen holds each profile as the box last applied it, so an edit + // in config.yaml reaches the car and nothing else the owner set does. + Profiles map[string]string `json:"profiles,omitempty"` + ProfileSeen map[string]Profile `json:"profile_seen,omitempty"` + // Unbound are vehicle drivers the owner took from a car. The box does + // not bind them again by itself. + Unbound []string `json:"unbound,omitempty"` + // Migrated is set once the box has made cars from its loadpoints. A box + // with no loadpoint yet makes them when the first one is configured. + Migrated bool `json:"migrated,omitempty"` + // Removed are the cars an owner removed, so an app copying lists + // between boxes does not bring them back. + Removed []Tombstone `json:"removed,omitempty"` +} + +// Tombstone is a removed car. +type Tombstone struct { + ID string `json:"id"` + AtMs int64 `json:"at_ms"` +} + +// sourceState follows one vehicle driver's word on its cable. +type sourceState struct { + seen bool + plugged bool + pluggedSince time.Time + obs telemetry.VehicleObservation +} + +// Runtime keeps the cars and their sessions. Safe for concurrent use. +type Runtime struct { + // syncMu orders the runtime's decisions with the loadpoint calls that + // carry them out: a tap and a tick never hand the loadpoint their + // bindings or seeds out of order. Taken before mu, and never by the + // loadpoint's savers and observers. + syncMu sync.Mutex + mu sync.Mutex + deps Deps + rec record + sources map[string]*sourceState + applied map[string]loadpoint.CarBinding + listeners []func() + dirty bool + levelSave bool + lastSave time.Time + started bool + startedAt time.Time + // pendingIdent holds a charger's word that arrived before the loadpoint + // saw the plug-in. + pendingIdent map[string]pendingIdent + // vehicleDrivers are the configured drivers that report a car's level, + // bound to a car before their first reading so an asleep car can be + // woken. + vehicleDrivers []string +} + +// SetVehicleDrivers names the configured vehicle drivers. Each binds to the +// car with its VIN, or to the only car, at the next tick, without waiting for +// its first reading. +func (r *Runtime) SetVehicleDrivers(names []string) { + r.mu.Lock() + defer r.mu.Unlock() + r.vehicleDrivers = slices.Clone(names) +} + +type pendingIdent struct { + ident string + at time.Time +} + +// Errors the operations return. appproto maps them onto refusal codes. +var ( + ErrUnknownCar = errors.New("cars: no such car") + ErrUnknownCharger = errors.New("cars: no such charger") + ErrUnplugged = errors.New("cars: nothing is plugged in") + ErrInvalid = errors.New("cars: invalid value") + // ErrSessionEnded is a tap for a plug-in that has ended. + ErrSessionEnded = errors.New("cars: that plug-in has ended") + // ErrDuplicate is a VIN or identifier another car already has. + ErrDuplicate = errors.New("cars: another car has that VIN or identifier") + // ErrElsewhere is a tap naming a car another charger has. + ErrElsewhere = errors.New("cars: that car is on another charger") + // ErrTooMany is a car past MaxCars. + ErrTooMany = errors.New("cars: the box keeps no more cars") +) + +// MaxCars is how many cars a box keeps, and maxTombstones how many removed +// ids it remembers. The oldest tombstone goes first. +const ( + MaxCars = 32 + maxTombstones = 64 +) + +// New builds a runtime. Call Start before the first Tick. +func New(deps Deps) *Runtime { + if deps.Now == nil { + deps.Now = time.Now + } + return &Runtime{ + deps: deps, + sources: map[string]*sourceState{}, + applied: map[string]loadpoint.CarBinding{}, + pendingIdent: map[string]pendingIdent{}, + } +} + +// OnChange registers fn to run after a session's car, a car's level source, +// goal or choices change: the hook for anything that pushes a plan or a +// charger view. Called outside the runtime's lock. +func (r *Runtime) OnChange(fn func()) { + r.mu.Lock() + defer r.mu.Unlock() + r.listeners = append(r.listeners, fn) +} + +// Start loads the cars, or makes them from what the box has on first start, +// and hands each loadpoint its car. It runs after the loadpoints' schedules +// and surplus choices are hydrated, and before the first control tick, so a +// restored session keeps its car's capacity. +func (r *Runtime) Start(profiles []Profile) error { + r.syncMu.Lock() + defer r.syncMu.Unlock() + r.mu.Lock() + r.startedAt = r.deps.Now() + loaded := false + if raw, ok := r.deps.Store.LoadConfig(StoreKey); ok && raw != "" { + var rec record + if err := json.Unmarshal([]byte(raw), &rec); err != nil { + // Keep the row for whoever looks, and make the cars again from + // the loadpoints' own rows, which the box keeps writing. + slog.Error("cars: the stored cars cannot be read; making them again from the chargers", "err", err) + _ = r.deps.Store.SaveConfig(StoreKey+":unreadable", raw) + rec = record{} + } + for _, car := range rec.Cars { + car.Goal.Normalize() + } + for _, s := range rec.Sessions { + s.restored = true + s.seeded = true + if s.ID == "" { + s.ID = newSessionID() + } + } + r.rec = rec + loaded = rec.Version != 0 + } + r.ensureMaps() + if !r.rec.Migrated { + r.migrateLocked() + r.mergeOnlyProfileLocked(profiles) + } + r.syncProfilesLocked(profiles) + r.started = true + var err error + if !loaded || r.dirty { + err = r.saveLocked() + } + acts := r.syncLocked(r.deps.Now()) + r.mu.Unlock() + run(acts) + return err +} + +func (r *Runtime) ensureMaps() { + if r.rec.Version == 0 { + r.rec.Version = 1 + } + if r.rec.Sessions == nil { + r.rec.Sessions = map[string]*session{} + } + if r.rec.LastCar == nil { + r.rec.LastCar = map[string]string{} + } + if r.rec.Profiles == nil { + r.rec.Profiles = map[string]string{} + } + if r.rec.ProfileSeen == nil { + r.rec.ProfileSeen = map[string]Profile{} + } +} + +// migrateLocked makes the cars from what the box had before them. Nothing the +// owner set is lost: each loadpoint's capacity, plug-in level, goal and +// surplus choice become a car's. The site's battery rule stays on the +// loadpoint. +func (r *Runtime) migrateLocked() { + lps := r.deps.Loadpoints + configs := lps.Configs() + if len(configs) == 0 { + return + } + r.rec.Migrated = true + r.dirty = true + for i, cfg := range configs { + st, _ := lps.State(cfg.ID) + car := &Car{ + ID: newID(), + BoxMinted: true, + Name: fmt.Sprintf("Car %d", i+1), + CapacityWh: cfg.VehicleCapacityWh, + PluginSoC: cfg.PluginSoC, + Goal: goalOnly(st.Schedule), + SurplusOnly: st.SurplusOnly, + UsualLP: cfg.ID, + } + r.rec.Cars = append(r.rec.Cars, car) + r.rec.LastCar[cfg.ID] = car.ID + slog.Info("cars: made a car from the loadpoint's settings", "lp", cfg.ID, "car", car.ID, "name", car.Name) + } +} + +// mergeOnlyProfileLocked folds a lone vehicles[] profile into the lone car +// the loadpoint made: one home, one charger, one profile is one car, which +// the charger names by its identifiers and which is also the only car. Its +// name, identifiers and capacity come from the profile; its goal and +// choices stay the ones the charger showed. +func (r *Runtime) mergeOnlyProfileLocked(profiles []Profile) { + if len(r.rec.Cars) != 1 || len(profiles) != 1 || profiles[0].ID == "" { + return + } + car, p := r.rec.Cars[0], profiles[0] + if p.Name != "" { + car.Name = p.Name + } else { + car.Name = p.ID + } + if p.CapacityWh > 0 { + car.CapacityWh = p.CapacityWh + } + car.Identifiers = append([]string(nil), p.Identifiers...) + car.ProfileID, car.ProfileTarget = p.ID, p.TargetSoC + r.rec.Profiles[p.ID] = car.ID + r.rec.ProfileSeen[p.ID] = p + r.dirty = true +} + +// Profile is a vehicles[] profile from config.yaml. +type Profile struct { + ID string `json:"id"` + Name string `json:"name,omitempty"` + CapacityWh float64 `json:"capacity_wh,omitempty"` + Identifiers []string `json:"identifiers,omitempty"` + SurplusOnly bool `json:"surplus_only,omitempty"` + TargetSoC float64 `json:"target_soc,omitempty"` +} + +// SettingsChange is a loadpoint setting someone changed in config.yaml, by +// hand or on the box's settings page. For one release such a change goes to +// the car the charger shows, as the charger routes do. A nil field is +// unchanged. +type SettingsChange struct { + LP string + CapacityWh *float64 + PluginSoC *float64 + SurplusOnly *bool +} + +// Reload follows a config.yaml change: a box that had no loadpoint makes its +// cars from the first one, each vehicles[] profile the box has not seen +// becomes a car, an edited profile changes its car, and a changed loadpoint +// setting becomes its car's. +func (r *Runtime) Reload(profiles []Profile, changes ...SettingsChange) { + r.syncMu.Lock() + defer r.syncMu.Unlock() + r.mu.Lock() + if !r.rec.Migrated { + r.migrateLocked() + } + r.syncProfilesLocked(profiles) + for _, c := range changes { + car, _ := r.viewCarLocked(c.LP) + if car == nil { + continue + } + if c.CapacityWh != nil && *c.CapacityWh >= 1000 && *c.CapacityWh <= 300000 { + car.CapacityWh = *c.CapacityWh + } + if c.PluginSoC != nil && *c.PluginSoC >= 0 && *c.PluginSoC <= 1 { + car.PluginSoC = *c.PluginSoC + } + if c.SurplusOnly != nil { + car.SurplusOnly = *c.SurplusOnly + } + r.dirty = true + } + var acts []func() + if r.dirty { + _ = r.saveLocked() + acts = r.syncLocked(r.deps.Now()) + } + r.mu.Unlock() + run(acts) +} + +func (r *Runtime) syncProfilesLocked(profiles []Profile) { + for _, p := range profiles { + if p.ID == "" { + continue + } + p.Identifiers = slices.Clone(p.Identifiers) + if carID, seen := r.rec.Profiles[p.ID]; seen { + prev, known := r.rec.ProfileSeen[p.ID] + if known && sameProfile(prev, p) { + continue + } + if car := r.carLocked(carID); car != nil && known { + r.followProfileLocked(car, prev, p) + } + r.rec.ProfileSeen[p.ID] = p + r.dirty = true + continue + } + if len(r.rec.Cars) >= MaxCars { + slog.Warn("cars: the box keeps no more cars; a vehicle profile made none", "profile", p.ID) + continue + } + name := p.Name + if name == "" { + name = p.ID + } + car := &Car{ + ID: newID(), BoxMinted: true, Name: name, CapacityWh: p.CapacityWh, + Identifiers: append([]string(nil), p.Identifiers...), SurplusOnly: p.SurplusOnly, + ProfileID: p.ID, ProfileTarget: p.TargetSoC, + } + r.rec.Cars = append(r.rec.Cars, car) + r.rec.Profiles[p.ID] = car.ID + r.rec.ProfileSeen[p.ID] = p + r.dirty = true + slog.Info("cars: made a car from a vehicle profile", "profile", p.ID, "car", car.ID) + } +} + +// followProfileLocked gives a car what changed in its profile, and leaves +// alone what did not, which the owner may have set since. +func (r *Runtime) followProfileLocked(car *Car, prev, p Profile) { + if p.Name != prev.Name && p.Name != "" { + car.Name = p.Name + } + if p.CapacityWh != prev.CapacityWh && p.CapacityWh >= 1000 && p.CapacityWh <= 300000 { + car.CapacityWh = p.CapacityWh + } + if !slices.Equal(p.Identifiers, prev.Identifiers) { + next := *car + next.Identifiers = slices.Clone(p.Identifiers) + if err := r.duplicateLocked(&next, nil); err != nil { + slog.Warn("cars: a vehicle profile's identifiers are another car's; its car keeps its own", + "profile", p.ID, "car", car.ID) + } else { + car.Identifiers = next.Identifiers + } + } + if p.SurplusOnly != prev.SurplusOnly { + car.SurplusOnly = p.SurplusOnly + } + if p.TargetSoC != prev.TargetSoC { + car.ProfileTarget = p.TargetSoC + } + slog.Info("cars: a vehicle profile changed; its car follows", "profile", p.ID, "car", car.ID) +} + +func sameProfile(a, b Profile) bool { + return a.Name == b.Name && a.CapacityWh == b.CapacityWh && slices.Equal(a.Identifiers, b.Identifiers) && + a.SurplusOnly == b.SurplusOnly && a.TargetSoC == b.TargetSoC +} + +// anyIdentifiersLocked reports whether some car lists a charger identifier. +// Until one does, a charger's identifier names nobody. +func (r *Runtime) anyIdentifiersLocked() bool { + for _, c := range r.rec.Cars { + if len(c.Identifiers) > 0 { + return true + } + } + return false +} + +// saveLocked writes the record. +func (r *Runtime) saveLocked() error { + b, err := json.Marshal(r.rec) + if err != nil { + return err + } + if err := r.deps.Store.SaveConfig(StoreKey, string(b)); err != nil { + return fmt.Errorf("cars: save: %w", err) + } + r.dirty, r.levelSave = false, false + r.lastSave = r.deps.Now() + return nil +} + +func (r *Runtime) carLocked(id string) *Car { + if id == "" { + return nil + } + for _, c := range r.rec.Cars { + if c.ID == id { + return c + } + } + return nil +} + +func (r *Runtime) carForSourceLocked(driver string) *Car { + for _, c := range r.rec.Cars { + for _, s := range c.Sources { + if s == driver { + return c + } + } + } + return nil +} + +// sessionOfLocked is the plugged-in loadpoint whose session has the car. +func (r *Runtime) sessionOfLocked(carID string) (string, *session) { + for lp, s := range r.rec.Sessions { + if s.CarID == carID { + return lp, s + } + } + return "", nil +} + +// viewCarLocked is the car a loadpoint's routes act on: the session's car +// while plugged in, else the car it last had, else the only car. +func (r *Runtime) viewCarLocked(lpID string) (car *Car, visitor bool) { + if s := r.rec.Sessions[lpID]; s != nil { + if c := r.carLocked(s.CarID); c != nil { + return c, false + } + return nil, true + } + if c := r.carLocked(r.rec.LastCar[lpID]); c != nil { + return c, false + } + if len(r.rec.Cars) == 1 { + return r.rec.Cars[0], false + } + return nil, false +} + +// sessionForRouteLocked opens the session of a charger plugged in since the +// last tick and asks the identification order at once, so a route acts on +// the car the box can name, or on the session alone, and never on the car +// the charger last had. The loadpoint hears of it at the next tick. +func (r *Runtime) sessionForRouteLocked(lpID string) { + if r.rec.Sessions[lpID] != nil { + return + } + states := r.deps.Loadpoints.States() + var st loadpoint.State + found := false + for _, x := range states { + if x.ID == lpID { + st, found = x, true + } + } + if !found || !st.Observed || !st.PluggedIn { + return + } + now := r.deps.Now() + s := r.openSessionLocked(st, now) + r.applyAnswerLocked(lpID, s, r.answerLocked(st, s, states, now), now) +} + +func (r *Runtime) notify() { + r.mu.Lock() + listeners := append([]func(){}, r.listeners...) + r.mu.Unlock() + for _, fn := range listeners { + fn() + } +} + +func (r *Runtime) replan(reason string) { + if r.deps.Replan != nil { + r.deps.Replan(reason) + } +} + +func run(acts []func()) { + for _, a := range acts { + a() + } +} + +// newID mints a car id: 128 random bits as 32 lowercase hex digits. +func newID() string { + var b [16]byte + if _, err := rand.Read(b[:]); err != nil { + panic(err) + } + return hex.EncodeToString(b[:]) +} + +// validID accepts an id an owner's app minted on another box. +func validID(id string) bool { + if len(id) != 32 { + return false + } + _, err := hex.DecodeString(id) + return err == nil && strings.ToLower(id) == id +} + +// normVIN returns s as a VIN, or "" when it is not shaped like one: 17 +// characters, digits and capitals, never I, O or Q. +func normVIN(s string) string { + s = strings.ToUpper(strings.TrimSpace(s)) + if len(s) != 17 { + return "" + } + for _, c := range s { + switch { + case c >= '0' && c <= '9': + case c >= 'A' && c <= 'Z' && c != 'I' && c != 'O' && c != 'Q': + default: + return "" + } + } + return s +} + +// goalOnly is a schedule without the site's battery rule. A schedule with no +// target is no goal, whatever clock it carries. +func goalOnly(s loadpoint.Schedule) loadpoint.Schedule { + if !s.HasTarget() { + return loadpoint.Schedule{} + } + s.SurplusUnlockBatSoC = 0 + return s +} + +// sameGoal compares what the owner chose, not what Core derived from it. +func sameGoal(a, b loadpoint.Schedule) bool { + a, b = goalOnly(a), goalOnly(b) + if !a.HasTarget() || !b.HasTarget() { + return a.HasTarget() == b.HasTarget() + } + ac, aok := a.TimeOfDayMin() + bc, bok := b.TimeOfDayMin() + if !aok { + ac = a.TimeOfDayMinUTC + } + if !bok { + bc = b.TimeOfDayMinUTC + } + return a.SoC == b.SoC && a.Recurring == b.Recurring && a.Days == b.Days && + a.FinishAtVehicleLimit == b.FinishAtVehicleLimit && ac == bc +} + +// emptyGoal reports a goal with nothing in it, the site rule aside. +func emptyGoal(s loadpoint.Schedule) bool { return goalOnly(s).Empty() } + +var identifiedRank = map[string]int{ + protocol.CarIdentifiedByCharger: 0, + protocol.CarIdentifiedByCar: 1, + protocol.CarIdentifiedByOnly: 2, + protocol.CarIdentifiedByTap: 3, + protocol.CarIdentifiedByAsking: 4, + protocol.CarIdentifiedByNone: 5, +} + +// newSessionID names one plug-in: 64 random bits as hex. +func newSessionID() string { + var b [8]byte + if _, err := rand.Read(b[:]); err != nil { + panic(err) + } + return hex.EncodeToString(b[:]) +} diff --git a/go/internal/cars/cars_test.go b/go/internal/cars/cars_test.go new file mode 100644 index 000000000..f9f081a2d --- /dev/null +++ b/go/internal/cars/cars_test.go @@ -0,0 +1,463 @@ +package cars + +import ( + "encoding/json" + "math" + "testing" + "time" + + "github.com/srcfl/ftw/go/internal/loadpoint" + "github.com/srcfl/ftw/go/internal/telemetry" + "github.com/srcfl/ftw/go/protocol" +) + +const teslaVIN = "XP7YGCEK7SB736799" + +// Fredrik's box becomes Car 1 on first start: the loadpoint's capacity, its +// goal and its surplus choice, and the Home Assistant Tesla driver bound to +// it, VIN and all. The charger keeps showing the same goal. +func TestHomeBoxBecomesCarOneBoundToTeslaHA(t *testing.T) { + h := newHarness(t, homeConfig(), map[string]loadpoint.Schedule{"easee": homeGoal(t)}, nil) + h.start() + + car := h.only() + if car.Name != "Car 1" || car.CapacityWh != 76000 || car.SurplusOnly || len(car.ID) != 32 { + t.Fatalf("car 1 = %+v", car) + } + if car.Goal == nil || *car.Goal != (protocol.CarGoal{TargetPermille: 800, ReadyByMin: 420, Days: 0x7F}) { + t.Fatalf("goal = %+v, want 80 %% by 07:00 every day", car.Goal) + } + + h.ident["tesla-ha"] = [2]string{"Tesla", teslaVIN} + h.report("tesla-ha", .14, h.now.Add(-9*time.Minute), "Stopped") + h.tick(time.Second) + car = h.only() + if len(car.Sources) != 1 || car.Sources[0] != "tesla-ha" || car.VIN != teslaVIN || car.Make != "Tesla" { + t.Fatalf("tesla-ha not bound to car 1: %+v", car) + } + st := h.state("easee") + if !goalEqual(st.Schedule, homeGoal(t)) || st.VehicleCapacityWh != 76000 || st.SurplusOnly { + t.Fatalf("the charger lost what the owner set: %+v", st.Schedule) + } + + // The cars are stored: a restart reads the same car back. + again := newHarness(t, homeConfig(), map[string]loadpoint.Schedule{"easee": homeGoal(t)}, h.kv) + again.start() + if got := again.only(); got.ID != car.ID || len(got.Sources) != 1 { + t.Fatalf("after restart: %+v", got) + } +} + +func goalEqual(a, b loadpoint.Schedule) bool { return sameGoal(goalOnly(a), goalOnly(b)) } + +// The bug on Fredrik's box: the Tesla read 14 % nine minutes ago and nothing +// has flowed since, yet the level showed as inferred because a reading was +// trusted for five minutes. A reading stays the car's until energy flows. +func TestANineMinuteOldReadingWithNothingDeliveredStaysFromTheCar(t *testing.T) { + h := newHarness(t, homeConfig(), map[string]loadpoint.Schedule{"easee": homeGoal(t)}, nil) + h.start() + h.ident["tesla-ha"] = [2]string{"Tesla", teslaVIN} + h.plug("easee", "s1") + h.report("tesla-ha", .14, h.now.Add(time.Minute), "Stopped") + h.tick(time.Minute) + h.tick(9 * time.Minute) + + car := h.only() + if car.SocPermille == nil || *car.SocPermille != 140 || car.SocSource != protocol.CarSoCVehicle || + car.SocEstimate || car.EnergySinceWh != 0 { + t.Fatalf("nine minutes, 0 Wh: %+v", car) + } + view, ok := h.rt.Session("easee") + if !ok || view.Source != protocol.CarSoCVehicle || view.Estimate { + t.Fatalf("session view %+v", view) + } + if st := h.state("easee"); math.Abs(st.CurrentSoC-.14) > 1e-9 || st.CarID != car.ID || + st.CarIdentifiedBy != protocol.CarIdentifiedByOnly { + t.Fatalf("charger %+v", st) + } + if got := h.rt.PlanningLimit("easee"); got != 0 { + t.Fatalf("limit %v from a report without one", got) + } + + // Past 1 kWh the same reading is an estimate: "≈ … from the car + kWh". + h.deliver("easee", "s1", 1500) + car = h.only() + if !car.SocEstimate || car.EnergySinceWh != 1500 || car.SocSource != protocol.CarSoCVehicle { + t.Fatalf("after 1.5 kWh: %+v", car) + } + want := .14 + 1500*loadpoint.DefaultChargeEfficiency/76000 + if math.Abs(float64(*car.SocPermille)/1000-want) > .001 { + t.Fatalf("level %d, want %.3f", *car.SocPermille, want) + } +} + +// A level read while the car stood unplugged starts the session, and the +// session's energy is credited to the car when it ends. A level from before +// the car last left a charger is not carried: the car may have driven. +func TestALevelCarriesIntoTheSessionOnlyUntilTheCarLeaves(t *testing.T) { + h := newHarness(t, homeConfig(), nil, nil) + h.start() + h.report("tesla-ha", .50, h.now.Add(-20*time.Minute), "Disconnected") + h.tick(time.Second) + h.plug("easee", "s1") + if st := h.state("easee"); math.Abs(st.CurrentSoC-.50) > 1e-9 || st.SoCSource == "assumed" { + t.Fatalf("parked reading not carried: %+v", st) + } + h.deliver("easee", "s1", 7600) + h.unplug("easee") + car := h.only() + if car.EnergySinceWh != 7600 || !car.SocEstimate { + t.Fatalf("after the session: %+v", car) + } + h.tick(8 * time.Hour) + h.plug("easee", "s2") + st := h.state("easee") + if st.SoCSource != "assumed" || math.Abs(st.CurrentSoC-.2) > 1e-9 { + t.Fatalf("a level from before a drive was carried: %+v", st) + } + if car := h.only(); car.SocSource != protocol.CarSoCAssumed || !car.SocEstimate { + t.Fatalf("car level %+v", car) + } +} + +// Two cars on one Easee, and neither the charger nor the cars say which is +// plugged in. For a minute the session is a visitor's; then the box asks, +// once. A tap names the car, and the loadpoint the planner reads takes that +// car's capacity, goal and surplus choice. +func TestTwoCarsAskWhichThenTheTapSetsThePlan(t *testing.T) { + h := newHarness(t, homeConfig(), map[string]loadpoint.Schedule{"easee": homeGoal(t)}, nil) + h.start() + tesla := h.only() + volvo := h.add(protocol.Car{Name: "Volvo", CapacityWh: 40000, SurplusOnly: true, + Goal: &protocol.CarGoal{TargetPermille: 600, ReadyByMin: 390, Days: 0x1F}}) + + h.plug("easee", "s1") + st := h.state("easee") + if !st.SoCUnknown || st.CarID != "" || st.CarIdentifiedBy != protocol.CarIdentifiedByNone || !st.SurplusOnly || + !goalOnly(st.Schedule).Empty() { + t.Fatalf("an unnamed car charges as a visitor: %+v", st) + } + h.tick(30 * time.Second) + if len(h.asked) != 0 { + t.Fatal("asked before the charger and the cars had their minute") + } + h.tick(31 * time.Second) + h.tick(time.Minute) + if len(h.asked) != 1 || h.asked[0].LoadpointID != "easee" || len(h.asked[0].CarIDs) != 2 || + h.asked[0].CarIDs[0] != tesla.ID || h.asked[0].CarIDs[1] != volvo.ID { + t.Fatalf("car.which = %+v, want once with both cars", h.asked) + } + + held, err := h.tap("easee", volvo.ID) + if err != nil || held.CarID != volvo.ID || held.CarIdentifiedBy != protocol.CarIdentifiedByTap { + t.Fatalf("tap: %+v %v", held, err) + } + h.tick(time.Second) + st = h.state("easee") + if st.VehicleCapacityWh != 40000 || !st.SurplusOnly || st.CarID != volvo.ID || st.SoCUnknown { + t.Fatalf("the plan does not follow the Volvo: %+v", st) + } + clock, _ := st.Schedule.TimeOfDayMin() + if st.Schedule.SoC != .6 || clock != 390 || !st.Schedule.Recurring || st.Schedule.Days != 0x1F { + t.Fatalf("goal %+v", st.Schedule) + } + if st.TargetSoC != .6 || st.TargetTime.IsZero() { + t.Fatalf("the roll did not take the Volvo's goal: target %v at %v", st.TargetSoC, st.TargetTime) + } + if v := h.car(volvo.ID); v.SocSource != protocol.CarSoCAssumed || !v.SocEstimate { + t.Fatalf("the Volvo has no reading, so its level is assumed: %+v", v) + } + if len(h.asked) != 1 { + t.Fatal("asked twice in one plug-in") + } +} + +// A visitor's car never gets a guessed level: not at plug-in, not after +// energy flows, not on the watch. A goal someone sets for it lasts the +// session and never becomes a car's. +func TestAVisitorNeverGetsAGuessedLevel(t *testing.T) { + h := newHarness(t, homeConfig(), map[string]loadpoint.Schedule{"easee": homeGoal(t)}, nil) + h.start() + carOne := h.only() + h.add(protocol.Car{Name: "Volvo", CapacityWh: 40000}) + h.plug("easee", "s1") + h.deliver("easee", "s1", 3000) + st := h.state("easee") + // The configured capacity converts the energy, so a goal set for the + // visitor gets closer and a manual hold can release; the level itself + // is never shown. + if !st.SoCUnknown || st.SoCSource != "unknown" || st.VehicleCapacityWh != 76000 || + math.Abs(st.CurrentSoC-3000*loadpoint.DefaultChargeEfficiency/76000) > 1e-9 { + t.Fatalf("visitor %+v", st) + } + if _, ok := h.rt.Session("easee"); ok { + t.Fatal("a visitor's session has a car view") + } + + visitorGoal := loadpoint.NewSchedule(.5, 360, false, 0, false) + if ok, err := h.mgr.SetScheduleChecked("easee", visitorGoal); !ok || err != nil { + t.Fatalf("goal: %v %v", ok, err) + } + h.tick(time.Second) + if g := h.car(carOne.ID).Goal; g == nil || g.TargetPermille != 800 { + t.Fatalf("the visitor's goal became car 1's: %+v", g) + } + if st := h.state("easee"); st.Schedule.SoC != .5 || !st.SoCUnknown { + t.Fatalf("the visitor's own goal was not kept: %+v", st.Schedule) + } + h.unplug("easee") + if st := h.state("easee"); !goalEqual(st.Schedule, homeGoal(t)) || st.SurplusOnly { + t.Fatalf("after the visitor left the charger shows %+v", st.Schedule) + } +} + +// The tap was wrong. Moving the session to the other car moves its energy: +// the first car is back where it was, and the second has what was delivered. +func TestAMidSessionSwitchMovesTheEnergy(t *testing.T) { + h := newHarness(t, homeConfig(), nil, nil) + h.start() + tesla := h.only() + volvo := h.add(protocol.Car{Name: "Volvo", CapacityWh: 40000}) + if _, err := h.rt.UpdateCar(tesla.ID, Update{Sources: &[]string{"tesla-ha"}}); err != nil { + t.Fatal(err) + } + h.report("tesla-ha", .50, h.now.Add(-10*time.Minute), "Disconnected") + h.tick(time.Second) + + h.plug("easee", "s1") + if _, err := h.tap("easee", tesla.ID); err != nil { + t.Fatal(err) + } + h.tick(time.Second) + h.deliver("easee", "s1", 5000) + if got := h.car(tesla.ID); got.EnergySinceWh != 5000 { + t.Fatalf("tesla before the switch %+v", got) + } + + if _, err := h.tap("easee", volvo.ID); err != nil { + t.Fatal(err) + } + h.tick(time.Second) + if got := h.car(tesla.ID); got.EnergySinceWh != 0 || *got.SocPermille != 500 { + t.Fatalf("tesla kept energy it never got: %+v", got) + } + v := h.car(volvo.ID) + if v.EnergySinceWh != 5000 { + t.Fatalf("volvo did not get the session's energy: %+v", v) + } + want := .2 + 5000*loadpoint.DefaultChargeEfficiency/40000 + if st := h.state("easee"); math.Abs(st.CurrentSoC-want) > 1e-9 || st.VehicleCapacityWh != 40000 || + st.CarCorrectedFrom != tesla.ID { + t.Fatalf("charger %+v, want level %.4f", st, want) + } + h.unplug("easee") + if got := h.car(volvo.ID); got.EnergySinceWh != 5000 { + t.Fatalf("volvo after unplug %+v", got) + } + if got := h.car(tesla.ID); got.EnergySinceWh != 0 { + t.Fatalf("tesla after unplug %+v", got) + } +} + +// The identification order: the charger's own word first, and a later, +// better rule corrects a tap and says so. +func TestTheChargerNamesTheCarAndALaterRuleCorrectsATap(t *testing.T) { + h := newHarness(t, homeConfig(), nil, nil) + h.start() + tesla := h.only() + volvo := h.add(protocol.Car{Name: "Volvo", CapacityWh: 40000, Identifiers: []string{"04:AB:CD:EF"}}) + + h.plug("easee", "s1") + h.rt.ChargerIdentified("easee", "04:ab:cd:ef") + h.tick(time.Second) + if st := h.state("easee"); st.CarID != volvo.ID || st.CarIdentifiedBy != protocol.CarIdentifiedByCharger { + t.Fatalf("charger rule: %+v", st) + } + h.unplug("easee") + + // Next plug-in: nobody says. Someone taps the Tesla, but it is the + // Volvo, and its source reports it charging here. + if _, err := h.rt.UpdateCar(volvo.ID, Update{Sources: &[]string{"volvo-cloud"}}); err != nil { + t.Fatal(err) + } + h.tick(time.Minute) + h.plug("easee", "s2") + if _, err := h.tap("easee", tesla.ID); err != nil { + t.Fatal(err) + } + h.tick(time.Second) + h.obs["volvo-cloud"] = telemetry.VehicleObservation{Driver: "volvo-cloud", SoC: .3, At: h.now, + ChargingState: "Charging", AtSite: boolp(true), ReportedAt: h.now} + h.deliver("easee", "s2", 500) + st := h.state("easee") + if st.CarID != volvo.ID || st.CarIdentifiedBy != protocol.CarIdentifiedByCar || st.CarCorrectedFrom != tesla.ID { + t.Fatalf("correction: %+v", st) + } + if got := h.rt.ChargerCar("easee"); got.CarCorrectedFrom != tesla.ID { + t.Fatalf("the correction is not shown: %+v", got) + } +} + +// Rule 2: a car whose own source saw the cable go in within a minute of the +// plug-in names itself. A car somewhere else does not. +func TestTheCarNamesItselfWithinAMinuteOfThePlugIn(t *testing.T) { + h := newHarness(t, homeConfig(), nil, nil) + h.start() + tesla := h.only() + volvo := h.add(protocol.Car{Name: "Volvo", CapacityWh: 40000, Sources: []string{"volvo-cloud"}}) + if _, err := h.rt.UpdateCar(tesla.ID, Update{Sources: &[]string{"tesla-ha"}}); err != nil { + t.Fatal(err) + } + unplugged := func(d string, atSite *bool) telemetry.VehicleObservation { + return telemetry.VehicleObservation{Driver: d, SoC: .4, At: h.now, Plugged: boolp(false), AtSite: atSite, ReportedAt: h.now} + } + h.obs["tesla-ha"] = unplugged("tesla-ha", boolp(true)) + h.obs["volvo-cloud"] = unplugged("volvo-cloud", boolp(false)) + h.tick(time.Second) + + h.plug("easee", "s1") + // The Volvo is plugged in at work at the same moment; it says where. + away := unplugged("volvo-cloud", boolp(false)) + away.Plugged, away.ReportedAt = boolp(true), h.now + h.obs["volvo-cloud"] = away + here := unplugged("tesla-ha", boolp(true)) + here.Plugged, here.ReportedAt = boolp(true), h.now.Add(20*time.Second) + h.obs["tesla-ha"] = here + h.tick(20 * time.Second) + if st := h.state("easee"); st.CarID != tesla.ID || st.CarIdentifiedBy != protocol.CarIdentifiedByCar { + t.Fatalf("car rule: %+v (volvo %s)", st, volvo.ID) + } + h.tick(2 * time.Minute) + if len(h.asked) != 0 { + t.Fatal("asked although the car named itself") + } +} + +// A restart in the middle of a session keeps the session's car and its +// capacity, so the loadpoint restores its own level. +func TestARestartKeepsTheSessionsCar(t *testing.T) { + h := newHarness(t, homeConfig(), nil, nil) + h.start() + h.add(protocol.Car{Name: "Volvo", CapacityWh: 40000}) + volvo := h.rt.Cars()[1] + h.plug("easee", "s1") + if _, err := h.tap("easee", volvo.ID); err != nil { + t.Fatal(err) + } + h.tick(time.Second) + h.deliver("easee", "s1", 2000) + h.tick(2 * time.Minute) // past the level save interval + + again := newHarness(t, homeConfig(), nil, h.kv) + again.now = h.now.Add(time.Minute) + again.start() + again.wh["easee"] = 2000 + again.deliver("easee", "s1", 0) + again.tick(time.Second) + st := again.state("easee") + if st.CarID != volvo.ID || st.CarIdentifiedBy != protocol.CarIdentifiedByTap || st.VehicleCapacityWh != 40000 { + t.Fatalf("after restart: %+v", st) + } + if len(again.asked) != 0 { + t.Fatal("asked again after a restart") + } +} + +// vehicles[] profiles become cars with their identifiers once. One charger +// and one profile is one car: the profile names it to the charger, and it is +// also the only car, so the charger keeps its goal and nothing asks. +func TestProfilesBecomeCars(t *testing.T) { + h := newHarness(t, homeConfig(), map[string]loadpoint.Schedule{"easee": homeGoal(t)}, nil) + h.start(Profile{ID: "ioniq", Name: "Ioniq", CapacityWh: 58000, Identifiers: []string{"RFID-9"}, SurplusOnly: true}) + car := h.only() + if car.Name != "Ioniq" || car.Identifiers[0] != "RFID-9" || car.CapacityWh != 58000 || car.SurplusOnly || + car.Goal == nil || car.Goal.TargetPermille != 800 || !car.BoxMinted { + t.Fatalf("merged car %+v", car) + } + h.rt.Reload([]Profile{{ID: "ioniq", Name: "Ioniq"}, {ID: "zoe", Name: "Zoe", Identifiers: []string{"RFID-2"}}}) + if got := h.rt.Cars(); len(got) != 2 || got[1].Name != "Zoe" { + t.Fatalf("after reload %+v", got) + } + + // Two profiles from the start are two cars beside the charger's own. + two := newHarness(t, homeConfig(), nil, nil) + two.start(Profile{ID: "a", Name: "A", Identifiers: []string{"1"}}, Profile{ID: "b", Name: "B", Identifiers: []string{"2"}}) + if got := two.rt.Cars(); len(got) != 3 { + t.Fatalf("cars %+v", got) + } +} + +// One release of overlap: a goal, a surplus choice and a level set through +// the charger's routes are the car's. +func TestTheChargerRoutesActOnTheCar(t *testing.T) { + h := newHarness(t, homeConfig(), map[string]loadpoint.Schedule{"easee": homeGoal(t)}, nil) + h.start() + id := h.only().ID + if ok, err := h.mgr.SetScheduleChecked("easee", loadpoint.NewSchedule(.9, 450, true, 0, false)); !ok || err != nil { + t.Fatal(ok, err) + } + if prev, ok, err := h.mgr.SetSurplusOnlyChecked("easee", true); prev || !ok || err != nil { + t.Fatal(prev, ok, err) + } + car := h.car(id) + if car.Goal == nil || car.Goal.TargetPermille != 900 || car.Goal.ReadyByMin != 450 || !car.SurplusOnly { + t.Fatalf("car %+v goal %+v", car, car.Goal) + } + h.plug("easee", "s1") + if !h.mgr.SetCurrentSoC("easee", .37) { + t.Fatal("SetCurrentSoC") + } + if car := h.car(id); car.SocSource != protocol.CarSoCManual || *car.SocPermille != 370 || car.SocEstimate { + t.Fatalf("level %+v", car) + } + var stored record + raw, _ := h.kv.LoadConfig(StoreKey) + if err := json.Unmarshal([]byte(raw), &stored); err != nil || stored.Cars[0].Goal.SoC != .9 { + t.Fatalf("not stored: %v %s", err, raw) + } +} + +// car.goal.set, car.soc.set and car.remove on a car that is not plugged in. +func TestCarOps(t *testing.T) { + h := newHarness(t, homeConfig(), nil, nil) + h.start() + id := h.only().ID + car, err := h.rt.SetGoal(id, &protocol.CarGoal{TargetPermille: 700, ReadyByMin: 480}, false, boolp(true)) + if err != nil || car.Goal == nil || car.Goal.Days != 0 || !car.SurplusOnly { + t.Fatalf("goal %+v %v", car, err) + } + if st := h.state("easee"); st.Schedule.SoC != .7 || st.Schedule.Recurring || !st.SurplusOnly { + t.Fatalf("the charger the car last had did not follow: %+v", st.Schedule) + } + car, err = h.rt.SetLevel(id, .42) + if err != nil || car.SocSource != protocol.CarSoCManual || *car.SocPermille != 420 { + t.Fatalf("level %+v %v", car, err) + } + if _, err := h.rt.SetGoal("nope", nil, true, nil); err != ErrUnknownCar { + t.Fatalf("unknown car: %v", err) + } + if _, err := h.rt.SetSessionCar("easee", "", id); err != ErrUnplugged { + t.Fatalf("tap while unplugged: %v", err) + } + // The same id again is an update of that car, not a second car. + if twin, err := h.rt.Add(protocol.Car{ID: id, Name: "Twin"}); err != nil || twin.Name != "Twin" || len(h.rt.Cars()) != 1 { + t.Fatalf("upsert %+v %v", twin, err) + } + if err := h.rt.Remove(id); err != nil || len(h.rt.Cars()) != 0 { + t.Fatalf("remove: %v", err) + } +} + +// A cars row the box cannot read is kept aside, and the cars are made again +// from the chargers' own rows, which the box keeps writing for this reason. +func TestAnUnreadableRowIsKeptAndTheCarsMadeAgain(t *testing.T) { + kv := &memKV{rows: map[string]string{StoreKey: "{not json"}} + h := newHarness(t, homeConfig(), map[string]loadpoint.Schedule{"easee": homeGoal(t)}, kv) + h.start() + if car := h.only(); car.Goal == nil || car.Goal.TargetPermille != 800 || car.CapacityWh != 76000 { + t.Fatalf("car made again %+v", car) + } + if raw, _ := kv.LoadConfig(StoreKey + ":unreadable"); raw != "{not json" { + t.Fatalf("the unreadable row was not kept: %q", raw) + } +} diff --git a/go/internal/cars/edges_test.go b/go/internal/cars/edges_test.go new file mode 100644 index 000000000..375168d06 --- /dev/null +++ b/go/internal/cars/edges_test.go @@ -0,0 +1,293 @@ +package cars + +import ( + "errors" + "math" + "slices" + "testing" + "time" + + "github.com/srcfl/ftw/go/internal/loadpoint" + "github.com/srcfl/ftw/go/protocol" +) + +// An edit to a vehicles[] profile in config.yaml reaches its car, and only +// what changed: a name the owner gave the car since stays, and so does a +// choice the owner made after the edit. +func TestAProfileEditReachesItsCar(t *testing.T) { + h := newHarness(t, homeConfig(), nil, nil) + p := Profile{ID: "volvo", Name: "Volvo", CapacityWh: 40000, Identifiers: []string{"04:AB"}} + h.start(p) + car := h.only() + if _, err := h.rt.UpdateCar(car.ID, Update{Name: strp("Family car")}); err != nil { + t.Fatal(err) + } + edited := p + edited.CapacityWh, edited.Identifiers, edited.SurplusOnly, edited.TargetSoC = 42000, []string{"04:AB", "rfid-9"}, true, .7 + h.rt.Reload([]Profile{edited}) + got := h.only() + if got.Name != "Family car" || got.CapacityWh != 42000 || !slices.Equal(got.Identifiers, []string{"04:AB", "rfid-9"}) || !got.SurplusOnly { + t.Fatalf("after the edit %+v", got) + } + if _, err := h.rt.SetGoal(car.ID, nil, false, boolp(false)); err != nil { + t.Fatal(err) + } + h.rt.Reload([]Profile{edited}) + if h.only().SurplusOnly { + t.Fatal("a reload with no edit undid the owner's choice") + } +} + +// Removing a car leaves its drivers unbound: they never move to the car that +// is left. +func TestARemovedCarsDriverStaysUnbound(t *testing.T) { + h := newHarness(t, homeConfig(), nil, nil) + h.start() + tesla := h.only() + volvo := h.add(protocol.Car{Name: "Volvo", Sources: []string{"volvo-cloud"}}) + if err := h.rt.Remove(volvo.ID); err != nil { + t.Fatal(err) + } + h.report("volvo-cloud", .4, h.now, "") + h.tick(time.Second) + if got := h.car(tesla.ID); slices.Contains(got.Sources, "volvo-cloud") || got.SocSource == protocol.CarSoCVehicle { + t.Fatalf("the removed car's driver went to the car left: %+v", got) + } +} + +// A visitor charges on the charger's battery size, and a config reload keeps +// it so rather than leaving none. +func TestAReloadKeepsAVisitorsCapacity(t *testing.T) { + h := newHarness(t, homeConfig(), nil, nil) + h.start() + if _, err := h.rt.UpdateCar(h.only().ID, Update{Identifiers: &[]string{"rfid-7"}}); err != nil { + t.Fatal(err) + } + h.rt.ChargerIdentified("easee", "rfid-guest") + h.plug("easee", "s1") + cfgs := homeConfig() + cfgs[0].VehicleCapacityWh = 60000 + h.mgr.Load(cfgs) + if st := h.state("easee"); !st.SoCUnknown || st.VehicleCapacityWh != 60000 { + t.Fatalf("visitor after the reload: unknown=%v capacity %v", st.SoCUnknown, st.VehicleCapacityWh) + } +} + +// The car ops check everything before they change anything; a tap may not +// take a car another charger has; an id comes back from its tombstone; and +// the box keeps at most MaxCars cars. +func TestCarOpsRefuseBeforeTheyChange(t *testing.T) { + t.Run("a bad add does not adopt", func(t *testing.T) { + h := newHarness(t, homeConfig(), nil, nil) + h.start() + minted := h.only() + vin := "5YJ3E7EB7KF000001" + if _, err := h.rt.UpdateCar(minted.ID, Update{VIN: &vin}); err != nil { + t.Fatal(err) + } + _, err := h.rt.Add(protocol.Car{ID: newID(), Name: "Tesla", VIN: vin, CapacityWh: 5}) + if !errors.Is(err, ErrInvalid) { + t.Fatalf("err %v", err) + } + if got := h.only(); got.ID != minted.ID || !got.BoxMinted { + t.Fatalf("a refused add took the box's car: %+v", got) + } + }) + t.Run("a car on another charger", func(t *testing.T) { + h := newHarness(t, []loadpoint.Config{{ID: "easee", DriverName: "easee"}, {ID: "garage", DriverName: "garage"}}, nil, nil) + h.start() + first := h.rt.Cars()[0] + h.plug("easee", "s1") + if h.state("easee").CarID != first.ID { + t.Fatalf("easee %+v", h.state("easee")) + } + h.mgr.ObserveSession("garage", true, 0, 0, true, "garage:G1", "g1") + h.rt.Tick() + if _, err := h.rt.SetSessionCar("garage", h.rt.ChargerCar("garage").Session, first.ID); !errors.Is(err, ErrElsewhere) { + t.Fatalf("err %v", err) + } + }) + t.Run("a removed id comes back", func(t *testing.T) { + h := newHarness(t, homeConfig(), nil, nil) + h.start() + gone := h.add(protocol.Car{Name: "Old"}) + if err := h.rt.Remove(gone.ID); err != nil { + t.Fatal(err) + } + if _, err := h.rt.UpdateCar(h.only().ID, Update{NewID: &gone.ID}); err != nil { + t.Fatal(err) + } + if got := h.rt.Removed(); len(got) != 0 { + t.Fatalf("the live car's id is still a tombstone: %+v", got) + } + }) + t.Run("at most MaxCars", func(t *testing.T) { + h := newHarness(t, homeConfig(), nil, nil) + h.start() + for len(h.rt.Cars()) < MaxCars { + h.add(protocol.Car{Name: "Car"}) + } + if _, err := h.rt.Add(protocol.Car{ID: newID(), Name: "One more"}); !errors.Is(err, ErrTooMany) { + t.Fatalf("err %v", err) + } + for range maxTombstones + 5 { + c := h.rt.Cars()[1] + if err := h.rt.Remove(c.ID); err != nil { + t.Fatal(err) + } + h.add(protocol.Car{Name: "Again"}) + } + if got := len(h.rt.Removed()); got != maxTombstones { + t.Fatalf("%d tombstones, want %d", got, maxTombstones) + } + }) +} + +// A level the box adopts from the loadpoint keeps its reading's time and the +// energy since: once more than 1 kWh has flowed it is an estimate, not "from +// the car" or the person's own number. +func TestAnAdoptedLevelKeepsItsReadingsTime(t *testing.T) { + h := newHarness(t, homeConfig(), nil, nil) + h.plug("easee", "s1") + h.mgr.SetCurrentSoC("easee", .40) + setAt := h.now + h.deliver("easee", "s1", 3000) + h.start() + h.tick(time.Second) + car := h.only() + if car.SocSource != protocol.CarSoCManual || !car.SocEstimate || car.EnergySinceWh < 2990 || car.EnergySinceWh > 3010 { + t.Fatalf("adopted %+v energy %d", car, car.EnergySinceWh) + } + if want := int64(h.now.Sub(setAt) / time.Second); car.SocAgeS == nil || *car.SocAgeS != want { + t.Fatalf("age %v, want %d", car.SocAgeS, want) + } + if want := permille(0.40 + 3000*loadpoint.DefaultChargeEfficiency/76000); *car.SocPermille != want { + t.Fatalf("shown %d, want the estimate %d", *car.SocPermille, want) + } +} + +func permille(f float64) int64 { return int64(math.Round(f * 1000)) } + +// A reading more than an hour older than the plug-in does not start the +// session, whenever it arrives: the car may have driven since. +func TestAReadingFromLongBeforeThePlugInIsNotItsStart(t *testing.T) { + h := newHarness(t, homeConfig(), nil, nil) + h.start() + car := h.only() + if _, err := h.rt.UpdateCar(car.ID, Update{Sources: &[]string{"tesla-ha"}}); err != nil { + t.Fatal(err) + } + h.report("tesla-ha", .62, h.now, "") + h.tick(time.Second) + h.tick(2 * time.Hour) + h.plug("easee", "s1") + if st := h.state("easee"); st.SoCSource != "assumed" || math.Abs(st.CurrentSoC-.2) > 1e-9 { + t.Fatalf("a reading two hours old started the session: %s %v", st.SoCSource, st.CurrentSoC) + } + // The same reading arriving late changes nothing either. + h.report("tesla-ha", .55, h.now.Add(-90*time.Minute), "") + h.tick(time.Second) + if st := h.state("easee"); st.SoCSource != "assumed" { + t.Fatalf("a late old reading anchored the session: %+v", st.SoCSource) + } +} + +// A cable that flaps for one reading keeps its session, its car and its +// level; what flowed before the flap stays the car's. A new charger session +// is a new plug-in. +func TestAFlapKeepsTheSessionAndItsLevel(t *testing.T) { + h := newHarness(t, homeConfig(), nil, nil) + h.start() + car := h.only() + if _, err := h.rt.UpdateCar(car.ID, Update{Sources: &[]string{"tesla-ha"}}); err != nil { + t.Fatal(err) + } + h.plug("easee", "s1") + h.report("tesla-ha", .60, h.now, "Charging") + h.tick(time.Second) + h.deliver("easee", "s1", 500) + session := h.state("easee").Session + + h.flap("easee") + h.plug("easee", "s1") + st := h.state("easee") + want := .60 + 500*loadpoint.DefaultChargeEfficiency/76000 + if st.Session != session || st.CarID != car.ID || math.Abs(st.CurrentSoC-want) > 1e-6 { + t.Fatalf("after the flap: session %q (was %q) car %q soc %v want %v", st.Session, session, st.CarID, st.CurrentSoC, want) + } + h.deliver("easee", "s1", 700) + if got := h.car(car.ID); got.EnergySinceWh < 1190 || got.EnergySinceWh > 1210 || !got.SocEstimate { + t.Fatalf("the energy before the flap is lost: %+v", got) + } + + h.flap("easee") + h.plug("easee", "s2") + if st := h.state("easee"); st.Session == session { + t.Fatal("a new charger session kept the old plug-in") + } +} + +// A box with two chargers names the car it made from each at that charger, +// as before cars, and tells listeners when it asks. A car added since could +// be on either, and the box asks. +func TestTheUsualChargerNamesItsCar(t *testing.T) { + two := []loadpoint.Config{{ID: "easee", DriverName: "easee", VehicleCapacityWh: 76000}, {ID: "garage", DriverName: "garage", VehicleCapacityWh: 40000}} + h := newHarness(t, two, nil, nil) + h.start() + cars := h.rt.Cars() + h.plug("easee", "s1") + if st := h.state("easee"); st.CarID != cars[0].ID || st.CarIdentifiedBy != protocol.CarIdentifiedByOnly { + t.Fatalf("easee %+v", st) + } + + added := newHarness(t, two, nil, nil) + added.start() + added.add(protocol.Car{Name: "Volvo"}) + changes := 0 + added.rt.OnChange(func() { changes++ }) + added.plug("easee", "s1") + before := changes + added.tick(61 * time.Second) + if st := added.state("easee"); st.CarIdentifiedBy != protocol.CarIdentifiedByAsking { + t.Fatalf("with a car added since: %+v", st.CarIdentifiedBy) + } + if changes == before { + t.Fatal("asking did not tell the listeners") + } +} + +// A goal set on a charger in the moment before the box opened its session +// lands on the car the box can name there, or on the session alone; never on +// the car the charger last had. +func TestARouteBeforeTheTickActsOnTheSession(t *testing.T) { + goal := loadpoint.NewSchedule(.7, 360, false, 0, false) + t.Run("one car", func(t *testing.T) { + h := newHarness(t, homeConfig(), nil, nil) + h.start() + car := h.only() + h.mgr.ObserveSession("easee", true, 0, 0, true, "easee:EH1", "s1") + if _, err := h.mgr.SetScheduleChecked("easee", goal); err != nil { + t.Fatal(err) + } + if got := h.car(car.ID); got.Goal == nil || got.Goal.TargetPermille != 700 { + t.Fatalf("car %+v", got.Goal) + } + }) + t.Run("two cars", func(t *testing.T) { + h := newHarness(t, homeConfig(), nil, nil) + h.start() + tesla := h.only() + h.add(protocol.Car{Name: "Volvo"}) + h.mgr.ObserveSession("easee", true, 0, 0, true, "easee:EH1", "s1") + if _, err := h.mgr.SetScheduleChecked("easee", goal); err != nil { + t.Fatal(err) + } + if got := h.car(tesla.ID); got.Goal != nil { + t.Fatalf("the car the charger last had took the goal: %+v", got.Goal) + } + h.tick(time.Second) + if st := h.state("easee"); st.CarID != "" || st.Schedule.SoC != .7 { + t.Fatalf("the session's own goal %+v car %q", st.Schedule, st.CarID) + } + }) +} diff --git a/go/internal/cars/harness_test.go b/go/internal/cars/harness_test.go new file mode 100644 index 000000000..a33797b6d --- /dev/null +++ b/go/internal/cars/harness_test.go @@ -0,0 +1,208 @@ +package cars + +import ( + "encoding/json" + "sync" + "testing" + "time" + + "github.com/srcfl/ftw/go/internal/events" + "github.com/srcfl/ftw/go/internal/loadpoint" + "github.com/srcfl/ftw/go/internal/telemetry" + "github.com/srcfl/ftw/go/protocol" +) + +// memKV is state.db's config table in memory. +type memKV struct { + mu sync.Mutex + rows map[string]string +} + +func (k *memKV) LoadConfig(key string) (string, bool) { + k.mu.Lock() + defer k.mu.Unlock() + v, ok := k.rows[key] + return v, ok +} + +func (k *memKV) SaveConfig(key, value string) error { + k.mu.Lock() + defer k.mu.Unlock() + k.rows[key] = value + return nil +} + +// harness is one box: a loadpoint manager, the cars runtime, the vehicle +// drivers' reports and a clock the test moves. +type harness struct { + t *testing.T + now time.Time + kv *memKV + mgr *loadpoint.Manager + rt *Runtime + obs map[string]telemetry.VehicleObservation + ident map[string][2]string + asked []events.CarWhich + wh map[string]float64 +} + +// homeConfig is Fredrik's box on 15 October 2026: one Easee at the carport, +// a 76 kWh car, a goal of 80 % by 07:00 every day, and no surplus-only. +func homeConfig() []loadpoint.Config { + return []loadpoint.Config{{ID: "easee", DriverName: "easee", VehicleCapacityWh: 76000, MaxChargeW: 11000, MinChargeW: 4140}} +} + +func homeGoal(t *testing.T) loadpoint.Schedule { + t.Helper() + var s loadpoint.Schedule + if err := json.Unmarshal([]byte(`{"soc":0.8,"time_of_day_min_utc":300,"recurring":true,"time_of_day_min":420}`), &s); err != nil { + t.Fatal(err) + } + return s +} + +func newHarness(t *testing.T, cfgs []loadpoint.Config, goals map[string]loadpoint.Schedule, kv *memKV) *harness { + t.Helper() + if kv == nil { + kv = &memKV{rows: map[string]string{}} + } + h := &harness{ + t: t, now: time.Date(2026, 10, 15, 16, 30, 0, 0, time.UTC), kv: kv, + mgr: loadpoint.NewManager(), obs: map[string]telemetry.VehicleObservation{}, + ident: map[string][2]string{}, wh: map[string]float64{}, + } + h.mgr.SetNowFn(func() time.Time { return h.now }) + h.mgr.SetLocation(time.UTC) + h.mgr.Load(cfgs) + if err := h.mgr.HydrateSchedules(func(id string) (loadpoint.Schedule, bool) { + s, ok := goals[id] + return s, ok + }); err != nil { + t.Fatal(err) + } + bus := events.NewBus() + bus.Subscribe(events.KindCarWhich, func(e events.Event) { h.asked = append(h.asked, e.(events.CarWhich)) }) + h.rt = New(Deps{ + Store: kv, Loadpoints: h.mgr, Bus: bus, Now: func() time.Time { return h.now }, + Vehicles: func() []telemetry.VehicleObservation { + out := make([]telemetry.VehicleObservation, 0, len(h.obs)) + for _, o := range h.obs { + out = append(out, o) + } + return out + }, + Identity: func(d string) (string, string) { return h.ident[d][0], h.ident[d][1] }, + }) + // Wired as main.go wires them: a goal, a surplus choice or a level set + // on a charger is its car's. + h.mgr.SetScheduleSaver(func(id string, s loadpoint.Schedule) error { + _, err := h.rt.GoalSaved(id, s) + return err + }) + h.mgr.SetSurplusOnlySaver(func(id string, v bool) error { + _, err := h.rt.SurplusSaved(id, v) + return err + }) + h.mgr.SetLevelObserver(h.rt.LevelSet) + return h +} + +func (h *harness) start(profiles ...Profile) { + h.t.Helper() + if err := h.rt.Start(profiles); err != nil { + h.t.Fatal(err) + } + // Past the window in which a plug-in counts as found at start. + h.now = h.now.Add(time.Minute) +} + +// tap is the one tap, naming the plug-in the charger shows. +func (h *harness) tap(lp, car string) (protocol.ChargerCar, error) { + return h.rt.SetSessionCar(lp, h.rt.ChargerCar(lp).Session, car) +} + +// add adds a car with an id the test mints, as the owner's app does. +func (h *harness) add(c protocol.Car) protocol.Car { + h.t.Helper() + if c.ID == "" { + c.ID = newID() + } + got, err := h.rt.Add(c) + if err != nil { + h.t.Fatal(err) + } + return got +} + +// tick moves the clock and runs what main.go runs each control tick: the +// chargers' observations, then the cars. +func (h *harness) tick(d time.Duration) { + h.now = h.now.Add(d) + h.mgr.RollSchedules(h.now) + h.rt.Tick() +} + +// plug and unplug drive the charger as the Easee driver reports it. +func (h *harness) plug(lp, session string) { + h.wh[lp] = 0 + h.mgr.ObserveSession(lp, true, 0, 0, true, "easee:EH1", session) + h.rt.Tick() +} + +// deliver charges at 7 kW for as long as wh takes: the loadpoint does not +// believe a counter that outruns the power. +func (h *harness) deliver(lp, session string, wh float64) { + h.wh[lp] += wh + h.now = h.now.Add(time.Duration(wh / 7000 * float64(time.Hour))) + h.mgr.ObserveSession(lp, true, 7000, h.wh[lp], true, "easee:EH1", session) + h.rt.Tick() +} + +// unplug is a cable out for good: the charger says so past UnplugGrace. +func (h *harness) unplug(lp string) { + h.mgr.ObserveSession(lp, false, 0, 0, false, "easee:EH1", "") + h.rt.Tick() + h.now = h.now.Add(UnplugGrace) + h.mgr.ObserveSession(lp, false, 0, 0, false, "easee:EH1", "") + h.rt.Tick() +} + +// flap is a cable that reads as out for one tick and is back. +func (h *harness) flap(lp string) { + h.mgr.ObserveSession(lp, false, 0, 0, false, "easee:EH1", "") + h.tick(5 * time.Second) +} + +// report is a vehicle driver's level report. +func (h *harness) report(driver string, soc float64, at time.Time, state string) { + h.obs[driver] = telemetry.VehicleObservation{Driver: driver, SoC: soc, At: at, ChargingState: state, ReportedAt: at} +} + +func (h *harness) car(id string) protocol.Car { + h.t.Helper() + c, err := h.rt.Car(id) + if err != nil { + h.t.Fatal(err) + } + return c +} + +func (h *harness) only() protocol.Car { + h.t.Helper() + cs := h.rt.Cars() + if len(cs) != 1 { + h.t.Fatalf("%d cars, want 1", len(cs)) + } + return cs[0] +} + +func (h *harness) state(lp string) loadpoint.State { + h.t.Helper() + st, ok := h.mgr.State(lp) + if !ok { + h.t.Fatalf("no loadpoint %s", lp) + } + return st +} + +func boolp(v bool) *bool { return &v } diff --git a/go/internal/cars/ops.go b/go/internal/cars/ops.go new file mode 100644 index 000000000..a4976cd7b --- /dev/null +++ b/go/internal/cars/ops.go @@ -0,0 +1,785 @@ +package cars + +import ( + "fmt" + "log/slog" + "math" + "slices" + "strings" + "time" + + "github.com/srcfl/ftw/go/internal/loadpoint" + "github.com/srcfl/ftw/go/internal/units" + "github.com/srcfl/ftw/go/protocol" +) + +// The operations (docs/cars.md, Ops). Each changes the record under the lock, +// saves it, and then hands the loadpoints what changed. + +// Update is car.update's arguments: a nil field is left alone. +type Update struct { + // NewID re-keys a car the box minted to the id the owner's app holds. + NewID *string + Name *string + Make *string + VIN *string + CapacityWh *float64 + MaxW *float64 + Phases *int + Identifiers *[]string + Sources *[]string +} + +// Add makes a car with the id the owner's app minted. An id the box already +// has is an update of that car. A car the box minted with the same VIN takes +// the app's id. A VIN or identifier another car has is refused. +func (r *Runtime) Add(c protocol.Car) (protocol.Car, error) { + r.syncMu.Lock() + defer r.syncMu.Unlock() + id := strings.TrimSpace(c.ID) + if !validID(id) { + return protocol.Car{}, fmt.Errorf("%w: id", ErrInvalid) + } + u := updateFrom(c) + var goal *loadpoint.Schedule + if c.Goal != nil { + g, err := scheduleFrom(*c.Goal) + if err != nil { + return protocol.Car{}, err + } + goal = &g + } + r.mu.Lock() + car := r.carLocked(id) + // adopt is a car the box minted with this VIN, which takes the app's id + // once the rest of the car checks out. + var adopt *Car + if car == nil { + if vin := normVIN(c.VIN); vin != "" { + for _, other := range r.rec.Cars { + if other.BoxMinted && other.VIN == vin { + adopt = other + break + } + } + } + } + base := car + if base == nil { + base = adopt + } + next := &Car{ID: id} + if base != nil { + cp := *base + cp.ID = id + cp.Identifiers, cp.Sources = slices.Clone(base.Identifiers), slices.Clone(base.Sources) + next = &cp + } else if strings.TrimSpace(c.Name) == "" { + r.mu.Unlock() + return protocol.Car{}, fmt.Errorf("%w: name", ErrInvalid) + } else if len(r.rec.Cars) >= MaxCars { + r.mu.Unlock() + return protocol.Car{}, ErrTooMany + } + if err := applyUpdate(next, u); err != nil { + r.mu.Unlock() + return protocol.Car{}, err + } + if goal != nil { + next.Goal = *goal + } + next.SurplusOnly = c.SurplusOnly + if err := r.duplicateLocked(next, base); err != nil { + r.mu.Unlock() + return protocol.Car{}, err + } + switch { + case car != nil: + *car = *next + case adopt != nil: + r.rekeyLocked(adopt, id) + next.BoxMinted = false + *adopt = *next + car = adopt + default: + car = next + r.rec.Cars = append(r.rec.Cars, car) + } + r.rec.Removed = slices.DeleteFunc(r.rec.Removed, func(t Tombstone) bool { return t.ID == id }) + r.takeSourcesLocked(car) + return r.commit("car_added", car) +} + +// UpdateCar changes what the box knows about a car. new_id re-keys a car the +// box minted to the id the owner's app holds. +func (r *Runtime) UpdateCar(id string, u Update) (protocol.Car, error) { + r.syncMu.Lock() + defer r.syncMu.Unlock() + r.mu.Lock() + car := r.carLocked(id) + if car == nil { + r.mu.Unlock() + return protocol.Car{}, ErrUnknownCar + } + rekey := u.NewID != nil && *u.NewID != id + if rekey { + var err error + switch { + case !car.BoxMinted: + err = fmt.Errorf("%w: only a car the box minted takes a new id", ErrInvalid) + case !validID(*u.NewID): + err = fmt.Errorf("%w: new_id", ErrInvalid) + case r.carLocked(*u.NewID) != nil: + err = fmt.Errorf("%w: new_id is another car's", ErrInvalid) + } + if err != nil { + r.mu.Unlock() + return protocol.Car{}, err + } + } + next := *car + next.Identifiers = slices.Clone(car.Identifiers) + next.Sources = slices.Clone(car.Sources) + if err := applyUpdate(&next, u); err != nil { + r.mu.Unlock() + return protocol.Car{}, err + } + if err := r.duplicateLocked(&next, car); err != nil { + r.mu.Unlock() + return protocol.Car{}, err + } + if u.Sources != nil { + // A driver the owner takes away stays away. + for _, d := range car.Sources { + if !slices.Contains(next.Sources, d) && !slices.Contains(r.rec.Unbound, d) { + r.rec.Unbound = append(r.rec.Unbound, d) + } + } + } + *car = next + if rekey { + r.rekeyLocked(car, *u.NewID) + } + r.takeSourcesLocked(car) + return r.commit("car_updated", car) +} + +// rekeyLocked gives a car the box minted the id the owner's app holds, and +// moves every reference to it. +func (r *Runtime) rekeyLocked(car *Car, id string) { + old := car.ID + car.ID, car.BoxMinted = id, false + for _, s := range r.rec.Sessions { + if s.CarID == old { + s.CarID = id + } + if s.CorrectedFrom == old { + s.CorrectedFrom = id + } + } + for lp, c := range r.rec.LastCar { + if c == old { + r.rec.LastCar[lp] = id + } + } + for p, c := range r.rec.Profiles { + if c == old { + r.rec.Profiles[p] = id + } + } + // The app's id is a live car again, whatever it was before. + r.rec.Removed = slices.DeleteFunc(r.rec.Removed, func(t Tombstone) bool { return t.ID == id }) + clear(r.applied) + slog.Info("cars: a car the box minted took the app's id", "from", old, "to", id) +} + +// duplicateLocked refuses a VIN or identifier another car has. self is the +// car being changed, which may still have its old id. +func (r *Runtime) duplicateLocked(car, self *Car) error { + for _, other := range r.rec.Cars { + if other.ID == car.ID || other == self { + continue + } + if car.VIN != "" && other.VIN == car.VIN { + return fmt.Errorf("%w: vin", ErrDuplicate) + } + for _, id := range car.Identifiers { + for _, o := range other.Identifiers { + if normIdentifier(id) == normIdentifier(o) { + return fmt.Errorf("%w: identifier", ErrDuplicate) + } + } + } + } + return nil +} + +// takeSourcesLocked keeps each source on one car: the one just changed. +func (r *Runtime) takeSourcesLocked(car *Car) { + for _, d := range car.Sources { + r.rec.Unbound = slices.DeleteFunc(r.rec.Unbound, func(u string) bool { return u == d }) + for _, other := range r.rec.Cars { + if other != car { + other.Sources = slices.DeleteFunc(other.Sources, func(s string) bool { return s == d }) + } + } + } +} + +// Remove takes a car off the box. A session on it becomes a visitor's. +func (r *Runtime) Remove(id string) error { + r.syncMu.Lock() + defer r.syncMu.Unlock() + r.mu.Lock() + car := r.carLocked(id) + if car == nil { + r.mu.Unlock() + return ErrUnknownCar + } + r.rec.Cars = slices.DeleteFunc(r.rec.Cars, func(c *Car) bool { return c.ID == id }) + r.rec.Removed = append(r.rec.Removed, Tombstone{ID: id, AtMs: r.deps.Now().UnixMilli()}) + if n := len(r.rec.Removed); n > maxTombstones { + r.rec.Removed = slices.Delete(r.rec.Removed, 0, n-maxTombstones) + } + // Its drivers stay off, as when the owner takes one off a car: the box + // does not hand them to the car that is left. + for _, d := range car.Sources { + if !slices.Contains(r.rec.Unbound, d) { + r.rec.Unbound = append(r.rec.Unbound, d) + } + } + for _, s := range r.rec.Sessions { + if s.CarID == id { + // The owner's word: a visitor's, which only the charger or the + // car can correct, never the only car left. + s.CarID, s.By, s.CorrectedFrom = "", protocol.CarIdentifiedByNone, "" + s.seeded = false + s.Asked, s.Tapped = true, true + } + } + for lp, c := range r.rec.LastCar { + if c == id { + delete(r.rec.LastCar, lp) + } + } + for p, c := range r.rec.Profiles { + if c == id { + // The profile stays seen: removing the car is the owner's word. + r.rec.Profiles[p] = "" + } + } + _, err := r.commit("car_removed", nil) + return err +} + +// SetGoal is car.goal.set. A nil goal with clear set removes the goal; a nil +// surplusOnly leaves the choice alone. +func (r *Runtime) SetGoal(id string, goal *protocol.CarGoal, clear bool, surplusOnly *bool) (protocol.Car, error) { + r.syncMu.Lock() + defer r.syncMu.Unlock() + var next loadpoint.Schedule + if goal != nil { + var err error + if next, err = scheduleFrom(*goal); err != nil { + return protocol.Car{}, err + } + } + r.mu.Lock() + car := r.carLocked(id) + if car == nil { + r.mu.Unlock() + return protocol.Car{}, ErrUnknownCar + } + if goal != nil || clear { + car.Goal = next + } + if surplusOnly != nil { + car.SurplusOnly = *surplusOnly + } + return r.commit("car_goal_changed", car) +} + +// SetLevel is car.soc.set: a level someone set, now, as a fraction. A car on +// a charger re-anchors that session. +func (r *Runtime) SetLevel(id string, soc float64) (protocol.Car, error) { + r.syncMu.Lock() + defer r.syncMu.Unlock() + if math.IsNaN(soc) || soc < 0 || soc > 1 { + return protocol.Car{}, fmt.Errorf("%w: soc", ErrInvalid) + } + r.mu.Lock() + car := r.carLocked(id) + if car == nil { + r.mu.Unlock() + return protocol.Car{}, ErrUnknownCar + } + lpID, s := r.sessionOfLocked(id) + if s == nil { + car.Level = Level{SoC: soc, Source: protocol.CarSoCManual, AtMs: r.deps.Now().UnixMilli(), ChargeLimit: car.Level.ChargeLimit} + return r.commit("car_level_set", car) + } + r.mu.Unlock() + // The loadpoint's level observer records it on the car (LevelSet). + if !r.deps.Loadpoints.SetCurrentSoC(lpID, soc) { + return protocol.Car{}, ErrUnplugged + } + r.replan("car_level_set") + r.notify() + return r.Car(id) +} + +// SetSessionGoal is car.goal.set with charger_id: the goal and surplus +// choice of the car on the charger, or of the session itself while its car +// is unknown, through the charger's own routes, which never touch a car the +// session does not have (docs/cars.md). +func (r *Runtime) SetSessionGoal(chargerID string, goal *protocol.CarGoal, clear bool, surplusOnly *bool) (protocol.ChargerCar, error) { + r.syncMu.Lock() + defer r.syncMu.Unlock() + st, err := r.pluggedSession(chargerID) + if err != nil { + return protocol.ChargerCar{}, err + } + lps := r.deps.Loadpoints + if goal != nil || clear { + next := loadpoint.Schedule{} + if goal != nil { + g, err := scheduleFrom(*goal) + if err != nil { + return protocol.ChargerCar{}, err + } + next = g + } + next.SurplusUnlockBatSoC = st.Schedule.SurplusUnlockBatSoC + if _, err := lps.SetScheduleChecked(chargerID, next); err != nil { + return protocol.ChargerCar{}, err + } + } + if surplusOnly != nil { + if _, _, err := lps.SetSurplusOnlyChecked(chargerID, *surplusOnly); err != nil { + return protocol.ChargerCar{}, err + } + } + r.replan("session_goal_set") + return r.ChargerCar(chargerID), nil +} + +// SetSessionLevel is car.soc.set with charger_id: the level of the car on the +// charger, or of the session itself while its car is unknown. +func (r *Runtime) SetSessionLevel(chargerID string, soc float64) (protocol.ChargerCar, error) { + r.syncMu.Lock() + defer r.syncMu.Unlock() + if math.IsNaN(soc) || soc < 0 || soc > 1 { + return protocol.ChargerCar{}, fmt.Errorf("%w: soc", ErrInvalid) + } + if _, err := r.pluggedSession(chargerID); err != nil { + return protocol.ChargerCar{}, err + } + if !r.deps.Loadpoints.SetCurrentSoC(chargerID, soc) { + return protocol.ChargerCar{}, ErrUnplugged + } + r.replan("session_level_set") + r.notify() + return r.ChargerCar(chargerID), nil +} + +func (r *Runtime) pluggedSession(chargerID string) (loadpoint.State, error) { + st, ok := r.deps.Loadpoints.State(chargerID) + if !ok { + return st, ErrUnknownCharger + } + if !st.PluggedIn { + return st, ErrUnplugged + } + return st, nil +} + +// SetSessionCar is session.car.set, the one tap. sessionID names the plug-in +// the tap answers; a tap for one that has ended is refused. An empty carID +// makes the session a visitor's. A tap over an answer the box found by itself +// is final. +func (r *Runtime) SetSessionCar(lpID, sessionID, carID string) (protocol.ChargerCar, error) { + r.syncMu.Lock() + defer r.syncMu.Unlock() + st, err := r.pluggedSession(lpID) + if err != nil { + return protocol.ChargerCar{}, err + } + r.mu.Lock() + s := r.rec.Sessions[lpID] + if s == nil { + // Plugged in since the last tick: open the session now. + s = r.openSessionLocked(st, r.deps.Now()) + } + if sessionID != "" && sessionID != s.ID { + r.mu.Unlock() + return protocol.ChargerCar{}, ErrSessionEnded + } + if carID != "" && r.carLocked(carID) == nil { + r.mu.Unlock() + return protocol.ChargerCar{}, ErrUnknownCar + } + if carID != "" && r.onOtherChargerLocked(carID, lpID) { + // One car is on one charger. The other charger's session lets go + // of it at its unplug, or when someone names its car. + r.mu.Unlock() + return protocol.ChargerCar{}, ErrElsewhere + } + automatic := s.By == protocol.CarIdentifiedByCharger || s.By == protocol.CarIdentifiedByCar || + s.By == protocol.CarIdentifiedByOnly + by := protocol.CarIdentifiedByTap + if carID == "" { + by = protocol.CarIdentifiedByNone + } + if carID != s.CarID || s.By != by { + r.bindLocked(lpID, s, carID, by, r.deps.Now()) + } + s.Tapped, s.TapFinal, s.Asked = true, s.TapFinal || automatic, true + if _, err := r.commit("session_car_set", nil); err != nil { + return protocol.ChargerCar{}, err + } + return r.ChargerCar(lpID), nil +} + +// commit saves the record, hands the loadpoints what changed and tells the +// listeners. It is called with the lock held and releases it. +func (r *Runtime) commit(reason string, car *Car) (protocol.Car, error) { + r.dirty = true + err := r.saveLocked() + if err != nil { + r.mu.Unlock() + return protocol.Car{}, err + } + now := r.deps.Now() + acts := r.syncLocked(now) + var out protocol.Car + if car != nil { + out = r.viewLocked(car, statesByID(r.deps.Loadpoints.States())) + } + r.mu.Unlock() + run(acts) + r.notify() + r.replan(reason) + return out, nil +} + +// ChargerIdentified is the charger naming the car on it: an OCPP idTag, MAC +// address or eMAID, at the start of each transaction. It often arrives before +// the loadpoint has seen the plug-in, and then waits for it. An identifier no +// car lists makes the session a visitor's when some car lists identifiers; +// while none does, it names nobody. +func (r *Runtime) ChargerIdentified(lpID, identifier string) { + now := r.deps.Now() + r.mu.Lock() + s := r.rec.Sessions[lpID] + if s == nil { + r.pendingIdent[lpID] = pendingIdent{ident: identifier, at: now} + } else if s.chargerIdent != identifier { + s.chargerIdent, s.chargerIdentAt = identifier, now + } + unknown := r.carWithIdentifierLocked(identifier) == nil && r.anyIdentifiersLocked() + r.mu.Unlock() + if unknown { + // The identifier itself stays out of the log: it names a person's + // card or car. + slog.Info("cars: the charger named a car no car lists; the session is a visitor's", "lp", lpID) + } +} + +// ChargerReportedLevel is a level the car sent through the charger (OCPP +// 2.0.1 NotifyEVChargingNeeds): a reading from the car. The loadpoint has +// already anchored it. +func (r *Runtime) ChargerReportedLevel(lpID string, soc float64) { + if math.IsNaN(soc) || soc < 0 || soc > 1 { + return + } + now := r.deps.Now() + r.mu.Lock() + s := r.rec.Sessions[lpID] + car := (*Car)(nil) + if s != nil { + car = r.carLocked(s.CarID) + } + if car == nil { + r.mu.Unlock() + return + } + wh := 0.0 + if st, ok := r.deps.Loadpoints.State(lpID); ok { + wh = st.DeliveredWhSession + } + car.Level = Level{SoC: soc, Source: protocol.CarSoCVehicle, AtMs: now.UnixMilli(), LP: lpID, SessionWh: wh, + ChargeLimit: car.Level.ChargeLimit} + r.levelSave = true + r.mu.Unlock() + r.notify() +} + +// LevelSet is the loadpoint's level observer: a level set by hand on a +// charger becomes its car's. A visitor's stays with the session. +func (r *Runtime) LevelSet(lpID string, soc float64, at time.Time) { + r.mu.Lock() + s := r.rec.Sessions[lpID] + if s == nil { + r.mu.Unlock() + return + } + car := r.carLocked(s.CarID) + if car == nil { + r.mu.Unlock() + return + } + wh := 0.0 + if st, ok := r.deps.Loadpoints.State(lpID); ok { + wh = st.DeliveredWhSession + } + car.Level = Level{SoC: soc, Source: protocol.CarSoCManual, AtMs: at.UnixMilli(), LP: lpID, SessionWh: wh, + ChargeLimit: car.Level.ChargeLimit} + r.dirty = true + if err := r.saveLocked(); err != nil { + slog.Warn("cars: could not save a level someone set; kept in memory", "err", err) + } + r.mu.Unlock() + r.notify() +} + +// GoalSaved is the loadpoint schedule saver's: a goal set on a charger +// becomes the goal of the car the charger shows. It returns the schedule to +// write to the loadpoint's own row, nil for none. A visitor's goal stays in +// memory with its session, and the row keeps the car's. +func (r *Runtime) GoalSaved(lpID string, s loadpoint.Schedule) (*loadpoint.Schedule, error) { + r.mu.Lock() + defer r.mu.Unlock() + if !r.started { + return &s, nil + } + r.sessionForRouteLocked(lpID) + car, visitor := r.viewCarLocked(lpID) + switch { + case visitor: + goal := goalOnly(s) + r.rec.Sessions[lpID].goal = &goal + row := r.rowFor(lpID, s) + return row, nil + case car == nil: + return &s, nil + } + car.Goal = goalOnly(s) + r.dirty = true + if err := r.saveLocked(); err != nil { + return nil, err + } + return &s, nil +} + +// rowFor is what a loadpoint's own row holds while a visitor sets a goal: +// the goal of the car it last had, and the site rule as just set. +func (r *Runtime) rowFor(lpID string, s loadpoint.Schedule) *loadpoint.Schedule { + row := loadpoint.Schedule{} + if c := r.carLocked(r.rec.LastCar[lpID]); c != nil { + row = c.Goal + } else if len(r.rec.Cars) == 1 { + row = r.rec.Cars[0].Goal + } + row.SurplusUnlockBatSoC = s.SurplusUnlockBatSoC + return &row +} + +// SurplusSaved is the loadpoint surplus saver's: the choice is the car's. It +// reports whether the loadpoint's own row should be written too. +func (r *Runtime) SurplusSaved(lpID string, v bool) (bool, error) { + r.mu.Lock() + defer r.mu.Unlock() + if !r.started { + return true, nil + } + r.sessionForRouteLocked(lpID) + car, visitor := r.viewCarLocked(lpID) + switch { + case visitor: + r.rec.Sessions[lpID].surplus = &v + return false, nil + case car == nil: + return true, nil + } + car.SurplusOnly = v + r.dirty = true + if err := r.saveLocked(); err != nil { + return false, err + } + return true, nil +} + +func updateFrom(c protocol.Car) Update { + u := Update{Name: &c.Name} + if c.Make != "" { + u.Make = &c.Make + } + if c.VIN != "" { + u.VIN = &c.VIN + } + if c.CapacityWh > 0 { + capacity := float64(c.CapacityWh) + u.CapacityWh = &capacity + } + if c.MaxW > 0 { + maxW := float64(c.MaxW) + u.MaxW = &maxW + } + if c.Phases > 0 { + phases := int(c.Phases) + u.Phases = &phases + } + if c.Identifiers != nil { + u.Identifiers = &c.Identifiers + } + if c.Sources != nil { + u.Sources = &c.Sources + } + return u +} + +// applyUpdate checks each value as the HTTP capacity route does. +func applyUpdate(car *Car, u Update) error { + if u.Name != nil { + name := strings.TrimSpace(*u.Name) + if name == "" || len(name) > 64 { + return fmt.Errorf("%w: name", ErrInvalid) + } + car.Name = name + } + if u.Make != nil { + car.Make = strings.TrimSpace(*u.Make) + } + if u.VIN != nil { + vin := normVIN(*u.VIN) + if vin == "" && strings.TrimSpace(*u.VIN) != "" { + return fmt.Errorf("%w: vin", ErrInvalid) + } + car.VIN = vin + } + if u.CapacityWh != nil { + v := *u.CapacityWh + if math.IsNaN(v) || v < 1000 || v > 300000 { + return fmt.Errorf("%w: capacityWh", ErrInvalid) + } + car.CapacityWh = v + } + if u.MaxW != nil { + v := *u.MaxW + if math.IsNaN(v) || v < 0 || v > 400000 { + return fmt.Errorf("%w: maxW", ErrInvalid) + } + car.MaxW = v + } + if u.Phases != nil { + if p := *u.Phases; p != 0 && p != 1 && p != 3 { + return fmt.Errorf("%w: phases", ErrInvalid) + } else { + car.Phases = p + } + } + if u.Identifiers != nil { + ids, err := textList(*u.Identifiers, "identifiers") + if err != nil { + return err + } + car.Identifiers = ids + } + if u.Sources != nil { + sources, err := textList(*u.Sources, "sources") + if err != nil { + return err + } + car.Sources = sources + } + return nil +} + +// textList trims and de-duplicates identifiers or sources, at most +// maxListLen of at most maxTextLen characters each. +func textList(in []string, what string) ([]string, error) { + var out []string + for _, v := range in { + v = strings.TrimSpace(v) + if v == "" || slices.Contains(out, v) { + continue + } + if len(v) > maxTextLen || len(out) == maxListLen { + return nil, fmt.Errorf("%w: %s", ErrInvalid, what) + } + out = append(out, v) + } + return out, nil +} + +// A car's identifiers and sources are few and short. +const ( + maxListLen = 8 + maxTextLen = 64 +) + +// scheduleFrom turns a wire goal into the schedule a loadpoint runs. +func scheduleFrom(g protocol.CarGoal) (loadpoint.Schedule, error) { + if g.TargetPermille < 0 || g.TargetPermille > 1000 || g.ReadyByMin < 0 || g.ReadyByMin >= 24*60 { + return loadpoint.Schedule{}, fmt.Errorf("%w: goal", ErrInvalid) + } + days := g.Days & 0x7F + recurring := days != 0 + if days == 0x7F { + days = 0 + } + return loadpoint.NewSchedule(float64(g.TargetPermille)/1000, int(g.ReadyByMin), recurring, days, g.FinishAtLimit), nil +} + +// carGoal is a schedule as the wire carries it. +func carGoal(s loadpoint.Schedule) *protocol.CarGoal { + if emptyGoal(s) { + return nil + } + clock, ok := s.TimeOfDayMin() + if !ok { + clock = s.TimeOfDayMinUTC + } + g := &protocol.CarGoal{ + TargetPermille: units.PermilleFromFraction(s.SoC), + ReadyByMin: int64(clock), + FinishAtLimit: s.FinishAtVehicleLimit, + } + if s.Recurring { + g.Days = s.Days + if g.Days == 0 { + g.Days = 0x7F + } + } + return g +} + +func normIdentifier(s string) string { return strings.ToLower(strings.TrimSpace(s)) } + +// SetViewCarCapacity is POST /api/loadpoints/{id}/vehicle for one release: the +// battery size of the car the charger shows. A visitor's session has no car +// to change, and the charger keeps the default. +func (r *Runtime) SetViewCarCapacity(lpID string, capacityWh float64) error { + r.syncMu.Lock() + defer r.syncMu.Unlock() + if _, ok := r.deps.Loadpoints.State(lpID); !ok { + return nil + } + r.mu.Lock() + if !r.started { + r.mu.Unlock() + return nil + } + r.sessionForRouteLocked(lpID) + car, _ := r.viewCarLocked(lpID) + if car == nil { + r.mu.Unlock() + return nil + } + if err := applyUpdate(car, Update{CapacityWh: &capacityWh}); err != nil { + r.mu.Unlock() + return err + } + _, err := r.commit("car_capacity_changed", car) + return err +} diff --git a/go/internal/cars/review_test.go b/go/internal/cars/review_test.go new file mode 100644 index 000000000..a66aa531e --- /dev/null +++ b/go/internal/cars/review_test.go @@ -0,0 +1,410 @@ +package cars + +import ( + "encoding/json" + "errors" + "math" + "testing" + "time" + + "github.com/srcfl/ftw/go/internal/loadpoint" + "github.com/srcfl/ftw/go/internal/telemetry" + "github.com/srcfl/ftw/go/protocol" +) + +// The level rule (docs/cars.md, The level) as a table: what a car shows +// after what happened to it. A number the car never said is never shown as +// the car's own, and a level from before the car last left a charger is an +// estimate. +func TestTheLevelRule(t *testing.T) { + for _, tc := range []struct { + name string + run func(h *harness) + source string + estimate bool + permille int64 + }{ + {"read nine minutes ago, nothing since", func(h *harness) { + h.plug("easee", "s1") + h.report("tesla-ha", .14, h.now, "Stopped") + h.tick(9 * time.Minute) + }, protocol.CarSoCVehicle, false, 140}, + {"read, then 0.9 kWh: still the car's own number", func(h *harness) { + h.plug("easee", "s1") + h.report("tesla-ha", .14, h.now, "Charging") + h.tick(time.Second) + h.deliver("easee", "s1", 900) + }, protocol.CarSoCVehicle, false, 140}, + {"read, then 1.5 kWh: an estimate", func(h *harness) { + h.plug("easee", "s1") + h.report("tesla-ha", .14, h.now, "Charging") + h.tick(time.Second) + h.deliver("easee", "s1", 1500) + }, protocol.CarSoCVehicle, true, 158}, + {"read while parked, a session, unplugged", func(h *harness) { + h.report("tesla-ha", .50, h.now, "Disconnected") + h.tick(time.Second) + h.plug("easee", "s1") + h.deliver("easee", "s1", 7600) + h.unplug("easee") + }, protocol.CarSoCVehicle, true, 590}, + {"read during a session, unplugged, plugged again", func(h *harness) { + h.plug("easee", "s1") + h.report("tesla-ha", .60, h.now, "Charging") + h.tick(time.Second) + h.unplug("easee") + h.tick(8 * time.Hour) + h.plug("easee", "s2") + }, protocol.CarSoCAssumed, true, 200}, + {"set by hand on the charger, nothing since", func(h *harness) { + h.plug("easee", "s1") + h.mgr.SetCurrentSoC("easee", .40) + h.tick(time.Second) + }, protocol.CarSoCManual, false, 400}, + } { + t.Run(tc.name, func(t *testing.T) { + h := newHarness(t, homeConfig(), nil, nil) + h.start() + h.ident["tesla-ha"] = [2]string{"Tesla", teslaVIN} + tc.run(h) + car := h.only() + if car.SocSource != tc.source || car.SocEstimate != tc.estimate || car.SocPermille == nil || *car.SocPermille != tc.permille { + t.Fatalf("got %s estimate=%v %v, want %s estimate=%v %d", + car.SocSource, car.SocEstimate, deref(car.SocPermille), tc.source, tc.estimate, tc.permille) + } + }) + } +} + +func deref(p *int64) any { + if p == nil { + return nil + } + return *p +} + +// A tap names the plug-in. One for a plug-in that has ended is refused, so a +// late tap from a notification never names the next car. +func TestATapForAnEndedPlugInIsRefused(t *testing.T) { + h := newHarness(t, homeConfig(), nil, nil) + h.start() + volvo := h.add(protocol.Car{Name: "Volvo"}) + h.plug("easee", "s1") + old := h.rt.ChargerCar("easee").Session + h.unplug("easee") + h.plug("easee", "s2") + if _, err := h.rt.SetSessionCar("easee", old, volvo.ID); !errors.Is(err, ErrSessionEnded) { + t.Fatalf("a tap for an ended plug-in: %v", err) + } + if _, err := h.tap("easee", "nope"); !errors.Is(err, ErrUnknownCar) { + t.Fatalf("unknown car: %v", err) + } +} + +// The box says asking while it waits for the tap. "None of these" makes the +// session a visitor's, and the only car left after a removal never takes it. +func TestAskingAndNoneOfThese(t *testing.T) { + h := newHarness(t, homeConfig(), nil, nil) + h.start() + h.add(protocol.Car{Name: "Volvo"}) + h.plug("easee", "s1") + h.tick(61 * time.Second) + if st := h.state("easee"); st.CarIdentifiedBy != protocol.CarIdentifiedByAsking || len(st.Choose) != 2 || st.Session == "" { + t.Fatalf("asking %+v", st) + } + if held, err := h.tap("easee", ""); err != nil || held.CarIdentifiedBy != protocol.CarIdentifiedByNone || held.CarID != "" { + t.Fatalf("none of these: %+v %v", held, err) + } + h.tick(time.Minute) + if st := h.state("easee"); st.CarIdentifiedBy != protocol.CarIdentifiedByNone || !st.SoCUnknown || len(h.asked) != 1 { + t.Fatalf("after none of these %+v asked %d", st, len(h.asked)) + } +} + +// car.which offers at most four cars, none of them on another charger; with +// more it offers none and the phone opens FTW. +func TestCarWhichOffersAtMostFourCarsNotOnAnotherCharger(t *testing.T) { + h := newHarness(t, []loadpoint.Config{{ID: "easee", DriverName: "easee"}, {ID: "garage", DriverName: "garage"}}, nil, nil) + h.start() + ids := []string{h.rt.Cars()[0].ID, h.rt.Cars()[1].ID} + for _, name := range []string{"C", "D", "E"} { + ids = append(ids, h.add(protocol.Car{Name: name}).ID) + } + h.mgr.ObserveSession("garage", true, 0, 0, true, "garage:G1", "g1") + h.rt.Tick() + if _, err := h.rt.SetSessionCar("garage", h.rt.ChargerCar("garage").Session, ids[0]); err != nil { + t.Fatal(err) + } + h.plug("easee", "s1") + h.tick(61 * time.Second) + if len(h.asked) != 1 || len(h.asked[0].CarIDs) != 4 || h.asked[0].Session == "" { + t.Fatalf("car.which %+v", h.asked) + } + for _, id := range h.asked[0].CarIDs { + if id == ids[0] { + t.Fatal("offered the car on the other charger") + } + } + h.add(protocol.Car{Name: "F"}) + if got := h.rt.ChargerCar("easee").Choose; got != nil { + t.Fatalf("five cars to choose from offers %v, want none", got) + } +} + +// Ids: the app mints them, the same id is an update, a car the box minted +// takes the app's id by VIN or by new_id, a VIN or identifier another car has +// is refused, and a removed car leaves a tombstone. +func TestCarIds(t *testing.T) { + h := newHarness(t, homeConfig(), nil, nil) + h.start() + minted := h.only() + if !minted.BoxMinted { + t.Fatal("the migrated car is not marked box-minted") + } + if _, err := h.rt.Add(protocol.Car{Name: "No id"}); !errors.Is(err, ErrInvalid) { + t.Fatalf("add without an id: %v", err) + } + if _, err := h.rt.UpdateCar(minted.ID, Update{VIN: strp(teslaVIN)}); err != nil { + t.Fatal(err) + } + appID := newID() + adopted, err := h.rt.Add(protocol.Car{ID: appID, Name: "Tesla", VIN: teslaVIN}) + if err != nil || adopted.ID != appID || adopted.BoxMinted || len(h.rt.Cars()) != 1 { + t.Fatalf("adopt by VIN: %+v %v", adopted, err) + } + if h.rt.rec.LastCar["easee"] != appID { + t.Fatal("the charger's last car still names the minted id") + } + if _, err := h.rt.UpdateCar(appID, Update{NewID: strp(newID())}); !errors.Is(err, ErrInvalid) { + t.Fatalf("re-keying an app car: %v", err) + } + other := h.add(protocol.Car{Name: "Volvo", Identifiers: []string{"04:AB"}}) + if _, err := h.rt.Add(protocol.Car{ID: newID(), Name: "Clone", VIN: teslaVIN}); !errors.Is(err, ErrDuplicate) { + t.Fatalf("duplicate VIN: %v", err) + } + if _, err := h.rt.UpdateCar(appID, Update{Identifiers: &[]string{"04:ab"}}); !errors.Is(err, ErrDuplicate) { + t.Fatalf("duplicate identifier: %v", err) + } + if err := h.rt.Remove(other.ID); err != nil { + t.Fatal(err) + } + if got := h.rt.Removed(); len(got) != 1 || got[0].ID != other.ID { + t.Fatalf("tombstones %+v", got) + } + h.add(protocol.Car{ID: other.ID, Name: "Volvo again"}) + if got := h.rt.Removed(); len(got) != 0 { + t.Fatalf("a re-added car kept its tombstone: %+v", got) + } + + // A car the box minted takes the app's id by new_id too. + again := newHarness(t, homeConfig(), nil, nil) + again.start() + id := newID() + if car, err := again.rt.UpdateCar(again.only().ID, Update{NewID: &id}); err != nil || car.ID != id || car.BoxMinted { + t.Fatalf("new_id: %+v %v", car, err) + } +} + +func strp(s string) *string { return &s } + +// Identification edges: a charger naming a car the box does not know makes a +// visitor once cars list identifiers, and names nobody before; the only car +// saying it is elsewhere makes a visitor. A tap over an answer the box found +// by itself is final. +func TestIdentificationEdges(t *testing.T) { + t.Run("an idTag on a box whose cars list none", func(t *testing.T) { + // OCPP 1.6 sends an idTag at every start. A one-car OCPP box with no + // vehicles[] profile keeps charging its car to its goal. + h := newHarness(t, homeConfig(), map[string]loadpoint.Schedule{"easee": homeGoal(t)}, nil) + h.start() + car := h.only() + h.rt.ChargerIdentified("easee", "FREECHARGING") + h.plug("easee", "s1") + st := h.state("easee") + if st.CarID != car.ID || st.CarIdentifiedBy != protocol.CarIdentifiedByOnly || st.SoCUnknown { + t.Fatalf("the idTag named nobody, so the only car is here: %+v", st) + } + if !st.Schedule.HasTarget() || st.Schedule.SoC != 0.8 { + t.Fatalf("the car's goal %+v", st.Schedule) + } + }) + t.Run("an unknown card when cars list theirs", func(t *testing.T) { + h := newHarness(t, homeConfig(), nil, nil) + h.start() + car := h.only() + if _, err := h.rt.UpdateCar(car.ID, Update{Identifiers: &[]string{"rfid-7"}}); err != nil { + t.Fatal(err) + } + // The charger's word arrives before the loadpoint sees the plug-in. + h.rt.ChargerIdentified("easee", "rfid-guest") + h.plug("easee", "s1") + if st := h.state("easee"); st.CarIdentifiedBy != protocol.CarIdentifiedByNone || st.CarID != "" || !st.SoCUnknown { + t.Fatalf("a guest's card %+v", st) + } + }) + t.Run("the only car is elsewhere", func(t *testing.T) { + h := newHarness(t, homeConfig(), nil, nil) + h.start() + car := h.only() + if _, err := h.rt.UpdateCar(car.ID, Update{Sources: &[]string{"tesla-ha"}}); err != nil { + t.Fatal(err) + } + h.plug("easee", "s1") + if st := h.state("easee"); st.CarID != car.ID { + t.Fatalf("only %+v", st) + } + h.obs["tesla-ha"] = telemetry.VehicleObservation{Driver: "tesla-ha", SoC: .5, At: h.now, + AtSite: boolp(false), ReportedAt: h.now.Add(10 * time.Second)} + h.tick(10 * time.Second) + if st := h.state("easee"); st.CarID != "" || st.CarIdentifiedBy != protocol.CarIdentifiedByNone || + !st.CarCorrected || st.CarCorrectedFrom != car.ID { + t.Fatalf("the only car said it is away: %+v", st) + } + }) + t.Run("a tap over the box's own answer is final", func(t *testing.T) { + h := newHarness(t, homeConfig(), nil, nil) + h.start() + tesla := h.only() + volvo := h.add(protocol.Car{Name: "Volvo", Identifiers: []string{"04:AB"}, Sources: []string{"volvo-cloud"}}) + h.plug("easee", "s1") + h.rt.ChargerIdentified("easee", "04:AB") + h.tick(time.Second) + if st := h.state("easee"); st.CarID != volvo.ID || st.CarIdentifiedBy != protocol.CarIdentifiedByCharger { + t.Fatalf("charger %+v", st) + } + if _, err := h.tap("easee", tesla.ID); err != nil { + t.Fatal(err) + } + h.obs["volvo-cloud"] = telemetry.VehicleObservation{Driver: "volvo-cloud", SoC: .3, At: h.now, + ChargingState: "Charging", AtSite: boolp(true), ReportedAt: h.now} + h.deliver("easee", "s1", 500) + if st := h.state("easee"); st.CarID != tesla.ID || st.CarIdentifiedBy != protocol.CarIdentifiedByTap { + t.Fatalf("the tap was overruled: %+v", st) + } + }) +} + +// One car is on one charger at a time: with one car and two chargers +// plugged in, the second is a visitor's. +func TestOneCarIsOnOneCharger(t *testing.T) { + h := newHarness(t, []loadpoint.Config{{ID: "easee", DriverName: "easee", VehicleCapacityWh: 76000}, {ID: "garage", DriverName: "garage"}}, nil, nil) + h.start() + if err := h.rt.Remove(h.rt.Cars()[1].ID); err != nil { + t.Fatal(err) + } + car := h.only() + h.plug("easee", "s1") + h.mgr.ObserveSession("garage", true, 0, 0, true, "garage:G1", "g1") + h.rt.Tick() + if a, b := h.state("easee"), h.state("garage"); a.CarID != car.ID || b.CarID != "" || !b.SoCUnknown { + t.Fatalf("easee %q, garage %q unknown=%v", a.CarID, b.CarID, b.SoCUnknown) + } +} + +// A switch to another car gives the car it had its level from before the +// session back, even when the session had started it from an assumption. +func TestASwitchRestoresTheOldCarsLevel(t *testing.T) { + h := newHarness(t, homeConfig(), nil, nil) + h.start() + tesla := h.only() + volvo := h.add(protocol.Car{Name: "Volvo", CapacityWh: 40000}) + if _, err := h.rt.SetLevel(tesla.ID, .66); err != nil { + t.Fatal(err) + } + h.plug("easee", "s1") + h.tick(61 * time.Second) + if _, err := h.tap("easee", tesla.ID); err != nil { + t.Fatal(err) + } + h.tick(time.Second) + h.deliver("easee", "s1", 2000) + if _, err := h.tap("easee", volvo.ID); err != nil { + t.Fatal(err) + } + h.tick(time.Second) + if got := h.car(tesla.ID); got.SocSource != protocol.CarSoCManual || *got.SocPermille != 660 || got.EnergySinceWh != 0 { + t.Fatalf("tesla after the switch %+v", got) + } +} + +// After a restart the loadpoint knows nothing of its cable until the charger +// reports. A tick in that gap must not end the session or lose its car. +func TestARestartBeforeTheChargerReportsKeepsTheSession(t *testing.T) { + h := newHarness(t, homeConfig(), nil, nil) + h.start() + volvo := h.add(protocol.Car{Name: "Volvo", CapacityWh: 40000}) + h.plug("easee", "s1") + h.tick(61 * time.Second) + if _, err := h.tap("easee", volvo.ID); err != nil { + t.Fatal(err) + } + h.tick(time.Second) + h.deliver("easee", "s1", 2000) + h.tick(2 * time.Minute) + + again := newHarness(t, homeConfig(), nil, h.kv) + again.now = h.now.Add(time.Minute) + again.start() + again.rt.Tick() // before the charger has reported + again.wh["easee"] = 2000 + again.deliver("easee", "s1", 0) + st := again.state("easee") + if st.CarID != volvo.ID || st.CarIdentifiedBy != protocol.CarIdentifiedByTap || st.VehicleCapacityWh != 40000 { + t.Fatalf("after restart: %+v", st) + } + for _, c := range again.rt.rec.Cars { + if c.ID == volvo.ID && c.UnpluggedMs != 0 { + t.Fatal("the restart counted as an unplug") + } + } +} + +// The first start with cars while a car charges keeps the level the +// loadpoint already trusts, rather than an assumption. +func TestFirstStartWhileChargingKeepsTheTrustedLevel(t *testing.T) { + h := newHarness(t, homeConfig(), nil, nil) + h.plug("easee", "s1") + h.mgr.SetCurrentSoC("easee", .55) + h.start() + h.tick(time.Second) + if st := h.state("easee"); math.Abs(st.CurrentSoC-.55) > 1e-9 || st.SoCSource == "assumed" { + t.Fatalf("the trusted level was replaced: %+v", st.CurrentSoC) + } + if car := h.only(); car.SocSource != protocol.CarSoCManual || *car.SocPermille != 550 { + t.Fatalf("car %+v", car) + } +} + +// A setting changed in config.yaml, by hand or on the box's settings page, +// is the car's for one release, as the charger routes are. +func TestASettingsEditReachesTheCar(t *testing.T) { + h := newHarness(t, homeConfig(), nil, nil) + h.start() + capacity, surplus := 50000.0, true + h.rt.Reload(nil, SettingsChange{LP: "easee", CapacityWh: &capacity, SurplusOnly: &surplus}) + h.tick(time.Second) + if car := h.only(); car.CapacityWh != 50000 || !car.SurplusOnly { + t.Fatalf("car %+v", car) + } + if st := h.state("easee"); st.VehicleCapacityWh != 50000 || !st.SurplusOnly { + t.Fatalf("charger %+v", st) + } +} + +// A schedule that holds only the site's battery rule is no goal, and the +// loadpoint keeps it without the runtime applying it again each tick. +func TestTheSiteRuleAloneIsNoGoal(t *testing.T) { + var rule loadpoint.Schedule + if err := json.Unmarshal([]byte(`{"surplus_unlock_bat_soc":0.9,"time_of_day_min":420,"recurring":true}`), &rule); err != nil { + t.Fatal(err) + } + h := newHarness(t, homeConfig(), map[string]loadpoint.Schedule{"easee": rule}, nil) + h.start() + if car := h.only(); car.Goal != nil { + t.Fatalf("goal %+v", car.Goal) + } + h.tick(time.Second) + if st := h.state("easee"); st.Schedule.SurplusUnlockBatSoC != .9 { + t.Fatalf("the site rule was lost: %+v", st.Schedule) + } +} diff --git a/go/internal/cars/tick.go b/go/internal/cars/tick.go new file mode 100644 index 000000000..965e278a7 --- /dev/null +++ b/go/internal/cars/tick.go @@ -0,0 +1,872 @@ +package cars + +import ( + "log/slog" + "slices" + "strings" + "time" + + "github.com/srcfl/ftw/go/internal/events" + "github.com/srcfl/ftw/go/internal/loadpoint" + "github.com/srcfl/ftw/go/internal/telemetry" + "github.com/srcfl/ftw/go/internal/units" + "github.com/srcfl/ftw/go/protocol" +) + +// Tick follows the loadpoints and the cars' sources. It runs once per control +// tick, after the loadpoints have observed their chargers. +func (r *Runtime) Tick() { + r.syncMu.Lock() + defer r.syncMu.Unlock() + now := r.deps.Now() + lps := r.deps.Loadpoints + states := lps.States() + var obs []telemetry.VehicleObservation + if r.deps.Vehicles != nil { + obs = r.deps.Vehicles() + } + + r.mu.Lock() + if !r.started { + r.mu.Unlock() + return + } + for _, d := range r.vehicleDrivers { + if r.carForSourceLocked(d) == nil { + r.autoBindLocked(d) + } + } + changed := r.followSessionsLocked(states, now) + acts, levelChanged := r.ingestLocked(obs, states, now) + if r.identifyLocked(states, now) { + changed = true + } + asks := r.askLocked(now) + if len(asks) > 0 { + // The chargers now say asking: listeners such as the plan push + // should see it. + changed = true + } + acts = append(acts, r.syncLocked(now)...) + if r.dirty || (r.levelSave && now.Sub(r.lastSave) >= levelSaveEvery) { + if err := r.saveLocked(); err != nil { + slog.Warn("cars: could not save; kept in memory", "err", err) + } + } + r.mu.Unlock() + + run(acts) + for _, ev := range asks { + r.deps.Bus.Publish(ev) + } + if changed || levelChanged { + r.notify() + r.replan("car_changed") + } +} + +// followSessionsLocked opens a session at each plug-in and closes it at +// unplug, crediting the car with what the session delivered. An unplug +// counts once the charger has said so for UnplugGrace, or names a new +// session: a cable that flaps for one reading keeps its session and level. +func (r *Runtime) followSessionsLocked(states []loadpoint.State, now time.Time) bool { + changed := false + seen := map[string]bool{} + for _, st := range states { + seen[st.ID] = true + if !st.Observed { + // After a restart the loadpoint knows nothing of its cable + // until the charger reports. A session it had stays as it was. + continue + } + s := r.rec.Sessions[st.ID] + if !st.PluggedIn { + if s != nil { + if s.unpluggedAt.IsZero() { + s.unpluggedAt = now + } + if now.Sub(s.unpluggedAt) >= UnplugGrace { + r.endSessionLocked(st.ID, s, s.unpluggedAt) + changed = true + } + } + continue + } + if s != nil && !s.unpluggedAt.IsZero() { + if s.Key != "" && st.SessionKey != "" && s.Key != st.SessionKey { + r.endSessionLocked(st.ID, s, s.unpluggedAt) + s = nil + } else { + r.resumeSessionLocked(st, s) + } + } + if s != nil && !r.samePlugLocked(s, st) { + r.endSessionLocked(st.ID, s, now) + s = nil + } + if s == nil { + s = r.openSessionLocked(st, now) + changed = true + } + if s.Key == "" && st.SessionKey != "" { + s.Key = st.SessionKey + r.dirty = true + } + if st.DeliveredWhSession-s.LastWh >= 100 || st.DeliveredWhSession < s.LastWh { + // Kept on disk at the level cadence, so an unplug the box did + // not see credits close to what the car got. + r.levelSave = true + } + s.LastWh = st.DeliveredWhSession + } + for lp, s := range r.rec.Sessions { + if !seen[lp] { + r.endSessionLocked(lp, s, now) + changed = true + } + } + return changed +} + +// openSessionLocked starts the session of a plug-in, with the charger's +// word if it came first. +func (r *Runtime) openSessionLocked(st loadpoint.State, now time.Time) *session { + plugged := st.PluggedAt + if plugged.IsZero() { + plugged = now + } + s := &session{ID: newSessionID(), By: protocol.CarIdentifiedByNone, Key: st.SessionKey, + PluggedMs: plugged.UnixMilli(), BoundMs: now.UnixMilli(), connMs: plugged.UnixMilli(), + uncertain: plugged.Sub(r.startedAt) < bootWindow} + if p, ok := r.pendingIdent[st.ID]; ok && absDuration(p.at.Sub(plugged)) <= IdentifyWindow { + s.chargerIdent, s.chargerIdentAt = p.ident, p.at + } + delete(r.pendingIdent, st.ID) + r.rec.Sessions[st.ID] = s + r.dirty = true + return s +} + +// resumeSessionLocked keeps a session whose cable came back within +// UnplugGrace. The loadpoint may count from zero again: what flowed before +// is the car's, and the car's level starts the loadpoint once more. +func (r *Runtime) resumeSessionLocked(st loadpoint.State, s *session) { + s.unpluggedAt = time.Time{} + s.connMs = st.PluggedAt.UnixMilli() + if car := r.carLocked(s.CarID); car != nil && car.Level.LP == st.ID { + car.Level.BaseWh += s.LastWh - car.Level.SessionWh + car.Level.SessionWh = st.DeliveredWhSession + } + s.LastWh = st.DeliveredWhSession + s.seeded = false + delete(r.applied, st.ID) + r.dirty = true + slog.Info("cars: the cable came back within the grace; the session goes on", "lp", st.ID) +} + +// bootWindow is how soon after the box starts a plug-in counts as one it +// found already plugged in. +const bootWindow = 30 * time.Second + +// UnplugGrace is how long a charger must say the cable is out before the +// session ends. +const UnplugGrace = 30 * time.Second + +// PrePlugMaxAge is how much older than the plug-in a level may be and still +// start the session. An older one may predate a drive. +const PrePlugMaxAge = time.Hour + +// tooOldFor reports whether a level read at atMs is too old to start the +// session s. +func tooOldFor(atMs int64, s *session) bool { + return atMs < s.PluggedMs-PrePlugMaxAge.Milliseconds() +} + +// samePlugLocked tells one plug-in from the next. A session read back after +// a restart is the same one when the charger's own session id says so, or, +// without one, when its energy counter has not started again. +func (r *Runtime) samePlugLocked(s *session, st loadpoint.State) bool { + if s.restored { + s.restored = false + var same bool + if s.Key != "" && st.SessionKey != "" { + same = s.Key == st.SessionKey + } else { + same = st.DeliveredWhSession >= s.LastWh + } + if same { + // PluggedMs stays the plug-in itself; the loadpoint's own + // connection time starts again at the restart. + s.connMs = st.PluggedAt.UnixMilli() + // The loadpoint restores its own level when it can. When it + // could not, the car's level starts the session again. + s.seeded = st.SoCSource != "assumed" + } + return same + } + if s.Key != "" && st.SessionKey != "" { + return s.Key == st.SessionKey + } + return s.connMs == st.PluggedAt.UnixMilli() +} + +func (r *Runtime) endSessionLocked(lpID string, s *session, at time.Time) { + if car := r.carLocked(s.CarID); car != nil { + r.creditLocked(car, lpID, s.LastWh) + car.UnpluggedMs = at.UnixMilli() + r.rec.LastCar[lpID] = car.ID + } + delete(r.rec.Sessions, lpID) + r.dirty = true +} + +// creditLocked moves the session's energy since the car's reading into the +// car's level, as the session ends. +func (r *Runtime) creditLocked(car *Car, lpID string, sessionWh float64) { + if car.Level.Source == "" { + return + } + from := 0.0 + if car.Level.LP == lpID { + from = car.Level.SessionWh + } + // Negative when a V2X session took energy out. + car.Level.BaseWh += sessionWh - from + car.Level.LP, car.Level.SessionWh = "", 0 +} + +// ingestLocked reads each car's sources. A newer reading becomes the car's +// level, and the loadpoint the car is on is anchored to it. It returns the +// anchors to run once the lock is released, and whether a car's level +// source changed. +func (r *Runtime) ingestLocked(obs []telemetry.VehicleObservation, states []loadpoint.State, now time.Time) ([]func(), bool) { + lps := r.deps.Loadpoints + byID := statesByID(states) + var acts []func() + changed := false + for _, o := range obs { + ss := r.sources[o.Driver] + if ss == nil { + ss = &sourceState{} + r.sources[o.Driver] = ss + } + plugged := pluggedEvidence(o) + if !plugged { + ss.pluggedSince = time.Time{} + } else if !ss.plugged && ss.seen { + ss.pluggedSince = o.ReportedAt + if ss.pluggedSince.IsZero() || ss.pluggedSince.After(now) { + ss.pluggedSince = now + } + } + ss.plugged, ss.seen, ss.obs = plugged, true, o + + car := r.carForSourceLocked(o.Driver) + if car == nil { + car = r.autoBindLocked(o.Driver) + } + if car == nil || !newerReading(car, o.At) { + continue + } + lpID, s := r.sessionOfLocked(car.ID) + if s != nil && tooOldFor(o.At.UnixMilli(), s) { + // Read more than PrePlugMaxAge before this plug-in: the car may + // have driven since, and the session keeps its own start. + continue + } + lvl := Level{SoC: o.SoC, Source: protocol.CarSoCVehicle, AtMs: o.At.UnixMilli(), ChargeLimit: o.ChargeLimit} + if s != nil { + lvl.LP = lpID + if lvl.AtMs >= s.PluggedMs { + wh, ok := lps.DeliveredAt(lpID, o.At) + if !ok { + wh = byID[lpID].DeliveredWhSession + } + lvl.SessionWh = wh + } + } + if car.Level.Source != protocol.CarSoCVehicle { + changed = true + } + car.Level = lvl + r.levelSave = true + if s == nil || !s.seeded { + continue + } + soc, at := o.SoC, o.At + // A reading from before a plug-in the box saw happen is the level + // the session started from. One from before a session the box + // found plugged in at start may be from after the plug-in: the + // loadpoint adopts it without energy it cannot account for. + beforePlugIn := lvl.AtMs < s.PluggedMs && !s.uncertain + acts = append(acts, func() { + if !lps.VehicleSoCSupersedes(lpID, at) { + return + } + if beforePlugIn { + // Read before this plug-in: that is the level the session + // started from, and all its energy went into the car since. + lps.SeedSessionLevel(lpID, loadpoint.LevelSeed{Known: true, SoC: soc, + Confirmed: true, ConfirmedAt: at, FromCar: true}) + return + } + lps.AnchorVehicleSoCAt(lpID, soc, at) + }) + } + return acts, changed +} + +// newerReading reports whether a reading made at at replaces the car's level: +// one newer than it, or for a plug-in assumption, which is no reading at all, +// any made since the car last left a charger. +func newerReading(car *Car, at time.Time) bool { + if car.Level.Source == protocol.CarSoCAssumed { + return at.UnixMilli() > car.UnpluggedMs + } + return at.UnixMilli() > car.Level.AtMs +} + +// pluggedEvidence is the car's own word that a cable is in: its plugged +// flag, or else a charging state that needs one. +func pluggedEvidence(o telemetry.VehicleObservation) bool { + if o.Plugged != nil { + return *o.Plugged + } + return telemetry.VehicleConnectedRank(o.ChargingState) >= 1 +} + +// autoBindLocked binds a vehicle driver to the car with its VIN, or to the +// only car. A driver the owner unbound stays unbound. +func (r *Runtime) autoBindLocked(driver string) *Car { + if slices.Contains(r.rec.Unbound, driver) { + return nil + } + var make, serial string + if r.deps.Identity != nil { + make, serial = r.deps.Identity(driver) + } + vin := normVIN(serial) + var car *Car + if vin != "" { + for _, c := range r.rec.Cars { + if c.VIN == vin { + car = c + break + } + } + } + if car == nil && len(r.rec.Cars) == 1 { + if c := r.rec.Cars[0]; c.VIN == "" || vin == "" || c.VIN == vin { + car = c + } + } + if car == nil { + return nil + } + car.Sources = append(car.Sources, driver) + if car.VIN == "" { + car.VIN = vin + } + if car.Make == "" { + car.Make = make + } + r.dirty = true + slog.Info("cars: bound a vehicle driver to a car", "driver", driver, "car", car.ID, "by_vin", vin != "" && car.VIN == vin) + return car +} + +// identifyLocked asks, for each open session, which car it is. +func (r *Runtime) identifyLocked(states []loadpoint.State, now time.Time) bool { + changed := false + for _, st := range states { + s := r.rec.Sessions[st.ID] + if s == nil || !st.PluggedIn { + continue + } + if r.applyAnswerLocked(st.ID, s, r.answerLocked(st, s, states, now), now) { + changed = true + } + } + return changed +} + +// answer is what the identification order says about a session. +type answer struct { + car string + by string + // rank is the rule that answered, as identifiedRank orders them. + rank int + at time.Time + // decisive is set when a rule answered "not one of the cars": the + // charger named a car the box does not know, or the only car said it is + // somewhere else. Without it an empty car means nobody answered. + decisive bool +} + +// answerLocked runs the identification order (docs/cars.md): the charger, +// the car, the only car. A tap is not an answer here; it is held until a +// rule may correct it. +func (r *Runtime) answerLocked(st loadpoint.State, s *session, states []loadpoint.State, now time.Time) answer { + none := answer{by: protocol.CarIdentifiedByNone, rank: identifiedRank[protocol.CarIdentifiedByNone], at: now} + if s.chargerIdent != "" { + if c := r.carWithIdentifierLocked(s.chargerIdent); c != nil { + return answer{car: c.ID, by: protocol.CarIdentifiedByCharger, at: s.chargerIdentAt} + } + if r.anyIdentifiersLocked() { + // The charger named another car: a visitor. + return answer{by: protocol.CarIdentifiedByNone, at: s.chargerIdentAt, decisive: true} + } + // No car lists identifiers, so the charger's names nobody: an OCPP + // charger sends an idTag at every start, often the same for every + // car. The other rules answer. + } + if c, evidenceAt := r.carEvidenceLocked(st, s, states, now); c != "" { + return answer{car: c, by: protocol.CarIdentifiedByCar, rank: identifiedRank[protocol.CarIdentifiedByCar], at: evidenceAt} + } + if candidates := r.candidatesLocked(st.ID); len(candidates) == 1 { + only := candidates[0] + if at, away := r.notHereLocked(only, s); away { + return answer{by: protocol.CarIdentifiedByNone, rank: identifiedRank[protocol.CarIdentifiedByOnly], at: at, decisive: true} + } + return answer{car: only.ID, by: protocol.CarIdentifiedByOnly, rank: identifiedRank[protocol.CarIdentifiedByOnly], + at: time.UnixMilli(s.PluggedMs)} + } else if usual := r.usualCarLocked(st.ID, candidates); usual != nil { + if _, away := r.notHereLocked(usual, s); !away { + return answer{car: usual.ID, by: protocol.CarIdentifiedByOnly, rank: identifiedRank[protocol.CarIdentifiedByOnly], + at: time.UnixMilli(s.PluggedMs)} + } + // The usual car is elsewhere, but another car may be here: ask. + } + return none +} + +// usualCarLocked is the car whose usual charger this is, when no other car +// that could be here claims it: each of them has a usual charger elsewhere. A +// car with none, as one an owner added since, could be on any charger, and +// the box asks. +func (r *Runtime) usualCarLocked(lpID string, candidates []*Car) *Car { + var found *Car + for _, c := range candidates { + switch c.UsualLP { + case lpID: + if found != nil { + return nil + } + found = c + case "": + return nil + } + } + return found +} + +// onOtherChargerLocked reports whether a car is the car of another plugged-in +// charger's session: one car is on one charger at a time. +func (r *Runtime) onOtherChargerLocked(carID, lpID string) bool { + other, _ := r.sessionOfLocked(carID) + return other != "" && other != lpID +} + +// candidatesLocked are the cars that could be on a charger: every car not on +// another charger. Rule 3 answers when there is exactly one. +func (r *Runtime) candidatesLocked(lpID string) []*Car { + var out []*Car + for _, c := range r.rec.Cars { + if !r.onOtherChargerLocked(c.ID, lpID) { + out = append(out, c) + } + } + return out +} + +// notHereLocked reports whether one of the car's sources said, within +// IdentifyWindow of the plug-in, that the car is not here: somewhere else, +// or with its cable out. +func (r *Runtime) notHereLocked(car *Car, s *session) (time.Time, bool) { + plugAt := time.UnixMilli(s.PluggedMs) + for _, d := range car.Sources { + ss := r.sources[d] + if ss == nil || !ss.seen { + continue + } + o := ss.obs + at := o.ReportedAt + if at.Before(plugAt) || at.Sub(plugAt) > IdentifyWindow { + continue + } + if (o.AtSite != nil && !*o.AtSite) || (o.Plugged != nil && !*o.Plugged) { + return at, true + } + } + return time.Time{}, false +} + +// carEvidenceLocked is rule 2: a car's own source says it was plugged in +// within IdentifyWindow of this plug-in and not somewhere else, or says it is +// charging at this site while this charger alone delivers. Two cars saying +// so is no answer. +func (r *Runtime) carEvidenceLocked(st loadpoint.State, s *session, states []loadpoint.State, now time.Time) (string, time.Time) { + plugAt := time.UnixMilli(s.PluggedMs) + delivering := st.CurrentPowerW >= loadpoint.DeliveringW + for _, other := range states { + if other.ID != st.ID && other.PluggedIn && other.CurrentPowerW >= loadpoint.DeliveringW { + delivering = false + } + } + found, foundAt := "", time.Time{} + for _, c := range r.rec.Cars { + if r.onOtherChargerLocked(c.ID, st.ID) { + continue + } + for _, d := range c.Sources { + ss := r.sources[d] + if ss == nil || !ss.seen { + continue + } + o := ss.obs + if o.AtSite != nil && !*o.AtSite { + continue + } + var at time.Time + switch { + case ss.plugged && !ss.pluggedSince.IsZero() && absDuration(ss.pluggedSince.Sub(plugAt)) <= IdentifyWindow: + at = ss.pluggedSince + case delivering && o.AtSite != nil && *o.AtSite && o.Live(now) && + telemetry.VehicleConnectedRank(o.ChargingState) == 3: + at = now + default: + continue + } + if found != "" && found != c.ID { + return "", time.Time{} + } + found, foundAt = c.ID, at + } + } + return found, foundAt +} + +func absDuration(d time.Duration) time.Duration { + if d < 0 { + return -d + } + return d +} + +func (r *Runtime) carWithIdentifierLocked(ident string) *Car { + want := normIdentifier(ident) + if want == "" { + return nil + } + for _, c := range r.rec.Cars { + for _, id := range c.Identifiers { + if normIdentifier(id) == want { + return c + } + } + } + return nil +} + +// applyAnswerLocked binds the session to the answer, or keeps what it has. +// +// An answer the box found by itself may be corrected by the charger or the +// car, never by a worse rule, and only with evidence newer than the choice; +// the only car may be corrected by any of them. A tap may be corrected the +// same way, unless it overrode an answer the box had found: then it is +// final. "Nobody answered" never unbinds a car. +func (r *Runtime) applyAnswerLocked(lpID string, s *session, a answer, now time.Time) bool { + cur := s.By + if a.car == s.CarID { + switch { + case a.car == "" && a.decisive && !s.Tapped && cur != protocol.CarIdentifiedByNone: + // asking, and then the charger or the only car said it is + // another car: a visitor, and nothing to ask. + s.By, s.Asked = protocol.CarIdentifiedByNone, true + r.dirty = true + return true + case a.car == "" && a.decisive && !s.Asked: + s.Asked = true + r.dirty = true + return false + case a.car != "" && a.by != cur && !s.Tapped && identifiedRank[a.by] < identifiedRank[cur]: + s.By = a.by + r.dirty = true + return true + } + return false + } + if a.car == "" && !a.decisive { + return false + } + corrects := a.by == protocol.CarIdentifiedByCharger || a.by == protocol.CarIdentifiedByCar || a.decisive + newer := a.at.After(time.UnixMilli(s.BoundMs)) + switch { + case s.Tapped: + if s.TapFinal || !corrects || !newer || a.by == protocol.CarIdentifiedByOnly { + return false + } + case cur == protocol.CarIdentifiedByOnly: + if !corrects { + return false + } + case cur == protocol.CarIdentifiedByCharger || cur == protocol.CarIdentifiedByCar: + if !corrects || a.rank > identifiedRank[cur] || !newer { + return false + } + } + by := a.by + if a.car == "" { + by = protocol.CarIdentifiedByNone + s.Asked = true + } + r.bindLocked(lpID, s, a.car, by, now) + s.Tapped, s.TapFinal = false, false + return true +} + +// bindLocked names the session's car. A session that had another car marks +// the change as a correction; the car it had gets its level from before +// the session back, unless its own source read it since. +func (r *Runtime) bindLocked(lpID string, s *session, carID, by string, now time.Time) { + old := s.CarID + if oldCar := r.carLocked(old); oldCar != nil && oldCar.Level.LP == lpID { + if oldCar.Level.Source != protocol.CarSoCVehicle && s.Prev != nil { + oldCar.Level = *s.Prev + } else { + oldCar.Level.LP, oldCar.Level.SessionWh = "", 0 + } + } + s.Prev = nil + if old != "" && old != carID { + s.CorrectedFrom = old + slog.Info("cars: the session's car changed", "lp", lpID, "from", old, "to", carID, "by", by) + } + s.CarID, s.By = carID, by + s.BoundMs = now.UnixMilli() + s.seeded = false + s.goal, s.surplus = nil, nil + if carID != "" { + r.rec.LastCar[lpID] = carID + } + r.dirty = true +} + +// askLocked raises car.which once per plug-in, when a box with several cars +// has had no answer within IdentifyWindow, and the session says asking. +func (r *Runtime) askLocked(now time.Time) []events.Event { + if len(r.rec.Cars) < 2 || r.deps.Bus == nil { + return nil + } + var out []events.Event + for lp, s := range r.rec.Sessions { + if s.By != protocol.CarIdentifiedByNone || s.Asked || s.Tapped || !s.unpluggedAt.IsZero() || + now.Sub(time.UnixMilli(s.PluggedMs)) < IdentifyWindow { + continue + } + s.Asked = true + s.By = protocol.CarIdentifiedByAsking + r.dirty = true + choose := r.chooseLocked(lp) + out = append(out, events.CarWhich{LoadpointID: lp, Session: s.ID, CarIDs: choose, At: now}) + slog.Info("cars: asking which car is plugged in", "lp", lp, "offered", len(choose)) + } + return out +} + +// chooseLocked is the cars a tap may pick on a charger: those not on another +// charger, at most four. With more, it offers none and the phone opens FTW. +func (r *Runtime) chooseLocked(lpID string) []string { + var ids []string + for _, c := range r.rec.Cars { + if other, _ := r.sessionOfLocked(c.ID); other != "" && other != lpID { + continue + } + ids = append(ids, c.ID) + } + if len(ids) > maxChoose { + return nil + } + return ids +} + +// maxChoose is how many cars a notification can offer as buttons. +const maxChoose = 4 + +// syncLocked hands each loadpoint the car it now has: capacity and limits, +// goal and surplus choice, and at a new binding the car's level. It returns +// the calls to make once the lock is released, because the loadpoint's +// savers call back into this runtime. +func (r *Runtime) syncLocked(now time.Time) []func() { + lps := r.deps.Loadpoints + var acts []func() + for _, st := range lps.States() { + id := st.ID + s := r.rec.Sessions[id] + var ( + b loadpoint.CarBinding + car *Car + goal loadpoint.Schedule + surplus bool + intent = true + ) + if s != nil && st.PluggedIn { + car = r.carLocked(s.CarID) + if car != nil { + b, goal, surplus = bindingFor(car), car.Goal, car.SurplusOnly + } else { + // A visitor charges from surplus only until someone sets a + // goal for the session. + surplus = true + if s.goal != nil { + goal = *s.goal + } + if s.surplus != nil { + surplus = *s.surplus + } + } + b.Session, b.IdentifiedBy, b.CorrectedFrom = s.ID, s.By, s.CorrectedFrom + b.Corrected = s.CorrectedFrom != "" + if s.By == protocol.CarIdentifiedByAsking { + b.Choose = strings.Join(r.chooseLocked(id), ",") + } + } else if car, _ = r.viewCarLocked(id); car != nil { + b, goal, surplus = bindingFor(car), car.Goal, car.SurplusOnly + } else { + intent = false + } + if prev, ok := r.applied[id]; !ok || prev != b { + r.applied[id] = b + acts = append(acts, func() { lps.SetCarBinding(id, b) }) + } + if intent { + if !sameGoal(st.Schedule, goal) { + // The loadpoint keeps the site rule it holds when the goal + // lands, not the one read here. + next := goalOnly(goal) + acts = append(acts, func() { lps.ApplyCarGoal(id, next, sameGoal) }) + } + if st.SurplusOnly != surplus { + acts = append(acts, func() { lps.ApplySurplusOnly(id, surplus) }) + } + } + if s != nil && st.PluggedIn && !s.seeded { + s.seeded = true + seed := r.seedLocked(car, s, st) + acts = append(acts, func() { lps.SeedSessionLevel(id, seed) }) + if car != nil && car.ProfileTarget > 0 { + target := car.ProfileTarget + acts = append(acts, func() { lps.SetTarget(id, target, time.Time{}) }) + } + } + } + return acts +} + +func bindingFor(car *Car) loadpoint.CarBinding { + return loadpoint.CarBinding{ID: car.ID, Name: car.Name, CapacityWh: car.CapacityWh, MaxW: car.MaxW, Phases: car.Phases} +} + +// seedLocked is the level a session starts from. A car's level carries into +// a session only when the car has not left a charger since it was read; a +// car that may have driven starts from its plug-in assumption, as before +// cars. A visitor starts from nothing. +func (r *Runtime) seedLocked(car *Car, s *session, st loadpoint.State) loadpoint.LevelSeed { + if car == nil { + return loadpoint.LevelSeed{} + } + if s.Prev == nil { + // The level before this session, which a switch to another car + // puts back. A session resumed after a flap keeps the first. + prev := car.Level + s.Prev = &prev + } + lvl := &car.Level + carried := lvl.Source != "" && (car.UnpluggedMs == 0 || lvl.AtMs > car.UnpluggedMs) && !tooOldFor(lvl.AtMs, s) + readHere := carried && lvl.AtMs >= s.PluggedMs + if s.CorrectedFrom == "" && !readHere && !st.SoCUnknown && st.SoCSource != "assumed" { + // The session's first car, and the loadpoint already holds a level + // it trusts: restored after a restart, or set while the box asked. + // That level is this car's; a seed from an older one would undo it. + *lvl = r.adoptedLevelLocked(st, s, lvl.ChargeLimit) + r.dirty = true + return loadpoint.LevelSeed{Known: true, SoC: st.CurrentSoC, SessionWh: st.DeliveredWhSession, + Confirmed: true, ConfirmedAt: st.SoCConfirmedAt, FromCar: st.LevelFromCar} + } + if !carried { + soc := car.PluginSoC + if soc <= 0 { + soc = units.DefaultPluginSoC + } + *lvl = Level{SoC: soc, Source: protocol.CarSoCAssumed, AtMs: s.PluggedMs, LP: st.ID} + r.dirty = true + return loadpoint.LevelSeed{Known: true, SoC: soc} + } + seed := loadpoint.LevelSeed{Known: true, SoC: lvl.SoC, Confirmed: lvl.Source != protocol.CarSoCAssumed, + ConfirmedAt: lvl.at(), FromCar: lvl.Source == protocol.CarSoCVehicle} + switch { + case lvl.LP == st.ID: + // Placed in this session. After a flap, what flowed before it is + // in BaseWh. + seed.SessionWh = lvl.SessionWh + if lvl.BaseWh != 0 && car.CapacityWh > 0 { + seed.SoC = units.ClampFraction(lvl.SoC + lvl.BaseWh*loadpoint.DefaultChargeEfficiency/car.CapacityWh) + seed.FromCar = false + } + case lvl.AtMs >= s.PluggedMs: + // Read in this session, before the box knew which car it was. + wh, ok := r.deps.Loadpoints.DeliveredAt(st.ID, lvl.at()) + if !ok { + wh = st.DeliveredWhSession + } + lvl.LP, lvl.SessionWh = st.ID, wh + seed.SessionWh = wh + r.dirty = true + case s.uncertain: + // Found plugged in at start: the reading may be from after the + // plug-in, so it is the level now, without energy the box cannot + // account for. + lvl.LP, lvl.SessionWh = st.ID, st.DeliveredWhSession + seed.SessionWh = st.DeliveredWhSession + r.dirty = true + default: + // Read before the plug-in: the level then, with what ended + // sessions delivered since. + if lvl.BaseWh > 0 && car.CapacityWh > 0 { + seed.SoC = units.ClampFraction(lvl.SoC + lvl.BaseWh*loadpoint.DefaultChargeEfficiency/car.CapacityWh) + seed.FromCar = false + } + } + return seed +} + +// adoptedLevelLocked is the level a loadpoint holds, as the car's: the +// reading it was confirmed with, at that reading's time and counter, so the +// car shows an estimate once the energy since says so. Without that time it +// is the level at the plug-in with the whole session since. +func (r *Runtime) adoptedLevelLocked(st loadpoint.State, s *session, chargeLimit float64) Level { + source := protocol.CarSoCManual + if st.LevelFromCar { + source = protocol.CarSoCVehicle + } + atMs, wh := s.PluggedMs, 0.0 + if !st.SoCConfirmedAt.IsZero() { + if d, ok := r.deps.Loadpoints.DeliveredAt(st.ID, st.SoCConfirmedAt); ok { + atMs, wh = st.SoCConfirmedAt.UnixMilli(), d + } + } + soc := st.CurrentSoC + if st.VehicleCapacityWh > 0 { + soc -= (st.DeliveredWhSession - wh) * loadpoint.DefaultChargeEfficiency / st.VehicleCapacityWh + } + return Level{SoC: units.ClampFraction(soc), Source: source, AtMs: atMs, LP: st.ID, SessionWh: wh, + ChargeLimit: chargeLimit} +} + +func statesByID(states []loadpoint.State) map[string]loadpoint.State { + out := make(map[string]loadpoint.State, len(states)) + for _, st := range states { + out[st.ID] = st + } + return out +} diff --git a/go/internal/cars/view.go b/go/internal/cars/view.go new file mode 100644 index 000000000..e39516f4b --- /dev/null +++ b/go/internal/cars/view.go @@ -0,0 +1,235 @@ +package cars + +import ( + "math" + "slices" + "time" + + "github.com/srcfl/ftw/go/internal/loadpoint" + "github.com/srcfl/ftw/go/internal/telemetry" + "github.com/srcfl/ftw/go/internal/units" + "github.com/srcfl/ftw/go/protocol" +) + +// Cars is every car, in the box's order, as the routes and the session +// carry it. +func (r *Runtime) Cars() []protocol.Car { + byID := statesByID(r.deps.Loadpoints.States()) + r.mu.Lock() + defer r.mu.Unlock() + out := make([]protocol.Car, 0, len(r.rec.Cars)) + for _, c := range r.rec.Cars { + out = append(out, r.viewLocked(c, byID)) + } + return out +} + +// Car is one car. +func (r *Runtime) Car(id string) (protocol.Car, error) { + byID := statesByID(r.deps.Loadpoints.States()) + r.mu.Lock() + defer r.mu.Unlock() + c := r.carLocked(id) + if c == nil { + return protocol.Car{}, ErrUnknownCar + } + return r.viewLocked(c, byID), nil +} + +func (r *Runtime) viewLocked(car *Car, byID map[string]loadpoint.State) protocol.Car { + c := protocol.Car{ + ID: car.ID, BoxMinted: car.BoxMinted, Name: car.Name, Make: car.Make, VIN: car.VIN, + CapacityWh: int64(car.CapacityWh + 0.5), MaxW: int64(car.MaxW + 0.5), Phases: int64(car.Phases), + Goal: carGoal(car.Goal), SurplusOnly: car.SurplusOnly, + Identifiers: car.Identifiers, Sources: car.Sources, + } + lvl := car.Level + if lvl.Source == "" { + return c + } + soc, energy, est := r.shownLevelLocked(car, byID) + permille := units.PermilleFromFraction(soc) + c.SocPermille = &permille + c.SocSource = lvl.Source + age := max(0, r.deps.Now().UnixMilli()-lvl.AtMs) / 1000 + c.SocAgeS = &age + c.EnergySinceWh = int64(math.Round(energy)) + c.SocEstimate = est + if lvl.ChargeLimit > 0 { + limit := units.PermilleFromFraction(lvl.ChargeLimit) + c.ChargeLimitPermille = &limit + } + return c +} + +// levelNowLocked is the car's level now and the energy delivered to it since +// its reading. On a charger it is the loadpoint's estimate, which the +// planner reads too. +func (r *Runtime) levelNowLocked(car *Car, byID map[string]loadpoint.State) (soc, energySinceWh float64) { + lvl := car.Level + if lp, s := r.sessionOfLocked(car.ID); s != nil && s.seeded { + if st, ok := byID[lp]; ok && st.PluggedIn && !st.SoCUnknown { + from := 0.0 + if lvl.LP == lp { + from = lvl.SessionWh + } + // Negative when a V2X session took energy out. + return st.CurrentSoC, lvl.BaseWh + st.DeliveredWhSession - from + } + } + soc = lvl.SoC + if car.CapacityWh > 0 { + soc += lvl.BaseWh * loadpoint.DefaultChargeEfficiency / car.CapacityWh + } + return units.ClampFraction(soc), lvl.BaseWh +} + +// shownLevelLocked is the level a car shows and the planner starts from: +// the car's or a person's own number while it is exact, the estimate after. +// A number the car never said is never shown as the car's. +func (r *Runtime) shownLevelLocked(car *Car, byID map[string]loadpoint.State) (soc, energySinceWh float64, estimated bool) { + now, energy := r.levelNowLocked(car, byID) + estimated = estimate(car, energy) + if !estimated { + return car.Level.SoC, energy, false + } + return now, energy, true +} + +// estimate reports whether the box counts the level as an estimate: once +// more than CarEstimateAfterWh has flowed since the reading, either way, for an assumed +// level, and for a level read before the car last left a charger, since it +// may have driven. +func estimate(car *Car, energySinceWh float64) bool { + lvl := car.Level + return lvl.Source == protocol.CarSoCAssumed || math.Abs(energySinceWh) > protocol.CarEstimateAfterWh || + (car.UnpluggedMs > 0 && lvl.AtMs < car.UnpluggedMs) +} + +// ChargerCar is what a charger carries about its car. +func (r *Runtime) ChargerCar(lpID string) protocol.ChargerCar { + r.mu.Lock() + defer r.mu.Unlock() + s := r.rec.Sessions[lpID] + if s == nil { + return protocol.ChargerCar{} + } + cc := protocol.ChargerCar{Session: s.ID, CarID: s.CarID, CarIdentifiedBy: s.By, + CarCorrected: s.CorrectedFrom != "", CarCorrectedFrom: s.CorrectedFrom} + if s.By == protocol.CarIdentifiedByAsking { + cc.Choose = r.chooseLocked(lpID) + } + return cc +} + +// SessionView is the session's car as a charger route shows it. +type SessionView struct { + CarID string + Source string + Estimate bool + At time.Time + // SoC is the reading itself, and Shown the level the car shows and the + // planner starts from: the reading while exact, the estimate after. + SoC float64 + Shown float64 + ChargeLimit float64 + // Driver and ChargingState are the newest report among the car's + // sources, and DriverAt when the car measured it. + Driver string + ChargingState string + DriverAt time.Time +} + +// Session is the car on a charger. False when nothing is plugged in or the +// session is a visitor's. +func (r *Runtime) Session(lpID string) (SessionView, bool) { + byID := statesByID(r.deps.Loadpoints.States()) + r.mu.Lock() + defer r.mu.Unlock() + s := r.rec.Sessions[lpID] + if s == nil { + return SessionView{}, false + } + car := r.carLocked(s.CarID) + if car == nil || car.Level.Source == "" { + return SessionView{CarID: s.CarID}, car != nil + } + shown, _, est := r.shownLevelLocked(car, byID) + v := SessionView{ + CarID: car.ID, Source: car.Level.Source, Estimate: est, + At: car.Level.at(), SoC: car.Level.SoC, Shown: shown, ChargeLimit: car.Level.ChargeLimit, + } + if o, ok := r.newestLocked(car); ok { + v.Driver, v.ChargingState, v.DriverAt = o.Driver, o.ChargingState, o.At + } + return v, true +} + +// newestLocked is the newest report among a car's sources. +func (r *Runtime) newestLocked(car *Car) (telemetry.VehicleObservation, bool) { + var best telemetry.VehicleObservation + found := false + for _, d := range car.Sources { + ss := r.sources[d] + if ss == nil || !ss.seen { + continue + } + if !found || ss.obs.At.After(best.At) { + best, found = ss.obs, true + } + } + return best, found +} + +// LiveReport is the newest live report of the car on a charger: its driver +// and charging state, for waking the car and for knowing when it is done. +// False when the session has no car or its sources have nothing live. +func (r *Runtime) LiveReport(lpID string) (telemetry.VehicleObservation, bool) { + now := r.deps.Now() + r.mu.Lock() + defer r.mu.Unlock() + s := r.rec.Sessions[lpID] + if s == nil { + return telemetry.VehicleObservation{}, false + } + car := r.carLocked(s.CarID) + if car == nil { + return telemetry.VehicleObservation{}, false + } + o, ok := r.newestLocked(car) + if !ok || !o.Live(now) { + return telemetry.VehicleObservation{}, false + } + return o, true +} + +// Sources are the vehicle drivers of the car on a charger. +func (r *Runtime) Sources(lpID string) []string { + r.mu.Lock() + defer r.mu.Unlock() + s := r.rec.Sessions[lpID] + if s == nil { + return nil + } + if car := r.carLocked(s.CarID); car != nil { + return append([]string(nil), car.Sources...) + } + return nil +} + +// Removed are the cars an owner removed, newest last. +func (r *Runtime) Removed() []Tombstone { + r.mu.Lock() + defer r.mu.Unlock() + return slices.Clone(r.rec.Removed) +} + +// PlanningLimit is the charge limit the car reported, when its level is the +// car's own and still exact. A plan never caps at a limit that may be old. +func (r *Runtime) PlanningLimit(lpID string) float64 { + v, ok := r.Session(lpID) + if !ok || v.Source != protocol.CarSoCVehicle || v.Estimate { + return 0 + } + return v.ChargeLimit +} diff --git a/go/internal/drivers/lua_vehicle_soc_freshness_test.go b/go/internal/drivers/lua_vehicle_soc_freshness_test.go index 058368015..5334afb41 100644 --- a/go/internal/drivers/lua_vehicle_soc_freshness_test.go +++ b/go/internal/drivers/lua_vehicle_soc_freshness_test.go @@ -82,10 +82,14 @@ end t.Fatalf("cached samples = %+v, want power only", samples) } - if pick := telemetry.PickBestVehicle(store, firstSoCUpdatedAt.Add(telemetry.VehicleMaxAge)); pick.Driver != "vehicle-cache" { - t.Fatalf("SoC at freshness boundary should remain usable, got %+v", pick) + obs := telemetry.VehicleObservations(store) + if len(obs) != 1 || !obs[0].At.Equal(firstSoCUpdatedAt) { + t.Fatalf("observation %+v, want the first fresh SoC's time", obs) } - if pick := telemetry.PickBestVehicle(store, firstSoCUpdatedAt.Add(telemetry.VehicleMaxAge+time.Nanosecond)); pick.Driver != "" { - t.Fatalf("SoC past freshness boundary must be rejected, got %+v", pick) + if !obs[0].Live(firstSoCUpdatedAt.Add(telemetry.VehicleMaxAge)) { + t.Fatalf("SoC at freshness boundary should remain live, got %+v", obs[0]) + } + if obs[0].Live(firstSoCUpdatedAt.Add(telemetry.VehicleMaxAge + time.Nanosecond)) { + t.Fatalf("SoC past freshness boundary must not be live, got %+v", obs[0]) } } diff --git a/go/internal/events/bus.go b/go/internal/events/bus.go index fceaad484..a7bcdef0b 100644 --- a/go/internal/events/bus.go +++ b/go/internal/events/bus.go @@ -72,6 +72,8 @@ const ( KindChargingSessionComplete = "charging.session_complete" KindChargingInterrupted = "charging.interrupted" KindChargingGoalAtRisk = "charging.goal_at_risk" + // KindCarWhich asks which car is on a charger (docs/cars.md). + KindCarWhich = "car.which" ) // HealthTick is fired every control-loop tick with the current telemetry @@ -203,3 +205,16 @@ type ChargingGoalAtRisk struct { } func (ChargingGoalAtRisk) Kind() string { return KindChargingGoalAtRisk } + +// CarWhich is emitted once per plug-in when the box cannot tell which of its +// cars is on a charger. Session names the plug-in a tap answers, and CarIDs +// are the cars a tap may pick, in the box's order: at most four, empty when +// there are more. +type CarWhich struct { + LoadpointID string + Session string + CarIDs []string + At time.Time +} + +func (CarWhich) Kind() string { return KindCarWhich } diff --git a/go/internal/loadpoint/car.go b/go/internal/loadpoint/car.go new file mode 100644 index 000000000..936436d7e --- /dev/null +++ b/go/internal/loadpoint/car.go @@ -0,0 +1,200 @@ +package loadpoint + +import ( + "time" + + "github.com/srcfl/ftw/go/internal/units" +) + +// The car on a loadpoint (docs/cars.md). internal/cars owns the cars and +// decides which one a session belongs to; the loadpoint keeps the session's +// energy and turns the car's level into the estimate the planner and the +// controller read. These are the doors between the two. + +// CarBinding is what the session's car brings to a loadpoint. A zero ID is a +// visitor while plugged in, and no car at all while unplugged. +type CarBinding struct { + ID string + Name string + // Session is the plug-in's id, IdentifiedBy how the box knows the car, + // and Corrected/CorrectedFrom a better rule's correction. Choose is the + // cars a tap may pick while the box asks, comma-separated so the + // binding stays comparable. + Session string + IdentifiedBy string + Corrected bool + CorrectedFrom string + Choose string + // CapacityWh replaces the configured vehicle capacity while the binding + // holds. Zero is config.yaml's vehicle_capacity_wh, or the default when + // that is unset: a visitor's capacity is not known either. + CapacityWh float64 + // MaxW and Phases are the car's own charger. Zero is unknown. + MaxW float64 + Phases int +} + +// LevelSeed starts a session's level from the car's. +type LevelSeed struct { + // Known is false for a visitor: nobody knows its level, and the box + // does not guess one. + Known bool + // SoC is the car's level when the session had delivered SessionWh. + SoC float64 + SessionWh float64 + // Confirmed is a level from the car or from someone who set it, not a + // plug-in assumption. ConfirmedAt is when, for VehicleSoCSupersedes. + Confirmed bool + ConfirmedAt time.Time + // FromCar is a reading the car itself made. + FromCar bool +} + +// SetCarBinding names the session's car. The level is kept: a new car's +// level comes through SeedSessionLevel. False for an unknown loadpoint. +func (m *Manager) SetCarBinding(id string, b CarBinding) bool { + m.sessionMu.Lock() + defer m.sessionMu.Unlock() + m.mu.Lock() + lp := m.byID[id] + if lp == nil { + m.mu.Unlock() + return false + } + lp.car = b + capacity := b.CapacityWh + if capacity <= 0 { + capacity = lp.configCapacityWh + } + persist := false + switch { + case lp.capacityFromCar: + // The car measured its own capacity this session, which outranks + // what the owner wrote down. Plug-out restores the car's figure. + lp.baseCapacityWh = capacity + case lp.VehicleCapacityWh != capacity: + persist = setCapacityLocked(lp, capacity) + } + lp.updatedAtMs = m.now().UnixMilli() + m.mu.Unlock() + if persist { + m.persistSession(id) + } + return true +} + +// setCapacityLocked changes how delivered energy turns into a level, and +// keeps the level the session shows now, as a config reload does. +func setCapacityLocked(lp *loadpointRuntime, capacityWh float64) bool { + lp.VehicleCapacityWh = capacityWh + if !lp.pluggedIn { + return false + } + delivered := 0.0 + if capacityWh > 0 { + delivered = lp.deliveredWhSession * DefaultChargeEfficiency / capacityWh + } + lp.sessionPluginSoC = lp.currentSoC - delivered + lp.currentSoC = estimateSoC(lp.sessionPluginSoC, lp.deliveredWhSession, capacityWh) + return lp.socConfirmed +} + +// SeedSessionLevel starts the session's level from the car's. False when the +// loadpoint is unknown or unplugged. +func (m *Manager) SeedSessionLevel(id string, seed LevelSeed) bool { + m.sessionMu.Lock() + defer m.sessionMu.Unlock() + m.mu.Lock() + lp := m.byID[id] + if lp == nil || !lp.pluggedIn || !finite(seed.SoC) || !finite(seed.SessionWh) { + m.mu.Unlock() + return false + } + if !seed.Known { + // Counted from empty: the planner then sizes a goal for a full + // battery, and the car stops charging when it is full. + lp.sessionPluginSoC = 0 + lp.socConfirmed = false + lp.socConfirmedAt = time.Time{} + lp.socUnknown = true + lp.vehicleAnchored = false + } else { + delivered := 0.0 + if lp.VehicleCapacityWh > 0 { + delivered = seed.SessionWh * DefaultChargeEfficiency / lp.VehicleCapacityWh + } + lp.sessionPluginSoC = units.ClampFraction(seed.SoC) - delivered + lp.socConfirmed = seed.Confirmed + lp.socConfirmedAt = time.Time{} + if seed.Confirmed { + lp.socConfirmedAt = seed.ConfirmedAt + } + lp.socUnknown = false + lp.vehicleAnchored = seed.FromCar + } + lp.currentSoC = estimateSoC(lp.sessionPluginSoC, lp.deliveredWhSession, lp.VehicleCapacityWh) + lp.updatedAtMs = m.now().UnixMilli() + confirmed := lp.socConfirmed + m.mu.Unlock() + if confirmed { + m.persistSession(id) + } + return true +} + +// DeliveredAt is the session's energy at t, as a car reading made then is +// anchored. False when the loadpoint is unplugged or the session cannot +// place t. +func (m *Manager) DeliveredAt(id string, t time.Time) (float64, bool) { + m.mu.RLock() + defer m.mu.RUnlock() + lp := m.byID[id] + if lp == nil || !lp.pluggedIn { + return 0, false + } + return lp.deliveredAt(t) +} + +// ApplySchedule sets the goal the car brings, as SetScheduleChecked does but +// without the schedule saver: the car's own record already holds it. +func (m *Manager) ApplySchedule(id string, s Schedule) bool { + ok, _ := m.setSchedule(id, s, false) + return ok +} + +// ApplyCarGoal sets the goal the car brings and keeps the site's battery +// rule the loadpoint holds now, read under the same lock that a schedule +// save takes, so a save in between is never undone. A goal the loadpoint +// already runs is left alone. same compares the owner's choices. +func (m *Manager) ApplyCarGoal(id string, goal Schedule, same func(a, b Schedule) bool) bool { + ok, _ := m.setScheduleWith(id, func(cur Schedule) (Schedule, bool) { + if same(cur, goal) { + return cur, false + } + goal.SurplusUnlockBatSoC = cur.SurplusUnlockBatSoC + return goal, true + }) + return ok +} + +// ApplySurplusOnly sets the car's surplus-only choice without the saver. +func (m *Manager) ApplySurplusOnly(id string, v bool) bool { + m.intentMu.Lock() + defer m.intentMu.Unlock() + m.mu.Lock() + defer m.mu.Unlock() + lp := m.byID[id] + if lp == nil { + return false + } + lp.Config.SurplusOnly = v + return true +} + +// SetLevelObserver hears every level someone sets by hand, so the session's +// car keeps it. Called outside the manager's locks. +func (m *Manager) SetLevelObserver(fn func(id string, soc float64, at time.Time)) { + m.mu.Lock() + defer m.mu.Unlock() + m.levelObserver = fn +} diff --git a/go/internal/loadpoint/loadpoint.go b/go/internal/loadpoint/loadpoint.go index 3d22a9716..c770500cd 100644 --- a/go/internal/loadpoint/loadpoint.go +++ b/go/internal/loadpoint/loadpoint.go @@ -12,6 +12,7 @@ package loadpoint import ( "crypto/rand" "sort" + "strings" "sync" "time" @@ -168,8 +169,38 @@ type State struct { PlanningSoC *float64 `json:"planning_soc,omitempty"` PlanningFromVehicle bool `json:"planning_from_vehicle,omitempty"` // VehicleName is the vehicle profile the session identified (via the - // charging transaction's idTag/idToken), empty when none matched. + // charging transaction's idTag/idToken), empty when none matched. With + // cars it is the session's car's name. VehicleName string `json:"vehicle_name,omitempty"` + // The car on the charger and how the box knows it (docs/cars.md). Empty + // while nothing is plugged in. The registry names them carId, + // carIdentifiedBy and carCorrectedFrom. + Session string `json:"session,omitempty"` + CarID string `json:"car_id,omitempty"` + CarIdentifiedBy string `json:"car_identified_by,omitempty"` + CarCorrected bool `json:"car_corrected,omitempty"` + CarCorrectedFrom string `json:"car_corrected_from,omitempty"` + Choose []string `json:"choose,omitempty"` + // SoCUnknown is a visitor's session: no level, and CurrentSoC counts + // only what was delivered. Routes leave the level out. + SoCUnknown bool `json:"-"` + // Observed is set once the charger has reported since the box started: + // before that, PluggedIn says nothing about the cable. + Observed bool `json:"-"` + // LevelFromCar is set while the session's level is anchored to a + // reading the car itself made, and SoCConfirmedAt is when the level was + // read or set; zero when it is not confirmed. + LevelFromCar bool `json:"-"` + SoCConfirmedAt time.Time `json:"-"` + // PluggedAt is when this connection was first seen, and SessionKey the + // charger's own session identity when it has one. Together they tell + // one plug-in from the next. + PluggedAt time.Time `json:"-"` + SessionKey string `json:"-"` + // CarMaxW and CarPhases are the session car's own charger, zero when + // unknown. The planner holds power below them. + CarMaxW float64 `json:"-"` + CarPhases int `json:"-"` // MinChargeW / MaxChargeW / AllowedStepsW are repeated here so the // UI has everything for rendering in one fetch. @@ -284,6 +315,8 @@ type Manager struct { // intentMu serializes durable goal and solar edits with config reloads. intentMu sync.Mutex + // levelObserver hears a level set by hand (SetLevelObserver). + levelObserver func(id string, soc float64, at time.Time) // scheduleSaver, if non-nil, is invoked synchronously whenever a // schedule is set or cleared. Wired by main.go to persist via // state.SaveConfig. Left nil in tests / sites without storage. @@ -398,20 +431,25 @@ type loadpointRuntime struct { // every plug-in transition (prev !pluggedIn → now pluggedIn). sessionPluginSoC float64 - // vehicleName is the vehicle profile applied for this session after - // the charging transaction identified the car (Manager. - // ApplyVehicleProfile); baseCapacityWh remembers the capacity to - // restore on plug-out — the profile is session-scoped, the next car - // may be a different one. - vehicleName string + // baseCapacityWh is the capacity to restore on plug-out after the car + // itself reported its own for the session (capacityFromCar): the next + // car may be a different one. baseCapacityWh float64 + // car is the session's car (SetCarBinding), and socUnknown is a + // visitor's session, whose level nobody knows. + car CarBinding + socUnknown bool + // configCapacityWh is config.yaml's vehicle_capacity_wh, which a + // binding with no capacity of its own falls back to. observed is set + // once the charger has reported since the box started. + configCapacityWh float64 + observed bool + // capacityFromCar is set when the vehicle itself reported its battery - // capacity (OCPP 2.0.1 NotifyEVChargingNeeds), which outranks both the - // configured value and a profile's — one is measured, the others are - // an operator's estimate of the car that usually parks here. It shares - // baseCapacityWh with vehicleName: whichever arrives first snapshots - // the configured capacity, and plug-out restores it either way. + // capacity (OCPP 2.0.1 NotifyEVChargingNeeds), which outranks the + // session car's — one is measured, the other is an owner's estimate. + // Plug-out restores baseCapacityWh. capacityFromCar bool // schedule carries the operator's persistent intent. Empty when @@ -568,7 +606,7 @@ func (m *Manager) Load(cfgs []Config) { if c.ID == "" { continue } - lp := &loadpointRuntime{Config: c} + lp := &loadpointRuntime{Config: c, configCapacityWh: c.VehicleCapacityWh} if existing := m.byID[c.ID]; existing != nil && existing.DriverName == c.DriverName { lp.sessionGeneration = existing.sessionGeneration lp.connectionGeneration = existing.connectionGeneration @@ -638,13 +676,22 @@ func (m *Manager) Load(cfgs []Config) { lp.chargingSteadySince = existing.chargingSteadySince lp.stoppedSince = existing.stoppedSince lp.steadyRunArmed = existing.steadyRunArmed - lp.vehicleName = existing.vehicleName lp.capacityFromCar = existing.capacityFromCar - if existing.vehicleName != "" || existing.capacityFromCar { + lp.car = existing.car + lp.observed = existing.observed + if existing.DriverName == c.DriverName { + lp.socUnknown = existing.socUnknown + } + if existing.car.CapacityWh > 0 { + // The car's capacity outlives a config reload, as the car + // does. A visitor's is the charger's, from the new config. + lp.Config.VehicleCapacityWh = existing.car.CapacityWh + } + if existing.capacityFromCar { // An identified car survives config hot-reload: keep the // session's applied capacity, but re-base the plug-out - // restore on the NEW config's value. - lp.baseCapacityWh = c.VehicleCapacityWh + // restore on the NEW config's value, or the car's. + lp.baseCapacityWh = lp.Config.VehicleCapacityWh if existing.VehicleCapacityWh > 0 { lp.Config.VehicleCapacityWh = existing.VehicleCapacityWh } @@ -777,6 +824,7 @@ func (m *Manager) observe(id string, pluggedIn bool, powerW, deliveredWh float64 } lp.sessionPluginSoC = anchor lp.socConfirmed = false + lp.socUnknown = false lp.completionNotified = false lp.goalRiskNotified = false lp.vehicleAnchored = false @@ -789,11 +837,10 @@ func (m *Manager) observe(id string, pluggedIn bool, powerW, deliveredWh float64 // Plug-out: drop any pending completion timer / latch. lp.notRequestingSince = time.Time{} lp.chargingDeclined = false - if lp.vehicleName != "" || lp.capacityFromCar { - // The identified car left with its session — the next one may - // be different, so restore the loadpoint's own capacity. + if lp.capacityFromCar { + // The car that measured its own capacity left with its + // session — the next one may be different. lp.VehicleCapacityWh = lp.baseCapacityWh - lp.vehicleName = "" lp.capacityFromCar = false lp.baseCapacityWh = 0 } @@ -903,6 +950,7 @@ func (m *Manager) observe(id string, pluggedIn bool, powerW, deliveredWh float64 lp.currentSoC = 0 } lp.updatedAtMs = now.UnixMilli() + lp.observed = true m.mu.Unlock() return fired, bus @@ -1032,33 +1080,6 @@ func (m *Manager) HydrateSurplusOnly(load func(id string) (bool, bool)) { } } -// ApplyVehicleProfile switches the loadpoint to an identified car for the -// rest of the session: capacityWh (when > 0) replaces the configured -// vehicle capacity so SoC inference and planner energy sizing follow the -// car actually plugged in. Reverted on plug-out; survives config -// hot-reloads (Load carries it across). The caller applies the profile's -// policy fields (surplus_only, target) through the ordinary setters. -// Returns false for an unknown loadpoint id. -func (m *Manager) ApplyVehicleProfile(id, vehicleName string, capacityWh float64) bool { - m.mu.Lock() - defer m.mu.Unlock() - lp, ok := m.byID[id] - if !ok { - return false - } - if lp.vehicleName == "" && !lp.capacityFromCar { - lp.baseCapacityWh = lp.VehicleCapacityWh - } - lp.vehicleName = vehicleName - // A capacity the car measured for this session outranks the profile's - // configured guess, whichever arrived first. - if capacityWh > 0 && !lp.capacityFromCar { - lp.VehicleCapacityWh = capacityWh - } - lp.updatedAtMs = m.now().UnixMilli() - return true -} - // SetSessionCapacityWh overrides the vehicle capacity for the rest of the // session with a figure the car itself reported — OCPP 2.0.1 // NotifyEVChargingNeeds carries the EV's own battery capacity. @@ -1079,7 +1100,7 @@ func (m *Manager) SetSessionCapacityWh(id string, capacityWh float64) bool { if !ok { return false } - if lp.vehicleName == "" && !lp.capacityFromCar { + if !lp.capacityFromCar { lp.baseCapacityWh = lp.VehicleCapacityWh } lp.capacityFromCar = true @@ -1099,21 +1120,27 @@ func (m *Manager) SetSessionCapacityWh(id string, capacityWh float64) bool { // Returns false for unknown IDs or when the loadpoint is unplugged. func (m *Manager) SetCurrentSoC(id string, socPct float64) bool { m.sessionMu.Lock() - defer m.sessionMu.Unlock() m.mu.Lock() - defer func() { m.mu.Unlock(); m.persistSession(id) }() lp, ok := m.byID[id] - if !ok { - return false - } - if !lp.pluggedIn || !finite(socPct) { + if !ok || !lp.pluggedIn || !finite(socPct) { + m.mu.Unlock() + m.persistSession(id) + m.sessionMu.Unlock() return false } // A correction gives the planner another chance to offer energy. // Sustained refusal can re-arm after SessionCompletionTimeout. lp.chargingDeclined = false lp.notRequestingSince = time.Time{} - reanchorSoCLocked(lp, socPct, m.now()) + at := m.now() + reanchorSoCLocked(lp, socPct, at) + soc, observer := lp.currentSoC, m.levelObserver + m.mu.Unlock() + m.persistSession(id) + m.sessionMu.Unlock() + if observer != nil { + observer(id, soc, at) + } return true } @@ -1250,6 +1277,7 @@ func reanchorSoCLocked(lp *loadpointRuntime, soc float64, at time.Time) { func reanchorSoCAtLocked(lp *loadpointRuntime, soc, deliveredWh float64, at time.Time) { soc = units.ClampFraction(soc) lp.socConfirmed = true + lp.socUnknown = false if at.IsZero() { at = time.Now() } @@ -1290,46 +1318,6 @@ func vehicleSupersedesLocked(lp *loadpointRuntime, observedAt time.Time) bool { return observedAt.After(lp.socConfirmedAt) } -// PlanningStartSoC is the EV state of charge a new plan starts from. -// haveVehicle is false when no car reading is inside the planning window. -// When the measurement should replace the session level, the result is that -// measurement plus energy delivered since it, when the session can account -// for that energy. Otherwise the session's current estimate is returned. -// fromVehicle reports which one was chosen. -func (m *Manager) PlanningStartSoC(id string, vehicleSoC float64, observedAt time.Time, haveVehicle bool) (soc float64, fromVehicle bool) { - m.mu.RLock() - defer m.mu.RUnlock() - lp := m.byID[id] - if lp == nil || !lp.pluggedIn { - return 0, false - } - if !haveVehicle || !vehicleSupersedesLocked(lp, observedAt) || !finite(vehicleSoC) { - return lp.currentSoC, false - } - // A measurement from before this connection is the level to adopt, not - // a base to which this session's energy should be added. The car may - // have been driven in between. - if !observedAt.IsZero() && !lp.connectionObservedAt.IsZero() && - observedAt.Before(lp.connectionObservedAt.Add(-anchorBeforePlugSlack)) { - return units.ClampFraction(vehicleSoC), true - } - deliveredNow := lp.deliveredWhSession - deliveredThen := deliveredNow - if !observedAt.IsZero() { - if at, ok := lp.deliveredAt(observedAt); ok { - deliveredThen = at - } - } - delta := deliveredNow - deliveredThen - if delta < 0 { - delta = 0 - } - if lp.VehicleCapacityWh <= 0 { - return units.ClampFraction(vehicleSoC), true - } - return units.ClampFraction(vehicleSoC + delta*DefaultChargeEfficiency/lp.VehicleCapacityWh), true -} - func (lp *loadpointRuntime) snapshot() State { steps := make([]float64, len(lp.AllowedStepsW)) copy(steps, lp.AllowedStepsW) @@ -1359,7 +1347,8 @@ func (lp *loadpointRuntime) snapshot() State { Schedule: lp.schedule, ChargingDeclined: lp.chargingDeclined, SoCRetention: lp.socRetention, - VehicleName: lp.vehicleName, + CarMaxW: lp.car.MaxW, + CarPhases: lp.car.Phases, CommandedW: lp.commandedW, CommandedReason: lp.commandedReason, CommandedKnown: lp.commandedKnown, @@ -1378,7 +1367,31 @@ func (lp *loadpointRuntime) snapshot() State { if !lp.powerAt.IsZero() { st.PowerUpdatedAtMs = lp.powerAt.UnixMilli() } - if st.PluggedIn && st.SoCSource == "" && !lp.socConfirmed { + if st.PluggedIn { + st.VehicleName = lp.car.Name + st.Session = lp.car.Session + st.CarID = lp.car.ID + st.CarIdentifiedBy = lp.car.IdentifiedBy + st.CarCorrected = lp.car.Corrected + st.CarCorrectedFrom = lp.car.CorrectedFrom + if lp.car.Choose != "" { + st.Choose = strings.Split(lp.car.Choose, ",") + } + st.PluggedAt = lp.connectionObservedAt + if lp.sessionDeviceID != "" && lp.sessionID != "" { + st.SessionKey = lp.sessionDeviceID + "|" + lp.sessionID + } + } + st.Observed = lp.observed + st.LevelFromCar = lp.vehicleAnchored + if lp.socConfirmed { + st.SoCConfirmedAt = lp.socConfirmedAt + } + switch { + case st.PluggedIn && lp.socUnknown: + st.SoCUnknown = true + st.SoCSource = "unknown" + case st.PluggedIn && st.SoCSource == "" && !lp.socConfirmed: st.SoCSource = "assumed" } st.ManualCommandUpdatedAt = lp.manualCommandUpdatedAt @@ -1409,16 +1422,40 @@ func (m *Manager) SetSchedule(id string, s Schedule) bool { // failure the previous schedule and derived target remain in effect. // The callback runs without m.mu so readers can keep seeing the current goal. func (m *Manager) SetScheduleChecked(id string, s Schedule) (bool, error) { + return m.setSchedule(id, s, true) +} + +// setSchedule stores a goal; save runs the schedule saver first. +func (m *Manager) setSchedule(id string, s Schedule, save bool) (bool, error) { + return m.setScheduleAs(id, func(Schedule) (Schedule, bool) { return s, true }, save) +} + +// setScheduleWith stores the goal next returns from the loadpoint's current +// schedule, read under intentMu, without the saver. next reports false to +// leave the schedule alone. +func (m *Manager) setScheduleWith(id string, next func(cur Schedule) (Schedule, bool)) (bool, error) { + return m.setScheduleAs(id, next, false) +} + +func (m *Manager) setScheduleAs(id string, next func(cur Schedule) (Schedule, bool), save bool) (bool, error) { m.intentMu.Lock() defer m.intentMu.Unlock() m.mu.RLock() lp, ok := m.byID[id] saver := m.scheduleSaver now, loc := m.now(), m.loc + var cur Schedule + if ok { + cur = lp.schedule + } m.mu.RUnlock() if !ok { return false, nil } + s, change := next(cur) + if !change { + return true, nil + } s.Normalize() // Keep the household clock the owner picked: time_of_day_min when the // request has it, else the UTC minute read on today's date. Clients @@ -1443,7 +1480,7 @@ func (m *Manager) SetScheduleChecked(id string, s Schedule) (bool, error) { // The weekday mask is 7 bits; a stray high bit from a future // client is dropped rather than left to confuse the roll. s.Days &= 0x7F - if saver != nil { + if saver != nil && save { if err := saver(id, s); err != nil { return true, err } diff --git a/go/internal/loadpoint/loadpoint_anchor_vehicle_test.go b/go/internal/loadpoint/loadpoint_anchor_vehicle_test.go index 99dc96fe7..5e3989759 100644 --- a/go/internal/loadpoint/loadpoint_anchor_vehicle_test.go +++ b/go/internal/loadpoint/loadpoint_anchor_vehicle_test.go @@ -16,22 +16,23 @@ func TestCachedVAGSoCReplacesManualPlanningAnchor(t *testing.T) { tel.EmitMetric("vag", "vehicle_soc_age_s", 34*60, "s", "", "") tel.DriverHealthMut("vag").RecordSuccess() _, receivedAt, _ := tel.LatestMetric("vag", "vehicle_soc_age_s") - pick := telemetry.PickVehicleForAnchor(tel, false, receivedAt.Add(time.Second)) - if pick.Driver == "" { - t.Fatal("timestamped VAG observation did not reach anchoring") + obs := telemetry.VehicleObservations(tel) + if len(obs) != 1 || obs[0].Driver != "vag" || !obs[0].At.Equal(receivedAt.Add(-34*time.Minute)) { + t.Fatalf("timestamped VAG observation did not reach the cars: %+v", obs) } + pick := obs[0] m := NewManager() m.Load([]Config{{ID: "easee", VehicleCapacityWh: 95000, PluginSoC: .27}}) - now := pick.UpdatedAt.Add(-time.Minute) + now := pick.At.Add(-time.Minute) m.SetNowFn(func() time.Time { return now }) m.Observe("easee", true, 0, 0, true) - now = pick.UpdatedAt + now = pick.At m.Observe("easee", true, 0, 0, true) now = receivedAt m.Observe("easee", true, 0, 2000, true) m.SetCurrentSoC("easee", .27) for range 3 { - if !m.AnchorVehicleSoCAt("easee", pick.SoC, pick.UpdatedAt) { + if !m.AnchorVehicleSoCAt("easee", pick.SoC, pick.At) { t.Fatal("same-session VAG anchor refused") } st, _ := m.State("easee") diff --git a/go/internal/loadpoint/planning_soc_test.go b/go/internal/loadpoint/planning_soc_test.go index 1183a61e0..696c17120 100644 --- a/go/internal/loadpoint/planning_soc_test.go +++ b/go/internal/loadpoint/planning_soc_test.go @@ -1,17 +1,19 @@ package loadpoint import ( - "encoding/json" "math" "testing" "time" - - "github.com/srcfl/ftw/go/internal/telemetry" ) -// manualThenVehicle builds a plugged-in session whose confirmed level is -// 27% at manualAt, and a vag_vehicle report of 78% whose measured age is age. -func manualThenVehicle(t *testing.T, manualAt time.Time, age time.Duration) (*Manager, telemetry.VehiclePick, time.Time) { +// The plan starts from the loadpoint's level. The cars runtime anchors it to +// the session car's own reading when that reading supersedes the level the +// session has (docs/cars.md); before cars, PlanningStartSoC made the same +// choice from a picked reading on each plan. These hold the choice. + +// manualSession is a plugged-in session whose level someone set to 27% at +// manualAt. +func manualSession(t *testing.T, manualAt time.Time) (*Manager, *time.Time) { t.Helper() m := NewManager() m.Load([]Config{{ID: "garage", VehicleCapacityWh: 60000, PluginSoC: 0.27}}) @@ -21,53 +23,38 @@ func manualThenVehicle(t *testing.T, manualAt time.Time, age time.Duration) (*Ma if !m.SetCurrentSoC("garage", 0.27) { t.Fatal("SetCurrentSoC") } - tel := telemetry.NewStore() - if age >= 0 { - soc := .78 - tel.Update("vag", telemetry.DerVehicle, 0, &soc, json.RawMessage(`{"soc":78,"soc_fresh":false,"stale":true,"charging_state":"Stopped"}`)) - tel.EmitMetric("vag", "vehicle_soc_age_s", age.Seconds(), "s", "", "") - tel.DriverHealthMut("vag").RecordSuccess() - } - _, receivedAt, _ := tel.LatestMetric("vag", "vehicle_soc_age_s") - now := receivedAt - if now.IsZero() { - now = manualAt.Add(time.Hour) - } else { - now = receivedAt.Add(time.Second) - } - pick := telemetry.PlanningVehiclePick(tel, false, now) - return m, pick, now + return m, &clock } -func TestPlanningStartsAtLaterVehicleSoC(t *testing.T) { - manualAt := time.Now().Add(-2 * time.Hour).UTC() - m, pick, _ := manualThenVehicle(t, manualAt, 34*time.Minute) - if pick.Driver != "vag" || pick.SoC != .78 || !pick.UpdatedAt.After(manualAt) { - t.Fatalf("34-minute vag reading was not a planning candidate: %+v", pick) - } - soc, fromVehicle := m.PlanningStartSoC("garage", pick.SoC, pick.UpdatedAt, true) - if !fromVehicle || math.Abs(soc-.78) > 1e-9 { - t.Fatalf("planner start = %v fromVehicle=%v, want 0.78 from the car", soc, fromVehicle) - } +func level(t *testing.T, m *Manager) float64 { + t.Helper() + st, _ := m.State("garage") + return st.CurrentSoC +} - // Energy delivered after the measurement moves the start, once. - clock := pick.UpdatedAt - m.SetNowFn(func() time.Time { return clock }) +// A car reading newer than the level someone set replaces it, and energy +// delivered after the reading moves the level once. +func TestPlanningStartsAtALaterCarReading(t *testing.T) { + manualAt := time.Now().Add(-2 * time.Hour).UTC() + m, clock := manualSession(t, manualAt) + readAt := manualAt.Add(86 * time.Minute) + *clock = readAt m.Observe("garage", true, 0, 0, true) - clock = pick.UpdatedAt.Add(10 * time.Minute) + *clock = readAt.Add(10 * time.Minute) m.Observe("garage", true, 11000, 2000, true) - soc, fromVehicle = m.PlanningStartSoC("garage", pick.SoC, pick.UpdatedAt, true) + if !m.VehicleSoCSupersedes("garage", readAt) || !m.AnchorVehicleSoCAt("garage", .78, readAt) { + t.Fatal("a newer car reading did not replace the level someone set") + } want := .78 + 2000*DefaultChargeEfficiency/60000 - if !fromVehicle || math.Abs(soc-want) > 1e-9 { - t.Fatalf("planner start = %v, want %v (measurement plus energy since)", soc, want) + if got := level(t, m); math.Abs(got-want) > 1e-9 { + t.Fatalf("level = %v, want %v (the reading plus energy since)", got, want) } - soc, fromVehicle = m.PlanningStartSoC("garage", pick.SoC, pick.UpdatedAt, true) - if !fromVehicle || math.Abs(soc-want) > 1e-9 { - t.Fatalf("energy counted twice: %v", soc) + if !m.AnchorVehicleSoCAt("garage", .78, readAt) || math.Abs(level(t, m)-want) > 1e-9 { + t.Fatalf("energy counted twice: %v", level(t, m)) } } -func TestPlanningKeepsManualWhenVehicleIsOlder(t *testing.T) { +func TestPlanningKeepsManualWhenTheCarReadingIsOlder(t *testing.T) { observed := time.Date(2026, 10, 11, 22, 0, 0, 0, time.UTC) m := NewManager() m.Load([]Config{{ID: "garage", VehicleCapacityWh: 60000, PluginSoC: 0.20}}) @@ -77,47 +64,56 @@ func TestPlanningKeepsManualWhenVehicleIsOlder(t *testing.T) { if !m.SetCurrentSoC("garage", 0.27) { t.Fatal("SetCurrentSoC") } - soc, fromVehicle := m.PlanningStartSoC("garage", .78, observed, true) - if fromVehicle || math.Abs(soc-.27) > 1e-9 { - t.Fatalf("newer manual lost: soc=%v fromVehicle=%v", soc, fromVehicle) - } if m.VehicleSoCSupersedes("garage", observed) { t.Fatal("older car measurement must not replace a newer manual level") } + if got := level(t, m); math.Abs(got-.27) > 1e-9 { + t.Fatalf("newer manual lost: %v", got) + } } -func TestPlanningKeepsManualWhenVehicleIsBeyondAnchorWindow(t *testing.T) { +// Before cars, a reading older than an hour could not start a plan. A car's +// last reading is its level however old; what ages it is energy delivered +// since and the car leaving a charger, which the cars runtime counts. +func TestAnHourOldReadingNewerThanTheManualLevelStillWins(t *testing.T) { manualAt := time.Now().Add(-3 * time.Hour).UTC() - m, pick, _ := manualThenVehicle(t, manualAt, telemetry.VehicleAnchorMaxAge+time.Minute) - if pick.Driver != "" { - t.Fatalf("reading past the anchor window reached planning: %+v", pick) + m, clock := manualSession(t, manualAt) + readAt := manualAt.Add(time.Hour) + *clock = manualAt.Add(2*time.Hour + time.Minute) + m.Observe("garage", true, 0, 0, true) + if !m.VehicleSoCSupersedes("garage", readAt) { + t.Fatal("the car's later reading lost to an earlier manual level") } - soc, fromVehicle := m.PlanningStartSoC("garage", 0, time.Time{}, false) - if fromVehicle || math.Abs(soc-.27) > 1e-9 { - t.Fatalf("planner start = %v fromVehicle=%v, want the manual 0.27", soc, fromVehicle) + if !m.AnchorVehicleSoCAt("garage", .78, readAt) { + t.Fatal("anchor") + } + if got := level(t, m); math.Abs(got-.78) > 1e-9 { + t.Fatalf("level = %v, want the car's 0.78", got) } } -func TestPlanningKeepsManualWhenNoVehicleDriver(t *testing.T) { - manualAt := time.Now().Add(-2 * time.Hour).UTC() - m, pick, _ := manualThenVehicle(t, manualAt, -1) - if pick.Driver != "" { - t.Fatalf("no driver still produced a pick: %+v", pick) - } - soc, fromVehicle := m.PlanningStartSoC("garage", 0, time.Time{}, false) - if fromVehicle || math.Abs(soc-.27) > 1e-9 { - t.Fatalf("planner start = %v fromVehicle=%v, want the manual 0.27", soc, fromVehicle) +func TestPlanningKeepsManualWithoutACarReading(t *testing.T) { + m, _ := manualSession(t, time.Now().Add(-2*time.Hour).UTC()) + if got := level(t, m); math.Abs(got-.27) > 1e-9 { + t.Fatalf("level = %v, want the manual 0.27", got) } } -func TestPlanningReplacesUnconfirmedPluginSoC(t *testing.T) { +func TestACarReadingReplacesAnUnconfirmedPlugInLevel(t *testing.T) { m := NewManager() m.Load([]Config{{ID: "garage", VehicleCapacityWh: 60000, PluginSoC: 0.27}}) at := time.Date(2026, 10, 11, 22, 0, 0, 0, time.UTC) m.SetNowFn(func() time.Time { return at }) m.Observe("garage", true, 0, 0, true) - soc, fromVehicle := m.PlanningStartSoC("garage", .78, at.Add(-34*time.Minute), true) - if !fromVehicle || math.Abs(soc-.78) > 1e-9 { - t.Fatalf("unconfirmed 27%% stayed in front of the car: soc=%v fromVehicle=%v", soc, fromVehicle) + readAt := at.Add(-34 * time.Minute) + if !m.VehicleSoCSupersedes("garage", readAt) { + t.Fatal("an assumed 27% stayed in front of the car") + } + // Read before the plug-in: the cars runtime starts the session there. + if !m.SeedSessionLevel("garage", LevelSeed{Known: true, SoC: .78, Confirmed: true, ConfirmedAt: readAt, FromCar: true}) { + t.Fatal("seed") + } + if got := level(t, m); math.Abs(got-.78) > 1e-9 { + t.Fatalf("level = %v, want the car's 0.78", got) } } diff --git a/go/internal/loadpoint/schedule.go b/go/internal/loadpoint/schedule.go index c9c083fef..718bc13a8 100644 --- a/go/internal/loadpoint/schedule.go +++ b/go/internal/loadpoint/schedule.go @@ -41,6 +41,15 @@ type Schedule struct { clockSet bool } +// NewSchedule is a goal on the household clock: soc is a fraction, readyBy +// minutes after midnight in the box's zone, days an ISO weekday mask with 0 +// for every day of a recurring goal. +func NewSchedule(soc float64, readyByMin int, recurring bool, days uint8, finishAtLimit bool) Schedule { + s := Schedule{SoC: units.ClampFraction(soc), Recurring: recurring, Days: days & 0x7F, + FinishAtVehicleLimit: finishAtLimit, clock: readyByMin, clockSet: true} + return s +} + // BatSoCUnlockHyst is the fraction gap between arm and release for the // bat-SoC surplus unlock. Armed at threshold, released at threshold − // 0.05. Tuned to swallow normal Kalman noise on bat SoC (~0.005–0.01) diff --git a/go/internal/loadpoint/session_capacity_test.go b/go/internal/loadpoint/session_capacity_test.go index 5d494a507..d1c23edf3 100644 --- a/go/internal/loadpoint/session_capacity_test.go +++ b/go/internal/loadpoint/session_capacity_test.go @@ -44,47 +44,47 @@ func TestSetSessionCapacitySessionScoped(t *testing.T) { } } -// Measured outranks configured, whichever order the two arrive in: a vehicle -// profile is an operator's estimate of the car that usually parks here, the -// NotifyEVChargingNeeds figure is the car that is actually plugged in. -func TestReportedCapacityOutranksProfile(t *testing.T) { - t.Run("profile first", func(t *testing.T) { +// Measured outranks configured, whichever order the two arrive in: the +// session car's capacity is its owner's estimate, the NotifyEVChargingNeeds +// figure is the car that is actually plugged in. Plug-out restores the car's. +func TestReportedCapacityOutranksTheCars(t *testing.T) { + leaf := CarBinding{ID: "c1", Name: "Leaf", CapacityWh: 40000} + t.Run("car first", func(t *testing.T) { m := NewManager() m.Load([]Config{{ID: "a", VehicleCapacityWh: 60000, PluginSoC: 0.30}}) m.Observe("a", true, 7000, 0, true) - m.ApplyVehicleProfile("a", "Leaf", 40000) + m.SetCarBinding("a", leaf) m.SetSessionCapacityWh("a", 77000) if got := m.Configs()[0].VehicleCapacityWh; got != 77000 { t.Fatalf("the car's own capacity should win, got %v", got) } - // Plug-out still restores the configured value, not the profile's. m.Observe("a", false, 0, 0, false) - if got := m.Configs()[0].VehicleCapacityWh; got != 60000 { - t.Fatalf("plug-out should restore the configured 60000, got %v", got) + if got := m.Configs()[0].VehicleCapacityWh; got != 40000 { + t.Fatalf("plug-out should restore the car's 40000, got %v", got) } }) - t.Run("car first", func(t *testing.T) { + t.Run("reported first", func(t *testing.T) { m := NewManager() m.Load([]Config{{ID: "a", VehicleCapacityWh: 60000, PluginSoC: 0.30}}) m.Observe("a", true, 7000, 0, true) m.SetSessionCapacityWh("a", 77000) - m.ApplyVehicleProfile("a", "Leaf", 40000) + m.SetCarBinding("a", leaf) if got := m.Configs()[0].VehicleCapacityWh; got != 77000 { - t.Fatalf("a later profile must not overwrite the car's own capacity, got %v", got) + t.Fatalf("a later binding must not overwrite the car's own capacity, got %v", got) } - // The profile still names the car for the UI. + // The binding still names the car for the UI. st, _ := m.State("a") - if st.VehicleName != "Leaf" { - t.Fatalf("vehicle name should still apply, got %+v", st) + if st.VehicleName != "Leaf" || st.CarID != "c1" { + t.Fatalf("the car should still be named, got %+v", st) } m.Observe("a", false, 0, 0, false) - if got := m.Configs()[0].VehicleCapacityWh; got != 60000 { - t.Fatalf("plug-out should restore the configured 60000, got %v", got) + if got := m.Configs()[0].VehicleCapacityWh; got != 40000 { + t.Fatalf("plug-out should restore the car's 40000, got %v", got) } }) } diff --git a/go/internal/loadpoint/session_state.go b/go/internal/loadpoint/session_state.go index 3dcd81b1c..ae921a36d 100644 --- a/go/internal/loadpoint/session_state.go +++ b/go/internal/loadpoint/session_state.go @@ -111,9 +111,8 @@ func (m *Manager) ObserveSample(id string, sample EVSample) { lp.chargingSteadySince = time.Time{} lp.stoppedSince = time.Time{} lp.steadyRunArmed = false - if lp.vehicleName != "" || lp.capacityFromCar { + if lp.capacityFromCar { lp.VehicleCapacityWh = lp.baseCapacityWh - lp.vehicleName = "" lp.capacityFromCar = false lp.baseCapacityWh = 0 } @@ -331,9 +330,8 @@ func (m *Manager) observeConnectionProof(id string, generation uint64, unknown b lp.sessionGeneration = m.nextSessionGeneration lp.socConfirmed = false lp.socRetention = "unavailable" - if lp.vehicleName != "" || lp.capacityFromCar { + if lp.capacityFromCar { lp.VehicleCapacityWh = lp.baseCapacityWh - lp.vehicleName = "" lp.capacityFromCar = false lp.baseCapacityWh = 0 } diff --git a/go/internal/loadpoint/session_state_test.go b/go/internal/loadpoint/session_state_test.go index c2e73e447..f4eefa585 100644 --- a/go/internal/loadpoint/session_state_test.go +++ b/go/internal/loadpoint/session_state_test.go @@ -173,9 +173,9 @@ func TestCapacityChangeKeepsCurrentLevelAndConfidence(t *testing.T) { func TestNewSessionAfterOutageDropsPriorCarCapacity(t *testing.T) { m := sessionManager(nil, "garage", "charger") m.ObserveSession("garage", true, 4300, 1000, true, "easee:ABC", "session-1") - m.ApplyVehicleProfile("garage", "Old car", 100000) + m.SetSessionCapacityWh("garage", 100000) m.ObserveSession("garage", true, 4300, 2000, true, "easee:ABC", "session-2") - if s, _ := m.State("garage"); s.VehicleName != "" || s.VehicleCapacityWh != 60000 { + if s, _ := m.State("garage"); s.VehicleCapacityWh != 60000 { t.Fatalf("prior car leaked after an unseen reconnect: %+v", s) } } diff --git a/go/internal/loadpoint/vehicle_anchor_order_test.go b/go/internal/loadpoint/vehicle_anchor_order_test.go index 72c401085..8b7807449 100644 --- a/go/internal/loadpoint/vehicle_anchor_order_test.go +++ b/go/internal/loadpoint/vehicle_anchor_order_test.go @@ -7,7 +7,10 @@ import ( ) // Cloud delivery order must not replace the car's measurement times. Both -// readings arrive late; the second was measured after the first. +// readings arrive late; the second was measured after the first. The plan +// starts from the loadpoint's level, which the cars runtime anchors only +// with a reading that supersedes it (docs/cars.md), so the level is what +// these hold. func TestDelayedVehicleSoCUsesMeasurementOrder(t *testing.T) { m := NewManager() m.Load([]Config{{ID: "car", VehicleCapacityWh: 86500, PluginSoC: .60}}) @@ -33,9 +36,6 @@ func TestDelayedVehicleSoCUsesMeasurementOrder(t *testing.T) { t.Fatal("newer car measurement lost to the first reading's receipt time") } want := .68 + 2500*DefaultChargeEfficiency/86500 - if got, fromVehicle := m.PlanningStartSoC("car", .68, second, true); !fromVehicle || math.Abs(got-want) > 1e-9 { - t.Fatalf("planner start %.12f fromVehicle=%t, want %.12f", got, fromVehicle, want) - } if !m.AnchorVehicleSoCAt("car", .68, second) { t.Fatal("second delayed car reading refused") } @@ -47,9 +47,9 @@ func TestDelayedVehicleSoCUsesMeasurementOrder(t *testing.T) { if m.VehicleSoCSupersedes("car", observed) { t.Fatalf("old or repeated measurement became newer: %v", observed) } - if got, fromVehicle := m.PlanningStartSoC("car", .68, observed, true); fromVehicle || math.Abs(got-want) > 1e-9 { - t.Fatalf("old reading changed the estimate or counted energy twice: %.12f, %t", got, fromVehicle) - } + } + if st, _ := m.State("car"); math.Abs(st.CurrentSoC-want) > 1e-9 { + t.Fatalf("old readings changed the estimate or counted energy twice: %.12f", st.CurrentSoC) } // A later explicit user correction still takes precedence over a car // measurement taken before it, even when that reading arrives afterwards. @@ -61,8 +61,8 @@ func TestDelayedVehicleSoCUsesMeasurementOrder(t *testing.T) { if m.VehicleSoCSupersedes("car", olderThanManual) { t.Fatal("a delayed car reading replaced a newer manual correction") } - if got, fromVehicle := m.PlanningStartSoC("car", .68, olderThanManual, true); fromVehicle || math.Abs(got-.71) > 1e-9 { - t.Fatalf("manual correction lost: %.12f, %t", got, fromVehicle) + if st, _ := m.State("car"); math.Abs(st.CurrentSoC-.71) > 1e-9 { + t.Fatalf("manual correction lost: %.12f", st.CurrentSoC) } now = now.Add(2 * time.Minute) if !m.VehicleSoCSupersedes("car", now.Add(-time.Minute)) { @@ -89,9 +89,6 @@ func TestDelayedVehicleSoCWithoutHistoryKeepsMeasurementOrder(t *testing.T) { if !m.VehicleSoCSupersedes("car", second) { t.Fatal("newer car measurement lost to raw fallback receipt time") } - if got, fromVehicle := m.PlanningStartSoC("car", .68, second, true); !fromVehicle || math.Abs(got-.68) > 1e-9 { - t.Fatalf("unknown earlier energy changed planning SoC: %.12f, %t", got, fromVehicle) - } if !m.AnchorVehicleSoCAt("car", .68, second) { t.Fatal("newer delayed reading refused") } diff --git a/go/internal/loadpoint/vehicle_completion.go b/go/internal/loadpoint/vehicle_completion.go index c9a758785..870f59436 100644 --- a/go/internal/loadpoint/vehicle_completion.go +++ b/go/internal/loadpoint/vehicle_completion.go @@ -11,21 +11,14 @@ type VehicleChargeState struct { } // VehicleObservationApplies rejects a reading from before this connection, -// including the first connection seen after restart. With several connected -// loadpoints, completion needs a car-to-charger binding that we do not have. +// including the first connection seen after restart. The caller passes only +// the session car's own reading (docs/cars.md), so several connected +// loadpoints no longer make the reading ambiguous. func (m *Manager) VehicleObservationApplies(id string, observedAt time.Time) bool { m.mu.RLock() defer m.mu.RUnlock() lp := m.byID[id] - if lp == nil || !lp.pluggedIn || observedAt.IsZero() || observedAt.Before(lp.connectionObservedAt) { - return false - } - for otherID, other := range m.byID { - if otherID != id && other.pluggedIn { - return false - } - } - return true + return lp != nil && lp.pluggedIn && !observedAt.IsZero() && !observedAt.Before(lp.connectionObservedAt) } func (c *Controller) SetVehicleChargeState(read func(string) (VehicleChargeState, bool)) { diff --git a/go/internal/loadpoint/vehicle_completion_test.go b/go/internal/loadpoint/vehicle_completion_test.go index 096eea033..ab4a98e36 100644 --- a/go/internal/loadpoint/vehicle_completion_test.go +++ b/go/internal/loadpoint/vehicle_completion_test.go @@ -192,7 +192,11 @@ func TestVehicleLimitReadsCompleteAfterChargerDecline(t *testing.T) { } } -func TestVehicleLimitRejectsPreviousConnectionAndAmbiguousLoadpoint(t *testing.T) { +// Before cars, a second connected loadpoint made every car reading ambiguous +// and completion refused it. The cars runtime now hands over only the +// session car's own reading, so the second charger changes nothing; a +// reading from before this connection is still refused. +func TestVehicleLimitRejectsPreviousConnectionNotASecondCharger(t *testing.T) { m := NewManager() now := time.Now().UTC() m.SetNowFn(func() time.Time { return now }) @@ -205,8 +209,8 @@ func TestVehicleLimitRejectsPreviousConnectionAndAmbiguousLoadpoint(t *testing.T t.Fatal("fresh reading for only connected loadpoint rejected") } m.ObserveSession("two", true, 0, 0, true, "device-two", "session-b") - if m.VehicleObservationApplies("one", now) { - t.Fatal("several connected loadpoints cannot share completion proof") + if !m.VehicleObservationApplies("one", now) { + t.Fatal("the session car's reading was refused because another charger is connected") } m.ObserveSession("two", false, 0, 0, false, "device-two", "") now = now.Add(time.Minute) diff --git a/go/internal/loadpoint/vehicle_profile_test.go b/go/internal/loadpoint/vehicle_profile_test.go index c6c228bc4..537e540df 100644 --- a/go/internal/loadpoint/vehicle_profile_test.go +++ b/go/internal/loadpoint/vehicle_profile_test.go @@ -2,49 +2,67 @@ package loadpoint import "testing" -// A vehicle profile is session-scoped: applied when the charging transaction -// identifies the car, steering SoC inference with that car's capacity, and -// reverted on plug-out because the next car may be a different one. -func TestApplyVehicleProfileSessionScoped(t *testing.T) { +// The session's car brings its capacity (docs/cars.md). Before cars a +// vehicle profile did this for one session and plug-out reverted it; now the +// cars runtime binds a car to each loadpoint and the binding holds until the +// runtime changes it, across a config reload too. +func TestCarBindingSteersTheSessionsCapacity(t *testing.T) { m := NewManager() m.Load([]Config{{ID: "a", VehicleCapacityWh: 60000, PluginSoC: 0.30}}) m.Observe("a", true, 7000, 0, true) - if m.ApplyVehicleProfile("nope", "Leaf", 40000) { + if m.SetCarBinding("nope", CarBinding{ID: "c1", CapacityWh: 40000}) { t.Fatal("unknown loadpoint should not apply") } - if !m.ApplyVehicleProfile("a", "Leaf", 40000) { + if !m.SetCarBinding("a", CarBinding{ID: "c1", Name: "Leaf", CapacityWh: 40000, IdentifiedBy: "tap"}) { t.Fatal("apply failed") } st, _ := m.State("a") - if st.VehicleName != "Leaf" { - t.Fatalf("vehicle name missing from state: %+v", st) + if st.VehicleName != "Leaf" || st.CarID != "c1" || st.CarIdentifiedBy != "tap" { + t.Fatalf("car missing from state: %+v", st) } - // 20 kWh delivered on a 40 kWh car = +45 points after charging losses from the 30 % anchor. - // With the configured 60 kWh it would only be +33 — the applied - // capacity must win. + // 20 kWh delivered on a 40 kWh car = +45 points after charging losses + // from the 30 % anchor. With the configured 60 kWh it would only be +33. m.Observe("a", true, 7000, 20000, true) st, _ = m.State("a") if st.CurrentSoC < 0.749 || st.CurrentSoC > 0.751 { - t.Fatalf("SoC should follow the applied 40 kWh capacity, got %v", st.CurrentSoC) + t.Fatalf("SoC should follow the car's 40 kWh capacity, got %v", st.CurrentSoC) } - // Hot-reload mid-session: the identified car survives. + // Hot-reload mid-session: the car survives. m.Load([]Config{{ID: "a", VehicleCapacityWh: 60000, PluginSoC: 0.30}}) - st, _ = m.State("a") - if st.VehicleName != "Leaf" { - t.Fatalf("vehicle should survive config reload, got %+v", st) + if st, _ = m.State("a"); st.VehicleName != "Leaf" || st.VehicleCapacityWh != 40000 { + t.Fatalf("car should survive config reload, got %+v", st) } - // Plug-out reverts to the loadpoint's own capacity. + // Plug-out leaves the car the charger last had; the charger shows no + // session car until the next plug-in. m.Observe("a", false, 0, 0, false) - st, _ = m.State("a") - if st.VehicleName != "" { - t.Fatalf("vehicle should clear on plug-out: %+v", st) + if st, _ = m.State("a"); st.CarID != "" || st.VehicleCapacityWh != 40000 { + t.Fatalf("after plug-out: %+v", st) + } +} + +// A visitor's session has no level: the loadpoint counts only what it +// delivers and says it does not know the car's. +func TestAVisitorsSessionHasNoLevel(t *testing.T) { + m := NewManager() + m.Load([]Config{{ID: "a", VehicleCapacityWh: 60000, PluginSoC: 0.30}}) + m.Observe("a", true, 7000, 0, true) + m.SetCarBinding("a", CarBinding{IdentifiedBy: "none"}) + if !m.SeedSessionLevel("a", LevelSeed{}) { + t.Fatal("seed") + } + st, _ := m.State("a") + if !st.SoCUnknown || st.SoCSource != "unknown" || st.CurrentSoC != 0 || st.VehicleCapacityWh != 60000 { + t.Fatalf("visitor %+v", st) + } + // Someone who knows sets it; then it is known. + if !m.SetCurrentSoC("a", .4) { + t.Fatal("set") } - cfgs := m.Configs() - if len(cfgs) != 1 || cfgs[0].VehicleCapacityWh != 60000 { - t.Fatalf("capacity should restore to the configured 60000, got %+v", cfgs) + if st, _ = m.State("a"); st.SoCUnknown || st.CurrentSoC != .4 { + t.Fatalf("after a level was set %+v", st) } } diff --git a/go/internal/notifications/catalogue.go b/go/internal/notifications/catalogue.go index 8faa90d28..8a6dd69ea 100644 --- a/go/internal/notifications/catalogue.go +++ b/go/internal/notifications/catalogue.go @@ -26,6 +26,7 @@ const ( PushApprovalKeySet = protocol.PushApprovalKeySet PushOwnerAdded = protocol.PushOwnerAdded PushCloudRevoked = protocol.PushCloudRevoked + PushCarWhich = protocol.PushCarWhich ) // PushSentence is one lock-screen sentence, exactly as the app wrote it. diff --git a/go/internal/notifications/service.go b/go/internal/notifications/service.go index b88ea54da..73a443ce9 100644 --- a/go/internal/notifications/service.go +++ b/go/internal/notifications/service.go @@ -120,6 +120,10 @@ func DefaultRules() []config.NotificationRule { {Type: PushAgentExpiring, Enabled: true, Priority: 3}, {Type: PushApprovalKeySet, Enabled: true, Priority: 3}, {Type: PushOwnerAdded, Enabled: true, Priority: 3}, + // Which car is plugged in. On by default: with several cars and no + // answer from the charger or the car, the box plans for a visitor + // until someone taps, and only this asks. Once per plug-in. + {Type: PushCarWhich, Enabled: true, Priority: 3}, } } @@ -135,7 +139,7 @@ func KnownRuleTypes() []string { PushChargingConnected, PushChargingSessionComplete, PushChargingInterrupted, PushChargingGoalAtRisk, PushUpdateInstalled, PushDriverOffline, PushFuseOverLimit, PushCloudGranted, PushCloudRevoked, PushAgentAdded, PushAgentRequest, - PushAgentExpiring, PushApprovalKeySet, PushOwnerAdded, + PushAgentExpiring, PushApprovalKeySet, PushOwnerAdded, PushCarWhich, } } @@ -442,6 +446,14 @@ func (s *Service) Subscribe(bus *events.Bus) { go s.handleCatalogued(PushChargingSessionComplete, ev.LoadpointID, map[string]string{"kwh": strconv.FormatFloat(ev.KWh, 'f', 1, 64)}) }) + bus.Subscribe(events.KindCarWhich, func(e events.Event) { + if ev, ok := e.(events.CarWhich); ok { + // The session names the plug-in the tap answers. The cars go + // with the event to FTW Cloud once the uplink cuts their + // opaque ids (docs/cars.md). + go s.handleCatalogued(PushCarWhich, ev.LoadpointID, map[string]string{"session": ev.Session}) + } + }) bus.Subscribe(events.KindChargingInterrupted, func(e events.Event) { ev, ok := e.(events.ChargingInterrupted) if !ok { @@ -501,7 +513,8 @@ func (s *Service) handleCatalogued(kind, device string, args map[string]string) return } loadpointID := "" - if strings.HasPrefix(kind, "charging.") { + if strings.HasPrefix(kind, "charging.") || kind == PushCarWhich { + // car.which names the charger whose car it asks about. loadpointID = device } s.deliver(kind, device, Message{ diff --git a/go/internal/notifications/service_test.go b/go/internal/notifications/service_test.go index 829a71f71..ba0b61236 100644 --- a/go/internal/notifications/service_test.go +++ b/go/internal/notifications/service_test.go @@ -824,3 +824,22 @@ func TestCatalogueFuseOverLimitRendersFromCatalogue(t *testing.T) { t.Fatalf("body = %q", msgs[0].Body) } } + +// car.which names the charger it asks about, for FTW Cloud's event and the +// web push alike, and the plug-in's session for the tap. +func TestCarWhichNamesItsCharger(t *testing.T) { + pub := &fakePub{published: make(chan struct{}, 1)} + svc, _ := newSvc(pushCfg(config.NotificationRule{Type: PushCarWhich, Enabled: true, Priority: 3}), pub) + bus := events.NewBus() + svc.Subscribe(bus) + bus.Publish(events.CarWhich{LoadpointID: "easee", Session: "7d1f0c2a9b3e4f50", CarIDs: []string{"a", "b"}, At: time.Now()}) + select { + case <-pub.published: + case <-time.After(2 * time.Second): + t.Fatal("no push") + } + msgs := pub.Messages() + if len(msgs) != 1 || msgs[0].LoadpointID != "easee" || msgs[0].Args["session"] != "7d1f0c2a9b3e4f50" { + t.Fatalf("car.which %+v", msgs) + } +} diff --git a/go/internal/ocpp/handlers.go b/go/internal/ocpp/handlers.go index 2d1fcdcd4..c06dd4435 100644 --- a/go/internal/ocpp/handlers.go +++ b/go/internal/ocpp/handlers.go @@ -274,10 +274,12 @@ func (h *Handler) probeFinished(id string) { } // noteVehicleID records the identity a transaction presented and fires the -// vehicleIdentified callback when it is new for this charger. Quarantine -// applies: a pending charger's identity is stored (the UI shows it so the -// operator can build a profile from it) but never fires the callback. -func (h *Handler) noteVehicleID(id, vehicleID, source string) { +// vehicleIdentified callback when it is new for this charger, and at the +// start of every transaction: the same card on the next plug-in names the +// car of that plug-in (docs/cars.md). Quarantine applies: a pending +// charger's identity is stored (the UI shows it so the operator can build a +// profile from it) but never fires the callback. +func (h *Handler) noteVehicleID(id, vehicleID, source string, started bool) { if vehicleID == "" { return } @@ -289,7 +291,7 @@ func (h *Handler) noteVehicleID(id, vehicleID, source string) { fn := h.vehicleIdentified approved := h.approved[id] h.mu.Unlock() - if changed && approved && fn != nil { + if (changed || started) && approved && fn != nil { fn(id, vehicleID, source) } } @@ -595,7 +597,7 @@ func (h *Handler) OnStartTransaction(id string, req *core.StartTransactionReques "charger", id, "txid", txID, "tag", req.IdTag, "meter_start_wh", req.MeterStart) // The 1.6 idTag names the RFID card that started the session — the // closest thing to a vehicle identity this dialect has. - h.noteVehicleID(id, req.IdTag, "rfid") + h.noteVehicleID(id, req.IdTag, "rfid", true) h.pushReading(id, s) h.telSuccess(id) return core.NewStartTransactionConfirmation( diff --git a/go/internal/ocpp/handlers_v201.go b/go/internal/ocpp/handlers_v201.go index 9020eaaa6..3e1b1009f 100644 --- a/go/internal/ocpp/handlers_v201.go +++ b/go/internal/ocpp/handlers_v201.go @@ -194,7 +194,8 @@ func (h *handlerV201) OnTransactionEvent(id string, req *transactions.Transactio // eMAID is ISO 15118 Plug & Charge. Cards (ISO14443/ISO15693) still // only name the card. The token may ride on any event type. if req.IDToken != nil { - h.noteVehicleID(id, req.IDToken.IdToken, string(req.IDToken.Type)) + h.noteVehicleID(id, req.IDToken.IdToken, string(req.IDToken.Type), + req.EventType == transactions.TransactionEventStarted) } h.pushReading(id, s) if ended { diff --git a/go/internal/telemetry/vehicle.go b/go/internal/telemetry/vehicle.go index 2ca5910f1..edc009c9b 100644 --- a/go/internal/telemetry/vehicle.go +++ b/go/internal/telemetry/vehicle.go @@ -3,39 +3,19 @@ package telemetry import ( "encoding/json" "math" + "sort" "time" "github.com/srcfl/ftw/go/internal/units" ) -// VehicleMaxAge is the freshness window past which a DerVehicle reading -// is considered stale enough to ignore for control decisions. Picked -// conservatively at 5 min so a vehicle driver that has lost contact -// (asleep car, paired-proxy outage, cloud-API throttle) cannot keep an -// old SoC live as ground truth. Tighter than this would churn against -// vendors whose backends only refresh on a 60–120 s cadence; looser -// would mean acting on a value that no longer reflects reality. +// VehicleMaxAge is how long a car's charging state counts as live: what +// wakes the car and what tells the box its goal is done. Picked at 5 min so +// a vehicle driver that has lost contact (asleep car, paired-proxy outage, +// cloud-API throttle) cannot keep an old state live. It no longer ages the +// car's level: a level stays the car's until energy flows (docs/cars.md). const VehicleMaxAge = 5 * time.Minute -// VehicleAnchorMaxAge bounds how old a car reading may be and still anchor -// a loadpoint's SoC estimate. The loadpoint adds the energy delivered since -// the reading, so age adds only metering error. Cloud sources such as the -// VW Group portal report every 15 minutes. -const VehicleAnchorMaxAge = time.Hour - -// VehiclePick is the "best matching" DerVehicle reading for a loadpoint: -// the one most likely to be the car physically connected right now. -// Empty Driver means "no usable reading" — the caller should fall back -// to whatever inferred SoC was already in place. -type VehiclePick struct { - Driver string - SoC float64 // bounded [0,1] - ChargeLimit float64 // bounded [0,1] - ChargingState string - Stale bool // unsuitable as a live observation; may still anchor an estimate - UpdatedAt time.Time // wall-clock of the last fresh SoC observation -} - // VehicleConnectedRank scores how likely a DerVehicle driver is to be // the one physically plugged into the loadpoint right now, using the // charging_state vocabulary every vehicle driver normalizes to (the @@ -43,9 +23,8 @@ type VehiclePick struct { // translated inside each Lua driver). Higher rank = more likely // connected. Negative = explicitly not connected; caller should skip. // -// Single source of truth for the rank table — both main.go (MPC plan -// inputs) and api.go (loadpoint decoration) call this so multi-vehicle -// pick decisions stay consistent. +// The cars runtime reads a rank of 1 or more as the car's word that a cable +// is in, and 3 as the car charging. func VehicleConnectedRank(chargingState string) int { switch chargingState { case "Charging", "Starting": @@ -61,146 +40,85 @@ func VehicleConnectedRank(chargingState string) int { } } -// PickBestVehicle scans the store for the single DerVehicle reading -// most likely to be the car connected right now: highest -// VehicleConnectedRank, tiebreak by freshness. Returns a zero-value -// VehiclePick if no usable reading exists. -// -// Defenses applied here (do NOT skip — every vehicle driver pulls -// from a network trust boundary, whether a local BLE proxy, an -// in-LAN OEM gateway, or a cloud API): -// - SoC bounded to [0,1] — a misbehaving driver reporting 2.0 or -// -0.5 must not be able to overcharge or freeze EV charging. -// - ChargeLimit bounded to [0,1] — same risk. -// - Stale by `now − SoCUpdatedAt > VehicleMaxAge` — wallclock check on -// the last fresh SoC observation, even when newer power or metadata -// updates carry the last-known value forward. A driver that stops -// publishing SoC mustn't keep it live forever. -// - Explicit `stale=true` — a driver that knows its upstream value is stale -// can stop control from using it before the wallclock limit. -// - Driver health-online check — offline drivers contribute nothing. -// -// Lives in telemetry/ rather than api/ or cmd/ because both packages -// need it and the dependency direction otherwise cycles. -func PickBestVehicle(s *Store, now time.Time) VehiclePick { - return pickBestVehicle(s, 0, now, VehicleMaxAge, vehiclePickLive) -} - -// PickBestVehicleForLoadpoint adds connection-evidence gating: when -// the loadpoint is delivering power right now (current_power_w over -// the threshold), the picker requires the vehicle's charging_state -// to be Charging or Starting (rank 3). Any other state — including -// Stopped/Complete on a vehicle parked elsewhere — is rejected, so -// a second car returning SoC from outside this charger cannot win -// the pick on freshness alone. -// -// When the loadpoint is plugged but idle (no current draw), gating -// falls back to the standard rank-based pick. We don't have strong -// evidence which car is connected during idle, but the planner is -// also not actively committing power, so a wrong pick during this -// window is much lower-impact than during active delivery. -func PickBestVehicleForLoadpoint(s *Store, lpDeliveringPower bool, now time.Time) VehiclePick { - minRank := 0 // any non-Disconnected - if lpDeliveringPower { - // Strict: only Charging/Starting count as evidence the vehicle - // is on this charger. A vehicle reporting Stopped while another - // loadpoint is at 11 kW is definitely not the connected one. - minRank = 3 +// Cached car reports can arrive before any fresh report after a restart. +// Store.SoC intentionally retains only control-trusted observations. Read the +// latest raw report with its explicit cache flag and measured SoC age. It may +// be displayed or anchored in a session with known metering history, but must +// never become a live observation or confirm completion. Never synthesize a +// fresh receipt time. +func cachedVehicleObservation(s *Store, vr *DerReading) (float64, time.Time, bool) { + var report struct { + SoC *float64 `json:"soc"` + SoCFresh *bool `json:"soc_fresh"` } - return pickBestVehicle(s, minRank, now, VehicleMaxAge, vehiclePickLive) -} - -// PickVehicleForAnchor applies the loadpoint gates with VehicleAnchorMaxAge. -// A cached cloud report with a measured age may also supply a historical -// anchor. Its SoC is a past observation: anchor it at UpdatedAt, never as now. -// The loadpoint must still know the session's energy at that time. -func PickVehicleForAnchor(s *Store, lpDeliveringPower bool, now time.Time) VehiclePick { - minRank := 0 - if lpDeliveringPower { - minRank = 3 + if json.Unmarshal(vr.Data, &report) != nil || report.SoC == nil || + report.SoCFresh == nil || *report.SoCFresh { + return 0, time.Time{}, false } - return pickBestVehicle(s, minRank, now, VehicleAnchorMaxAge, vehiclePickAnchor) -} - -// PlanningVehiclePick is the car reading an EV plan may start from. -// A live reading wins. Otherwise a reading inside VehicleAnchorMaxAge is -// returned and marked stale, including a cached cloud report and a -// driver-stale replay of a measurement that is still inside that hour. -// An empty Driver means the plan keeps the session estimate. Callers must -// ignore ChargeLimit when Stale is set: a stale limit is not a live cap. -func PlanningVehiclePick(s *Store, lpDeliveringPower bool, now time.Time) VehiclePick { - if live := PickBestVehicleForLoadpoint(s, lpDeliveringPower, now); live.Driver != "" { - return live + raw := *report.SoC + if math.IsNaN(raw) || math.IsInf(raw, 0) || raw < 0 || raw > 100 { + return 0, time.Time{}, false } - return PickVehicleForAnchor(s, lpDeliveringPower, now) -} - -// PickBestVehicleForDisplay retains an otherwise valid last-known vehicle -// observation after the planning window. The returned pick is marked stale -// so presentation can show provenance and age. Planning stops at -// VehicleAnchorMaxAge; completion and live control stop at VehicleMaxAge. -func PickBestVehicleForDisplay(s *Store, lpDeliveringPower bool, now time.Time) VehiclePick { - minRank := 0 - if lpDeliveringPower { - minRank = 3 + age, receivedAt, ok := s.LatestMetric(vr.Driver, "vehicle_soc_age_s") + if !ok || receivedAt.IsZero() || math.IsNaN(age) || math.IsInf(age, 0) || + age < 0 || age > (100*365*24*time.Hour).Seconds() { + return 0, time.Time{}, false } - return pickBestVehicle(s, minRank, now, VehicleMaxAge, vehiclePickDisplay) + return units.DecodeJSONFraction(0, raw), receivedAt.Add(-time.Duration(age * float64(time.Second))), true } -// PickVehicleForCompletion requires one vehicle source. Rank and freshness -// cannot bind one of several cars to a charger or prove that its goal is done. -// Callers must also check that only one loadpoint is connected. -func PickVehicleForCompletion(s *Store, now time.Time) VehiclePick { - if s == nil || len(s.ReadingsByType(DerVehicle)) != 1 { - return VehiclePick{} - } - return pickBestVehicle(s, 1, now, VehicleMaxAge, vehiclePickLive) +// VehicleObservation is one vehicle driver's newest level report. The cars +// runtime binds each driver to a car (docs/cars.md), so nothing here ranks +// one driver against another or ages a reading out: a level with no energy +// delivered since stays the car's level however old it is. +type VehicleObservation struct { + Driver string + SoC float64 // bounded [0,1] + ChargeLimit float64 // bounded [0,1], 0 when unknown + ChargingState string // the canonical vocabulary of VehicleConnectedRank + At time.Time // when the car measured SoC + // Stale is the driver's own stale flag, or a cached replay. Such a + // level may anchor an estimate, but its charging state is not live. + Stale bool + // Plugged and AtSite are the car's own word on its cable and place, + // when its driver gives them. Nil is unknown. + Plugged *bool + AtSite *bool + // ReportedAt is when the driver last reported anything, which can be + // later than At: a cable changes without a new level. + ReportedAt time.Time } -type vehiclePickUse uint8 - -const ( - vehiclePickLive vehiclePickUse = iota - vehiclePickAnchor - vehiclePickDisplay -) +// Live reports whether the charging state is current enough to act on: +// not stale, and measured within VehicleMaxAge. +func (o VehicleObservation) Live(now time.Time) bool { + age := now.Sub(o.At) + return !o.Stale && age >= 0 && age <= VehicleMaxAge +} -func pickBestVehicle(s *Store, minRank int, now time.Time, maxAge time.Duration, use vehiclePickUse) VehiclePick { +// VehicleObservations lists the newest level report of every online vehicle +// driver, sorted by driver name. Every vehicle driver pulls from a network +// trust boundary, so the bounds hold here: SoC and charge limit within +// [0,1] (the store refuses an SoC outside it), an offline driver contributes +// nothing, and a carried value keeps the time the car measured it. A cached +// cloud report, with its measured age, stands in when the driver has no +// fresher level; it is marked stale. +func VehicleObservations(s *Store) []VehicleObservation { if s == nil { - return VehiclePick{} + return nil } - var best VehiclePick - bestRank := -1 - allowAgeStale := use == vehiclePickDisplay + var out []VehicleObservation for _, vr := range s.ReadingsByType(DerVehicle) { - socValue := vr.SoC - socUpdatedAt := vr.SoCUpdatedAt - cachedObservation := false - if allowAgeStale || use == vehiclePickAnchor { - if value, observedAt, ok := cachedVehicleObservation(s, vr); ok && - (allowAgeStale || socUpdatedAt.IsZero() || !observedAt.Before(socUpdatedAt)) { - socValue = &value - socUpdatedAt = observedAt - cachedObservation = true - } - } - if socValue == nil { - continue - } if h := s.DriverHealth(vr.Driver); h == nil || !h.IsOnline() { continue } - if socUpdatedAt.IsZero() { - continue - } - age := now.Sub(socUpdatedAt) - if age < 0 { - continue + socValue, at := vr.SoC, vr.SoCUpdatedAt + cached := false + if value, observedAt, ok := cachedVehicleObservation(s, vr); ok && + (socValue == nil || at.IsZero() || !observedAt.Before(at)) { + socValue, at, cached = &value, observedAt, true } - if age > maxAge && !allowAgeStale { - // Reading is older than we're willing to trust as ground - // truth — driver probably stopped publishing. Skip rather - // than risk acting on a stale SoC. + if socValue == nil || at.IsZero() { continue } var meta struct { @@ -208,63 +126,24 @@ func pickBestVehicle(s *Store, minRank int, now time.Time, maxAge time.Duration, ChargeLimit float64 `json:"charge_limit"` ChargeLimitPct float64 `json:"charge_limit_pct"` Stale bool `json:"stale"` + Plugged *bool `json:"plugged"` + AtSite *bool `json:"at_site"` } if len(vr.Data) > 0 { _ = json.Unmarshal(vr.Data, &meta) } - // A driver stale flag keeps the value out of live control and - // completion. Inside the anchor window it is still the car's - // measured SoC — cloud drivers such as vag_vehicle mark a reading - // stale once it leaves their own fresh window, which is well - // inside VehicleAnchorMaxAge. Age, not the flag, bounds planning. - if meta.Stale && use == vehiclePickLive { - continue - } - rank := VehicleConnectedRank(meta.ChargingState) - if rank < 0 || rank < minRank { - continue - } - if rank < bestRank || (rank == bestRank && !socUpdatedAt.After(best.UpdatedAt)) { - continue - } - soc := units.ClampFraction(*socValue) - limit := units.ClampFraction(units.DecodeJSONFraction(meta.ChargeLimit, meta.ChargeLimitPct)) - best = VehiclePick{ + out = append(out, VehicleObservation{ Driver: vr.Driver, - SoC: soc, - ChargeLimit: limit, + SoC: units.ClampFraction(*socValue), + ChargeLimit: units.ClampFraction(units.DecodeJSONFraction(meta.ChargeLimit, meta.ChargeLimitPct)), ChargingState: meta.ChargingState, - Stale: age > VehicleMaxAge || meta.Stale || cachedObservation, - UpdatedAt: socUpdatedAt, - } - bestRank = rank - } - return best -} - -// Cached car reports can arrive before any fresh report after a restart. -// Store.SoC intentionally retains only control-trusted observations. Read the -// latest raw report with its explicit cache flag and measured SoC age. It may -// be displayed or anchored in a session with known metering history, but must -// never become a live observation or confirm completion. Never synthesize a -// fresh receipt time. -func cachedVehicleObservation(s *Store, vr *DerReading) (float64, time.Time, bool) { - var report struct { - SoC *float64 `json:"soc"` - SoCFresh *bool `json:"soc_fresh"` - } - if json.Unmarshal(vr.Data, &report) != nil || report.SoC == nil || - report.SoCFresh == nil || *report.SoCFresh { - return 0, time.Time{}, false + At: at, + Stale: meta.Stale || cached, + Plugged: meta.Plugged, + AtSite: meta.AtSite, + ReportedAt: vr.UpdatedAt, + }) } - raw := *report.SoC - if math.IsNaN(raw) || math.IsInf(raw, 0) || raw < 0 || raw > 100 { - return 0, time.Time{}, false - } - age, receivedAt, ok := s.LatestMetric(vr.Driver, "vehicle_soc_age_s") - if !ok || receivedAt.IsZero() || math.IsNaN(age) || math.IsInf(age, 0) || - age < 0 || age > (100*365*24*time.Hour).Seconds() { - return 0, time.Time{}, false - } - return units.DecodeJSONFraction(0, raw), receivedAt.Add(-time.Duration(age * float64(time.Second))), true + sort.Slice(out, func(i, j int) bool { return out[i].Driver < out[j].Driver }) + return out } diff --git a/go/internal/telemetry/vehicle_source_time_test.go b/go/internal/telemetry/vehicle_source_time_test.go index 371186ccc..0d4bc323c 100644 --- a/go/internal/telemetry/vehicle_source_time_test.go +++ b/go/internal/telemetry/vehicle_source_time_test.go @@ -19,25 +19,33 @@ func TestVehicleSourceTimeControlsFreshnessAndFullCarRecovery(t *testing.T) { if got := s.Get("tesla", DerVehicle); !got.SoCUpdatedAt.Equal(time.UnixMilli(old.UnixMilli())) { t.Fatalf("source time replaced: %v", got.SoCUpdatedAt) } - if pick := PickBestVehicle(s, now); pick.Driver != "" { - t.Fatal("old first response became planning truth") + live := func() (VehicleObservation, bool) { + for _, o := range VehicleObservations(s) { + if o.Driver == "tesla" && o.Live(now) { + return o, true + } + } + return VehicleObservation{}, false + } + if _, ok := live(); ok { + t.Fatal("an old first response became live") } emit(old, 0.5) - if pick := PickBestVehicle(s, now); pick.Driver != "" { + if _, ok := live(); ok { t.Fatal("cached response renewed observation") } s.FlushSamples() fresh := now.Add(-time.Second) emit(fresh, 1) - pick := PickBestVehicle(s, now) - if pick.Driver != "tesla" || pick.SoC != 1 { - t.Fatalf("full car did not replace inferred demand: %+v", pick) + o, ok := live() + if !ok || o.SoC != 1 { + t.Fatalf("full car did not replace inferred demand: %+v", o) } if samples := s.FlushSamples(); len(samples) != 2 { t.Fatalf("fresh source not stored: %+v", samples) } emit(fresh, 0.1) - if got := s.Get("tesla", DerVehicle); *got.SoC != 1 || !got.SoCUpdatedAt.Equal(pick.UpdatedAt) { + if got := s.Get("tesla", DerVehicle); *got.SoC != 1 || !got.SoCUpdatedAt.Equal(o.At) { t.Fatal("identical source timestamp replaced full-car reading") } } diff --git a/go/internal/telemetry/vehicle_test.go b/go/internal/telemetry/vehicle_test.go index 8277396ec..48848b09b 100644 --- a/go/internal/telemetry/vehicle_test.go +++ b/go/internal/telemetry/vehicle_test.go @@ -6,9 +6,15 @@ import ( "time" ) -// PickBestVehicle is the trust boundary between BLE-proxy telemetry -// (potentially attacker-controlled) and the loadpoint controller / MPC. -// These tests lock in the bounds, freshness check, and rank ordering. +// VehicleObservations is the trust boundary between vehicle telemetry +// (potentially attacker-controlled) and the cars runtime, which binds each +// driver to one car (docs/cars.md). These tests lock in the bounds, the +// health check and the times a report carries. +// +// Before cars the pickers here ranked every vehicle driver against every +// charger, and a second car's fresher report could win. Which car a report +// belongs to is now the cars runtime's binding; its tests hold that a car +// somewhere else never names this charger's car. func TestVehicleConnectedRankOrdering(t *testing.T) { if VehicleConnectedRank("Charging") <= VehicleConnectedRank("NoPower") { @@ -21,35 +27,15 @@ func TestVehicleConnectedRankOrdering(t *testing.T) { t.Error("Stopped must outrank unknown") } if VehicleConnectedRank("Disconnected") >= 0 { - t.Error("Disconnected must score negative — never picked") + t.Error("Disconnected must score negative — no cable") } if VehicleConnectedRank("Starting") != VehicleConnectedRank("Charging") { t.Error("Starting and Charging share top rank — vehicle is engaged") } } -func TestVehicleCompletionRequiresUnambiguousConnectedSource(t *testing.T) { - s := NewStore() - pushVehicle(t, s, "one", .8, .8, "Complete", false, 0) - if got := PickVehicleForCompletion(s, time.Now()); got.Driver != "one" { - t.Fatal(got) - } - pushVehicle(t, s, "two", .5, .8, "Charging", false, 0) - if got := PickVehicleForCompletion(s, time.Now()); got.Driver != "" { - t.Fatal("rank is not a car-to-charger binding", got) - } - for _, state := range []string{"", "Disconnected"} { - s = NewStore() - pushVehicle(t, s, "one", .8, .8, state, false, 0) - if got := PickVehicleForCompletion(s, time.Now()); got.Driver != "" { - t.Fatal("missing connection proof", got) - } - } -} - // pushVehicle publishes a DerVehicle reading. soc and limit are 0–1 -// fractions (core SI). charge_limit_pct in the driver blob is the -// legacy vendor door and is converted at PickBestVehicle. +// fractions (core SI). age moves the reading and its SoC observation back. func pushVehicle(t *testing.T, s *Store, driver string, soc, limit float64, state string, stale bool, age time.Duration) { t.Helper() @@ -60,10 +46,6 @@ func pushVehicle(t *testing.T, s *Store, driver string, soc, limit float64, "stale": stale, }) s.Update(driver, DerVehicle, 0, &stored, data) - // Mark health online + age the reading and SoC observation by - // reaching into the store directly; a follow-up Update with a - // known timestamp would be invasive; instead the test passes - // `age` by comparing relative to (now - age) inside the helper. if age > 0 { s.mu.Lock() if r := s.readings[key(driver, DerVehicle)]; r != nil { @@ -76,222 +58,113 @@ func pushVehicle(t *testing.T, s *Store, driver string, soc, limit float64, s.DriverHealthMut(driver).RecordSuccess() } -func TestPickBestVehicleHonoursRank(t *testing.T) { - s := NewStore() - pushVehicle(t, s, "garage", 0.50, 0.80, "Stopped", false, 0) - pushVehicle(t, s, "driveway", 0.30, 0.80, "Charging", false, 0) - pick := PickBestVehicle(s, time.Now()) - if pick.Driver != "driveway" { - t.Errorf("expected Charging vehicle to win, got %q", pick.Driver) +func observation(t *testing.T, s *Store, driver string) (VehicleObservation, bool) { + t.Helper() + for _, o := range VehicleObservations(s) { + if o.Driver == driver { + return o, true + } } + return VehicleObservation{}, false } -func TestPickBestVehicleSkipsDisconnected(t *testing.T) { +// Two cars, two drivers: each report is listed under its own driver with +// its own state, and nothing chooses between them. A Disconnected car is +// listed too: the cars runtime reads it as no cable. +func TestVehicleObservationsListEveryDriverAndRankNone(t *testing.T) { s := NewStore() - pushVehicle(t, s, "garage", 0.50, 0.80, "Disconnected", false, 0) - pick := PickBestVehicle(s, time.Now()) - if pick.Driver != "" { - t.Errorf("Disconnected reading must not be picked, got %q", pick.Driver) + pushVehicle(t, s, "garage", 0.50, 0.80, "Stopped", false, 0) + pushVehicle(t, s, "driveway", 0.30, 0.80, "Charging", false, 0) + pushVehicle(t, s, "away", 0.70, 0.80, "Disconnected", false, 0) + got := VehicleObservations(s) + if len(got) != 3 || got[0].Driver != "away" || got[1].Driver != "driveway" || got[2].Driver != "garage" { + t.Fatalf("observations %+v", got) + } + if got[0].ChargingState != "Disconnected" || got[1].SoC != .30 || got[2].ChargeLimit != .80 { + t.Fatalf("fields %+v", got) + } + if VehicleObservations(nil) != nil { + t.Fatal("nil store") } } -func TestPickBestVehicleDropsInvalidSoC(t *testing.T) { +func TestVehicleObservationsDropInvalidSoC(t *testing.T) { s := NewStore() - // Store.Update validates 0–1 before the row exists, so a lying - // proxy cannot park 2.5 in core for the picker to paper over. + // Store.Update validates 0–1 before the row exists, so a lying proxy + // cannot park 2.5 in core. pushVehicle(t, s, "rogue", 2.50, 0.80, "Charging", false, 0) - if pick := PickBestVehicle(s, time.Now()); pick.Driver != "" { - t.Errorf("invalid SoC must not be stored, got %+v", pick) - } - - s2 := NewStore() - pushVehicle(t, s2, "rogue2", -0.50, 0.80, "Charging", false, 0) - if pick := PickBestVehicle(s2, time.Now()); pick.Driver != "" { - t.Errorf("negative SoC must not be stored, got %+v", pick) + pushVehicle(t, s, "rogue2", -0.50, 0.80, "Charging", false, 0) + if got := VehicleObservations(s); len(got) != 0 { + t.Errorf("invalid SoC must not be listed, got %+v", got) } } -func TestPickBestVehicleClampsLegacyChargeLimit(t *testing.T) { +func TestVehicleObservationsClampLegacyChargeLimit(t *testing.T) { s := NewStore() soc := 0.50 - data, _ := json.Marshal(map[string]any{ - "charging_state": "Charging", - "charge_limit_pct": 200, - "stale": false, - }) + data, _ := json.Marshal(map[string]any{"charging_state": "Charging", "charge_limit_pct": 200, "stale": false}) s.Update("rogue", DerVehicle, 0, &soc, data) s.DriverHealthMut("rogue").RecordSuccess() - pick := PickBestVehicle(s, time.Now()) - if pick.Driver != "rogue" { - t.Fatalf("expected pick, got %+v", pick) - } - if pick.ChargeLimit != 1 { - t.Errorf("legacy 200%% charge limit = %v, want clamped to 1", pick.ChargeLimit) + o, ok := observation(t, s, "rogue") + if !ok || o.ChargeLimit != 1 { + t.Errorf("legacy 200%% charge limit = %+v, want clamped to 1", o) } } -func TestPickBestVehicleSkipsStaleByWallclock(t *testing.T) { +// A report ten minutes old keeps its level and its time: whether the level +// is still exact is the energy rule's to say. Its charging state is not +// live, so it neither wakes the car nor finishes a goal. +func TestAnOldReportKeepsItsTimeAndIsNotLive(t *testing.T) { s := NewStore() - // Reading is 10 min old, well past VehicleMaxAge. Even though - // the proxy didn't set the `stale` flag, freshness is decided - // by wallclock — a proxy that stops talking can't keep the - // last-known SoC live forever. pushVehicle(t, s, "asleep", 0.50, 0.80, "Charging", false, 10*time.Minute) - pick := PickBestVehicle(s, time.Now()) - if pick.Driver != "" { - t.Errorf("stale-by-wallclock reading must not be picked, got %q", pick.Driver) + o, ok := observation(t, s, "asleep") + if !ok || o.SoC != .50 || time.Since(o.At) < 10*time.Minute || o.Live(time.Now()) { + t.Fatalf("old report %+v", o) } } -func TestPickBestVehicleDoesNotFreshenCarriedSoC(t *testing.T) { +func TestACarriedSoCIsNotFreshened(t *testing.T) { s := NewStore() pushVehicle(t, s, "asleep", 0.50, 0.80, "Charging", false, 0) staleAt := time.Now().Add(-VehicleMaxAge - time.Minute) s.mu.Lock() s.readings[key("asleep", DerVehicle)].SoCUpdatedAt = staleAt s.mu.Unlock() - - data, err := json.Marshal(map[string]any{ - "charging_state": "Charging", - "charge_limit": 0.80, - "soc_fresh": false, - }) - if err != nil { - t.Fatal(err) - } + data, _ := json.Marshal(map[string]any{"charging_state": "Charging", "charge_limit": 0.80, "soc_fresh": false}) cachedSoC := 0.50 s.Update("asleep", DerVehicle, 0, &cachedSoC, data) r := s.Get("asleep", DerVehicle) - if r == nil || !r.SoCUpdatedAt.Equal(staleAt) { + if r == nil || !r.SoCUpdatedAt.Equal(staleAt) || !r.UpdatedAt.After(staleAt) { t.Fatalf("power-only update changed SoC source time: %+v", r) } - if !r.UpdatedAt.After(staleAt) { - t.Fatalf("reading timestamp did not advance: %+v", r) - } - if pick := PickBestVehicle(s, time.Now()); pick.Driver != "" { - t.Fatalf("carried stale SoC must not stay selectable, got %+v", pick) + o, ok := observation(t, s, "asleep") + if !ok || !o.At.Equal(staleAt) || !o.ReportedAt.After(staleAt) || o.Live(time.Now()) { + t.Fatalf("carried SoC was freshened: %+v", o) } } -func TestPickBestVehicleSkipsDriverMarkedStale(t *testing.T) { +func TestADriverMarkedStaleIsNeverLive(t *testing.T) { s := NewStore() pushVehicle(t, s, "driver-stale", 0.50, 0.80, "Charging", true, 0) - if pick := PickBestVehicle(s, time.Now()); pick.Driver != "" { - t.Fatalf("driver-marked stale SoC must not be selectable, got %+v", pick) - } -} - -func TestPickBestVehicleNilStoreSafe(t *testing.T) { - pick := PickBestVehicle(nil, time.Now()) - if pick.Driver != "" { - t.Errorf("nil store must return zero-value pick, got %+v", pick) - } -} - -func TestPickBestVehicleTiebreakByFreshness(t *testing.T) { - s := NewStore() - pushVehicle(t, s, "a", 0.40, 0.80, "Charging", false, 60*time.Second) - pushVehicle(t, s, "b", 0.60, 0.80, "Charging", false, 5*time.Second) - pick := PickBestVehicle(s, time.Now()) - if pick.Driver != "b" { - t.Errorf("fresher reading should win tiebreak, got %q (soc=%v)", pick.Driver, pick.SoC) + o, ok := observation(t, s, "driver-stale") + if !ok || !o.Stale || o.Live(time.Now()) { + t.Fatalf("driver-marked stale report: %+v", o) } } -// Connection-evidence gate: when the loadpoint is actively delivering -// power, only vehicles in Charging/Starting are accepted as the -// connected one. A second car at home reporting Stopped (parked, -// charge-limit reached on a previous session, etc.) must NOT win the -// pick — that's the two-Tesla flap behaviour the gate exists to -// prevent. -func TestPickBestVehicleForLoadpointGatesByDeliveringPower(t *testing.T) { +func TestAnOfflineDriverContributesNothing(t *testing.T) { s := NewStore() - // Tesla #1: actively charging on this loadpoint. - pushVehicle(t, s, "tesla-charging", 0.50, 0.80, "Charging", false, 0) - // Tesla #2: parked elsewhere, fresher reading but not charging. - pushVehicle(t, s, "tesla-parked", 0.60, 0.80, "Stopped", false, 0) - - // Loadpoint NOT delivering power → existing behaviour: rank-based - // pick still wins, charging > stopped. - pick := PickBestVehicleForLoadpoint(s, false, time.Now()) - if pick.Driver != "tesla-charging" { - t.Errorf("idle loadpoint: expected tesla-charging, got %q", pick.Driver) - } - - // Loadpoint delivering power → strict gate, only Charging/Starting - // accepted. Same outcome here, but the test below demonstrates - // the gate excludes a parked-but-fresher vehicle. - pick = PickBestVehicleForLoadpoint(s, true, time.Now()) - if pick.Driver != "tesla-charging" { - t.Errorf("delivering loadpoint: expected tesla-charging, got %q", pick.Driver) + soc := .5 + s.Update("offline", DerVehicle, 0, &soc, json.RawMessage(`{"charging_state":"Stopped"}`)) + if got := VehicleObservations(s); len(got) != 0 { + t.Fatalf("offline driver listed: %+v", got) } } -func TestPickBestVehicleForLoadpointStrictExcludesStopped(t *testing.T) { - s := NewStore() - // Only a Stopped vehicle is reporting (e.g. parked Tesla in - // driveway), with no Charging-state counterpart. When the - // loadpoint is delivering power, the gate must reject — there's - // no evidence this Stopped car is the one connected. - pushVehicle(t, s, "tesla-parked", 0.60, 0.80, "Stopped", false, 0) - pick := PickBestVehicleForLoadpoint(s, true, time.Now()) - if pick.Driver != "" { - t.Errorf("delivering loadpoint with only Stopped vehicle: must return zero pick, got %q", pick.Driver) - } - // Without the gate (idle loadpoint), the Stopped car is a valid - // pick — ranks above Disconnected, can be the connected one. - pick = PickBestVehicleForLoadpoint(s, false, time.Now()) - if pick.Driver != "tesla-parked" { - t.Errorf("idle loadpoint: expected tesla-parked, got %q", pick.Driver) - } -} - -// Regression: two Charging readings at different freshness must still -// pick the freshest under the strict gate (rank parity falls back to -// freshness, exactly as before). -func TestPickBestVehicleForLoadpointStrictTiebreakByFreshness(t *testing.T) { - s := NewStore() - pushVehicle(t, s, "a", 0.40, 0.80, "Charging", false, 60*time.Second) - pushVehicle(t, s, "b", 0.60, 0.80, "Charging", false, 5*time.Second) - pick := PickBestVehicleForLoadpoint(s, true, time.Now()) - if pick.Driver != "b" { - t.Errorf("strict gate: fresher reading should win tiebreak, got %q", pick.Driver) - } -} - -func TestPickBestVehicleForDisplayRetainsOldObservation(t *testing.T) { - s := NewStore() - pushVehicle(t, s, "asleep", 0.50, 0.80, "Stopped", false, 12*time.Minute) - - if pick := PickBestVehicleForLoadpoint(s, false, time.Now()); pick.Driver != "" { - t.Fatalf("old SoC must stay unavailable to control, got %+v", pick) - } - - pick := PickBestVehicleForDisplay(s, false, time.Now()) - if pick.Driver != "asleep" { - t.Fatalf("display picker lost last-known car report: %+v", pick) - } - if !pick.Stale { - t.Fatalf("12-minute-old report must be marked stale: %+v", pick) - } - if pick.SoC != 0.50 { - t.Fatalf("display SoC=%v, want 0.50", pick.SoC) - } -} - -func TestPickBestVehicleForDisplayRetainsDriverMarkedStale(t *testing.T) { - s := NewStore() - pushVehicle(t, s, "invalid", 0.50, 0.80, "Stopped", true, 12*time.Minute) - - if pick := PickBestVehicleForDisplay(s, false, time.Now()); pick.Driver != "invalid" || !pick.Stale { - t.Fatalf("stale display: %+v", pick) - } - if pick := PickBestVehicleForLoadpoint(s, false, time.Now()); pick.Driver != "" { - t.Fatalf("stale control: %+v", pick) - } -} - -func TestCachedVehicleDisplayAfterRestartAndOlderPredecessor(t *testing.T) { +// After a restart a cloud driver replays its last report with its measured +// age. It is listed at the time the car measured it, never as now, and it is +// never live. +func TestACachedCloudReportKeepsTheTimeTheCarMeasuredIt(t *testing.T) { for _, predecessor := range []bool{false, true} { s := NewStore() if predecessor { @@ -302,80 +175,44 @@ func TestCachedVehicleDisplayAfterRestartAndOlderPredecessor(t *testing.T) { s.EmitMetric("audi-vag", "vehicle_soc_age_s", 156022, "s", "", "") s.DriverHealthMut("audi-vag").RecordSuccess() _, receivedAt, _ := s.LatestMetric("audi-vag", "vehicle_soc_age_s") - now := receivedAt.Add(2 * time.Minute) - got := PickBestVehicleForDisplay(s, false, now) - if got.Driver != "audi-vag" || got.SoC != .98 || !got.Stale || int64(now.Sub(got.UpdatedAt)/time.Second) != 156142 { - t.Fatalf("predecessor=%v: cached display = %+v", predecessor, got) - } - if got := PickBestVehicleForLoadpoint(s, false, now); got.Driver != "" { - t.Fatalf("cached report reached control: %+v", got) - } - if got := PickVehicleForCompletion(s, now); got.Driver != "" { - t.Fatalf("cached report completed goal: %+v", got) - } - if got := PickBestVehicleForDisplay(s, true, now); got.Driver != "" { - t.Fatalf("Stopped car matched charging loadpoint: %+v", got) + o, ok := observation(t, s, "audi-vag") + if !ok || o.SoC != .98 || !o.Stale || !o.At.Equal(receivedAt.Add(-156022*time.Second)) || o.Live(receivedAt) { + t.Fatalf("predecessor=%v: cached report %+v", predecessor, o) } } } -func TestCachedVehicleDisplayRequiresValidSourceAgeAndSoC(t *testing.T) { + +func TestACachedReportNeedsAValidAgeAndSoC(t *testing.T) { for _, report := range []string{`{"soc":98,"soc_fresh":false,"stale":true}`, `{"soc":101,"soc_fresh":false,"stale":true}`, `{"soc":98,"soc_fresh":"false","stale":true}`} { s := NewStore() soc := .98 s.Update("audi", DerVehicle, 0, &soc, json.RawMessage(report)) s.DriverHealthMut("audi").RecordSuccess() - if got := PickBestVehicleForDisplay(s, false, time.Now()); got.Driver != "" { - t.Fatalf("invalid report/unknown age: %+v", got) + if got := VehicleObservations(s); len(got) != 0 { + t.Fatalf("invalid report or unknown age: %+v", got) } if report != `{"soc":98,"soc_fresh":false,"stale":true}` { s.EmitMetric("audi", "vehicle_soc_age_s", 156022, "s", "", "") - if got := PickBestVehicleForDisplay(s, false, time.Now()); got.Driver != "" { + if got := VehicleObservations(s); len(got) != 0 { t.Fatalf("invalid raw report: %+v", got) } } } } -func TestPickVehicleForAnchorAcceptsCloudCadence(t *testing.T) { - s := NewStore() - pushVehicle(t, s, "cloud", 0.50, 0.80, "Stopped", false, 30*time.Minute) - - if pick := PickBestVehicleForLoadpoint(s, false, time.Now()); pick.Driver != "" { - t.Fatalf("30-minute-old SoC is not live: %+v", pick) - } - pick := PickVehicleForAnchor(s, false, time.Now()) - if pick.Driver != "cloud" || pick.SoC != 0.50 || !pick.Stale { - t.Fatalf("anchor pick: %+v", pick) - } - if age := time.Since(pick.UpdatedAt); age < 29*time.Minute { - t.Fatalf("anchor pick must keep the observation time, age %v", age) - } -} - -func TestPickVehicleForAnchorRejectsTooOldAndKeepsStaleOutOfLiveControl(t *testing.T) { +// Before cars a reading older than an hour could not anchor a level. Now a +// level is the car's last reading however old, and the energy rule and the +// car's unplugs say whether it is still exact. +func TestAnHourOldReportIsStillTheCarsLastReading(t *testing.T) { s := NewStore() - pushVehicle(t, s, "old", 0.50, 0.80, "Stopped", false, VehicleAnchorMaxAge+time.Minute) - if pick := PickVehicleForAnchor(s, false, time.Now()); pick.Driver != "" { - t.Fatalf("reading past VehicleAnchorMaxAge: %+v", pick) - } - if pick := PlanningVehiclePick(s, false, time.Now()); pick.Driver != "" { - t.Fatalf("planning pick past the anchor window: %+v", pick) - } - s = NewStore() - pushVehicle(t, s, "marked", 0.78, 0.80, "Stopped", true, 34*time.Minute) - pick := PlanningVehiclePick(s, false, time.Now()) - if pick.Driver != "marked" || pick.SoC != 0.78 || !pick.Stale { - t.Fatalf("stale reading inside the anchor window: %+v", pick) - } - if live := PickBestVehicleForLoadpoint(s, false, time.Now()); live.Driver != "" { - t.Fatalf("stale reading became live: %+v", live) - } - if done := PickVehicleForCompletion(s, time.Now()); done.Driver != "" { - t.Fatalf("stale reading confirmed completion: %+v", done) + pushVehicle(t, s, "cloud", 0.50, 0.80, "Stopped", false, 61*time.Minute) + o, ok := observation(t, s, "cloud") + if !ok || o.SoC != .50 || time.Since(o.At) < 61*time.Minute || o.Live(time.Now()) { + t.Fatalf("hour-old report %+v", o) } } -func TestAgedVehicleSoCStaysAnAnchorAfterDriverMarksReplayStale(t *testing.T) { +func TestADriverStaleReplayKeepsTheMeasurementTime(t *testing.T) { s := NewStore() pushVehicle(t, s, "vag", .78, .80, "Stopped", false, 0) aged := time.Now().Add(-34 * time.Minute) @@ -387,80 +224,33 @@ func TestAgedVehicleSoCStaysAnAnchorAfterDriverMarksReplayStale(t *testing.T) { cached := .78 s.Update("vag", DerVehicle, 0, &cached, json.RawMessage(`{"soc":78,"soc_fresh":false,"stale":true,"charging_state":"Stopped"}`)) s.EmitMetric("vag", "vehicle_soc_age_s", 34*60, "s", "", "") - pick := PlanningVehiclePick(s, false, time.Now()) - if pick.Driver != "vag" || pick.SoC != .78 || !pick.Stale { - t.Fatalf("replay of a 34-minute measurement dropped: %+v", pick) - } - if live := PickBestVehicleForLoadpoint(s, false, time.Now()); live.Driver != "" { - t.Fatalf("replay became live: %+v", live) + o, ok := observation(t, s, "vag") + if !ok || o.SoC != .78 || !o.Stale || time.Since(o.At) < 33*time.Minute { + t.Fatalf("replay of a 34-minute measurement: %+v", o) } } -func TestCachedCloudSoCCanAnchorWithoutBecomingLive(t *testing.T) { +func TestACachedReportDoesNotReplaceANewerCarObservation(t *testing.T) { s := NewStore() + pushVehicle(t, s, "vag", .80, .80, "Stopped", false, time.Minute) + latest := s.Get("vag", DerVehicle).SoCUpdatedAt soc := .78 - s.Update("vag", DerVehicle, 0, &soc, json.RawMessage(`{"soc":78,"soc_fresh":false,"stale":true,"charging_state":"Stopped"}`)) + s.Update("vag", DerVehicle, 0, &soc, json.RawMessage(`{"soc":78,"soc_fresh":false,"stale":false,"charging_state":"Stopped"}`)) s.EmitMetric("vag", "vehicle_soc_age_s", 34*60, "s", "", "") - s.DriverHealthMut("vag").RecordSuccess() - _, receivedAt, _ := s.LatestMetric("vag", "vehicle_soc_age_s") - now := receivedAt.Add(time.Second) - pick := PickVehicleForAnchor(s, false, now) - if pick.Driver != "vag" || pick.SoC != .78 || !pick.Stale || - !pick.UpdatedAt.Equal(receivedAt.Add(-34*time.Minute)) { - t.Fatalf("cached cloud anchor: %+v", pick) - } - if got := PickBestVehicleForLoadpoint(s, false, now); got.Driver != "" { - t.Fatalf("historical SoC became live: %+v", got) - } - if got := PickVehicleForCompletion(s, now); got.Driver != "" { - t.Fatalf("historical SoC confirmed completion: %+v", got) - } - if got := PickVehicleForAnchor(s, true, now); got.Driver != "" { - t.Fatalf("stopped car matched a different actively charging car: %+v", got) - } - if got := PickVehicleForAnchor(s, false, receivedAt.Add(27*time.Minute)); got.Driver != "" { - t.Fatalf("cached anchor was rejuvenated beyond one hour: %+v", got) + o, ok := observation(t, s, "vag") + if !ok || o.SoC != .80 || !o.At.Equal(latest) { + t.Fatalf("older cached report replaced newer car measurement: %+v", o) } } -func TestCachedCloudAnchorRequiresKnownAgeAndOnlineConnectedCar(t *testing.T) { - for _, tc := range []struct { - name, state string - age float64 - online bool - }{ - {"unknown-age", "Stopped", -1, true}, - {"too-old", "Stopped", 61 * 60, true}, - {"disconnected", "Disconnected", 34 * 60, true}, - {"offline", "Stopped", 34 * 60, false}, - } { - t.Run(tc.name, func(t *testing.T) { - s := NewStore() - soc := .78 - data, _ := json.Marshal(map[string]any{"soc": 78, "soc_fresh": false, "stale": true, "charging_state": tc.state}) - s.Update("vag", DerVehicle, 0, &soc, data) - if tc.age >= 0 { - s.EmitMetric("vag", "vehicle_soc_age_s", tc.age, "s", "", "") - } - if tc.online { - s.DriverHealthMut("vag").RecordSuccess() - } - if got := PickVehicleForAnchor(s, false, time.Now()); got.Driver != "" { - t.Fatalf("invalid cached anchor: %+v", got) - } - }) - } -} - -func TestCachedCloudAnchorDoesNotReplaceNewerCarObservation(t *testing.T) { +// The car's own word on its cable and place reaches the cars runtime. +func TestVehicleObservationsCarryPluggedAndAtSite(t *testing.T) { s := NewStore() - pushVehicle(t, s, "vag", .80, .80, "Stopped", false, time.Minute) - latest := s.Get("vag", DerVehicle).SoCUpdatedAt - soc := .78 - s.Update("vag", DerVehicle, 0, &soc, json.RawMessage(`{"soc":78,"soc_fresh":false,"stale":false,"charging_state":"Stopped"}`)) - s.EmitMetric("vag", "vehicle_soc_age_s", 34*60, "s", "", "") - got := PickVehicleForAnchor(s, false, time.Now()) - if got.Driver != "vag" || got.SoC != .80 || !got.UpdatedAt.Equal(latest) { - t.Fatalf("older cached report replaced newer car measurement: %+v", got) + soc := .4 + s.Update("tesla-ha", DerVehicle, 0, &soc, json.RawMessage(`{"plugged":true,"at_site":false}`)) + s.DriverHealthMut("tesla-ha").RecordSuccess() + o, ok := observation(t, s, "tesla-ha") + if !ok || o.Plugged == nil || !*o.Plugged || o.AtSite == nil || *o.AtSite { + t.Fatalf("plugged/at_site %+v", o) } } diff --git a/go/internal/units/consistency_test.go b/go/internal/units/consistency_test.go index 2dfd758b2..4084ac4db 100644 --- a/go/internal/units/consistency_test.go +++ b/go/internal/units/consistency_test.go @@ -168,11 +168,11 @@ func TestLoadpointConfigHasNoPluginSoCPct(t *testing.T) { } } -func TestVehiclePickSoCIsFraction(t *testing.T) { - typ := reflect.TypeOf(telemetry.VehiclePick{}) +func TestVehicleObservationSoCIsFraction(t *testing.T) { + typ := reflect.TypeOf(telemetry.VehicleObservation{}) for _, banned := range []string{"SoCPct", "ChargeLimitPct"} { if _, ok := typ.FieldByName(banned); ok { - t.Fatalf("telemetry.VehiclePick.%s must not exist; use SoC/ChargeLimit 0–1", banned) + t.Fatalf("telemetry.VehicleObservation.%s must not exist; use SoC/ChargeLimit 0–1", banned) } } } From bbe222b89abcb65fd09108455d4e69cf46b42e76 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Fri, 16 Oct 2026 07:03:36 +0200 Subject: [PATCH 2/4] feat(cars): agents read and ask by car, and the plan push names the car #1646 landed first, so this does the wiring left to whichever came second. An agent names a car in car.goal.set and car.soc.set by the id cars.get gave it (agent_args kind car), and the box checks the car and the goal as the request arrives. cars.get answers with the car class under the agent key: levels and goals, nothing that names a car. The redactor takes the cars' names, VINs, identifiers and sources out of logs and config. loadpoints.get and FTW Cloud's lp carry each charger's car by an opaque id, and an agent's plan.get and command outcomes name chargers and cars by its own ids. The plan's chargers carry the session's car. A change of car replans before it tells listeners, so a phone sees the new car on its charger at once, with the plan pending. Co-Authored-By: Claude Opus 5.5 --- docs/agents.md | 15 ++-- docs/cars.md | 11 ++- go/cmd/ftw/agents.go | 30 ++++++- go/cmd/ftw/app_link.go | 6 +- go/cmd/ftw/cars_agents_test.go | 98 ++++++++++++++++++++ go/cmd/ftw/cloud_link.go | 6 ++ go/cmd/ftw/cloud_link_test.go | 9 ++ go/cmd/ftw/main.go | 2 +- go/internal/appproto/agentargs.go | 65 +++++++++++++- go/internal/appproto/agents.go | 22 ++++- go/internal/appproto/agents_cars_test.go | 110 +++++++++++++++++++++++ go/internal/appproto/agents_test.go | 18 +++- go/internal/appproto/cars.go | 24 +++++ go/internal/appproto/cars_test.go | 8 ++ go/internal/appproto/handler.go | 11 +++ go/internal/appproto/ports.go | 2 + go/internal/cars/ops.go | 4 +- go/internal/cars/tick.go | 2 +- go/internal/cloudlink/project_test.go | 62 +++++++++++++ go/internal/cloudlink/sender.go | 39 +++++++- 20 files changed, 521 insertions(+), 23 deletions(-) create mode 100644 go/cmd/ftw/cars_agents_test.go create mode 100644 go/internal/appproto/agents_cars_test.go create mode 100644 go/internal/cloudlink/project_test.go diff --git a/docs/agents.md b/docs/agents.md index 8afe6d7d6..2951a14f1 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -125,6 +125,7 @@ open to an agent. | `planner.prefs.set` | `ftw.goals.request` | `agent.request` only | | `loadpoint.soc.set` | `ftw.goals.request` | `agent.request` only | | `loadpoint.surplus_only.set` | `ftw.goals.request` | `agent.request` only | +| `car.goal.set`, `car.soc.set` | `ftw.goals.request` | `agent.request` only, naming the car by its agent id or the charger by `charger_id` ([cars.md](cars.md)) | | `battery.hold`, `loadpoint.boost` | none | Never. They return when the box can bound them: `battery.hold` has no handler, and a boost has no power of its own to hold to `maxW` | - **A per-grant allowlist.** A grant lists `ops`, a subset of the ops whose @@ -139,9 +140,11 @@ open to an agent. `clear`; `voltage`, `max_amps_per_phase`, `site_phases`, `phase_split_w` and `min_phase_hold_s` stay with owners. - **Ids are the agent's.** A `loadpoint` argument is the opaque charger id - the agent read from `loadpoints.get`. The box resolves it with its own - agent key, so an id from another box, or from the readable copy, names - nothing (`E_UNKNOWN_OP {arg: "id"}`). + the agent read from `loadpoints.get`, and a `car` argument the car id it + read from `cars.get`. The box resolves each with its own agent key, so an + id from another box, or from the readable copy, names nothing + (`E_UNKNOWN_OP {arg: "id"}`). A command's outcome names a car and a + charger's car by the same ids. - **A durable change waits.** An agent that sends a `ftw.goals.request` op as `cmd` gets `E_NEEDS_OWNER`, with `{use: "agent.request"}`. - **The checks run in Core's order:** the command's expiry, a known op, the @@ -400,8 +403,10 @@ The registry's `reads` table names each read and the scope it needs. fallbacks, refusals and plan against actual join it when the box keeps them; a field the box cannot fill is absent. - The box answers `events.get` and `diag.run` with `E_UNKNOWN_OP` until it - builds them, and `cars.get` until it has the car store. Until then - `loadpoints.get` carries no car id. + builds them. `cars.get` carries no name, maker, VIN, identifier or source, + and the redactor takes those out of `logs.get` and `config.get`; a box + without the car store answers it `E_UNAVAILABLE`. `plan.get` names each + charger and its car by agent id. - `logs.get` looks back over the box's whole log ring, its newest 2,000 lines. `beforeMs` and `afterMs` together pick the lines between them; an `afterMs` at or past `beforeMs` gets `E_UNKNOWN_OP {field: "beforeMs"}`. diff --git a/docs/cars.md b/docs/cars.md index 47dc3e6ed..4bc724705 100644 --- a/docs/cars.md +++ b/docs/cars.md @@ -46,6 +46,11 @@ The Go types are in [`go/protocol/cars.go`](../go/protocol/cars.go). | `GET /api/cars` with `ftw.cars.read` | The owner role | Every field | | `GET /api/cars` without it | Viewers and watches | `id`, `name`, the level fields, `goal`, `surplusOnly` | | FTW Cloud's `car` class | Phones with `ftw.live.read` | Opaque `id`, the level fields, `goal`, `surplusOnly` | +| `cars.get` | Agents with `ftw.live.read` | The car class under the agent key ([agents.md](agents.md)) | + +An agent asks for a car's goal or level with `car.goal.set` and +`car.soc.set` as `agent.request`, which waits for an owner, naming the car +by the id `cars.get` gave it. It never taps and never keeps the list. `car.add`, `car.update` and `car.remove` need `ftw.cars.write`, which only the owner role carries. They change no hardware, so they need no passkey @@ -223,7 +228,11 @@ by default: until someone taps, the car charges as a visitor. each do not fit, and the last is left out with TRUNC, as for any `now`. - FTW Cloud's `lp` carries the same charger fields with opaque car ids, and its `car` class carries each car's level and goal and nothing that names - it ([protocol-cloud.md](protocol-cloud.md)). + it ([protocol-cloud.md](protocol-cloud.md)). An agent's `loadpoints.get` + and `cars.get` are the same bodies under the agent key. +- The plan a phone reads or is pushed names each charger's car in + `chargers[].carId`; a change of car replans, and the plan reaches the + phone pending at once. ## One release of overlap diff --git a/go/cmd/ftw/agents.go b/go/cmd/ftw/agents.go index f59de4bba..882f5fbfd 100644 --- a/go/cmd/ftw/agents.go +++ b/go/cmd/ftw/agents.go @@ -23,6 +23,7 @@ import ( "github.com/srcfl/ftw/go/internal/appenroll" "github.com/srcfl/ftw/go/internal/appproto" "github.com/srcfl/ftw/go/internal/appuplink" + "github.com/srcfl/ftw/go/internal/cars" "github.com/srcfl/ftw/go/internal/cloudlink" "github.com/srcfl/ftw/go/internal/config" "github.com/srcfl/ftw/go/internal/redact" @@ -128,6 +129,9 @@ type agentPorts struct { boxPage func(by string) (*appproto.Handler, error) // key cuts every id an agent sees, and the redactor's. key []byte + // cars are the owners' cars, whose names, VINs, identifiers and + // sources the redactor takes out. Nil on a box that keeps none. + cars *cars.Runtime // warned holds the rows already told they expire within a day. warnMu sync.Mutex warned map[string]bool @@ -160,12 +164,22 @@ func (p *agentPorts) redactor() *redact.Redactor { // names is everything the configuration calls something. func (p *agentPorts) names() []string { + var out []string + if p.cars != nil { + // The cars an owner named on the box or in the app: what names + // them, and the drivers that report them. + for _, c := range p.cars.Cars() { + out = append(out, c.Name, c.VIN) + out = append(out, c.Identifiers...) + out = append(out, c.Sources...) + } + } if p.cfg == nil { - return nil + return out } p.cfgMu.RLock() defer p.cfgMu.RUnlock() - out := []string{p.cfg.Site.Name} + out = append(out, p.cfg.Site.Name) for _, d := range p.cfg.Drivers { out = append(out, d.Name) } @@ -341,7 +355,10 @@ func (p *agentPorts) warnExpiring(nowMs int64) { // views builds the typed reads' bodies from the same source as FTW Cloud's // copy, with the agent key's ids. -type agentViews struct{ site *cloudSite } +type agentViews struct { + site *cloudSite + cars *cars.Runtime +} func (v agentViews) Live(key []byte) cloudwire.Live { return cloudlink.ProjectLive(key, time.Since(v.site.site.started).Milliseconds(), v.site.Live()) @@ -355,6 +372,13 @@ func (v agentViews) Loadpoints(key []byte) (cloudwire.LP, bool) { return cloudlink.ProjectLP(key, lp), true } +func (v agentViews) Cars(key []byte) (cloudwire.Cars, bool) { + if v.cars == nil { + return cloudwire.Cars{}, false + } + return cloudlink.ProjectCars(key, v.cars.Cars()), true +} + // endAgentHolds releases every hold an agent set, by its subject. func endAgentHolds(lp appproto.Loadpoints) func(subject string) { return func(subject string) { diff --git a/go/cmd/ftw/app_link.go b/go/cmd/ftw/app_link.go index c9bb45ec0..550887372 100644 --- a/go/cmd/ftw/app_link.go +++ b/go/cmd/ftw/app_link.go @@ -587,7 +587,7 @@ func (a *appPlans) CeilingW() *int64 { // 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. +// charger, with the session's car while the box knows it. type appPlanChargers struct { planner *mpc.Service mgr *loadpoint.Manager @@ -606,7 +606,7 @@ func (a *appPlanChargers) PlanChargers(now time.Time) []appproto.PlanCharger { out := make([]appproto.PlanCharger, 0, len(states)) for _, st := range states { ch := appproto.PlanCharger{ - ID: st.ID, PluggedIn: st.PluggedIn, + ID: st.ID, CarID: st.CarID, PluggedIn: st.PluggedIn, Pending: snap.Pending, Outdated: snap.Outdated, Windows: []appproto.PlanChargerWindow{}, GoalRev: goalRev(st), @@ -788,7 +788,7 @@ func startAppLink( caps = append(caps, appproto.CapAccessAgents) // One view for every agent session, so a charger's plug-in count is // the same whichever session reads it. - views := agentViews{site: &cloudSite{site: site, api: gateway}} + views := agentViews{site: &cloudSite{site: site, api: gateway}, cars: carsRT} // handlerFor builds one handler for one session: a phone's stream, or a // watch's exchange. They share the ports above, which are read-only diff --git a/go/cmd/ftw/cars_agents_test.go b/go/cmd/ftw/cars_agents_test.go new file mode 100644 index 000000000..c320b9ec7 --- /dev/null +++ b/go/cmd/ftw/cars_agents_test.go @@ -0,0 +1,98 @@ +package main + +import ( + "bytes" + "path/filepath" + "testing" + "time" + + "github.com/srcfl/ftw/go/internal/apiauth" + "github.com/srcfl/ftw/go/internal/appproto" + "github.com/srcfl/ftw/go/internal/cars" + "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" + "github.com/srcfl/ftw/go/protocol" + "github.com/srcfl/ftw/go/protocol/cloudwire" +) + +// carsOnEasee is a box with one Easee, the car made from it and a Volvo +// added since, wired as main.go wires a car change to the planner. +func carsOnEasee(t *testing.T, replan func(string)) (*loadpoint.Manager, *cars.Runtime, protocol.Car) { + t.Helper() + mgr := loadpoint.NewManager() + mgr.Load([]loadpoint.Config{{ID: "easee", DriverName: "easee", VehicleCapacityWh: 76000}}) + rt := cars.New(cars.Deps{Store: &memRows{rows: map[string]string{}}, Loadpoints: mgr, Replan: replan, + Vehicles: func() []telemetry.VehicleObservation { return nil }}) + if err := rt.Start(nil); err != nil { + t.Fatal(err) + } + volvo, err := rt.Add(protocol.Car{ID: "0123456789abcdef0123456789abcdef", Name: "Familjens Volvo", VIN: "YV1XZK7V5N2000001", + CapacityWh: 40000, Identifiers: []string{"rfid-7"}, Sources: []string{"volvo-cloud"}}) + if err != nil { + t.Fatal(err) + } + return mgr, rt, volvo +} + +// A tap reaches a streaming phone as a plan whose charger names the car: at +// once, pending, because the tap asked for a replan. +func TestATapReachesAStreamingPhoneWithTheCar(t *testing.T) { + st, err := state.Open(filepath.Join(t.TempDir(), "state.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { st.Close() }) + 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}) + // Not started: the tap's replan request alone moves the plan. + svc.InstallPlan(mpc.Plan{GeneratedAtMs: time.Now().UnixMilli()}, svc.Defaults, "easee") + + mgr, rt, volvo := carsOnEasee(t, svc.RequestReplan) + feed := appproto.NewPlanFeed(appproto.PlanSettle) + svc.OnPlanChange(feed.Changed) + owner := streamingSession(t, &appPlanChargers{planner: svc, mgr: mgr}, feed, apiauth.RoleOwner) + + mgr.ObserveSession("easee", true, 0, 0, true, "easee:EH1", "s1") + rt.Tick() + if _, err := rt.SetSessionCar("easee", rt.ChargerCar("easee").Session, volvo.ID); err != nil { + t.Fatal(err) + } + tapped := time.Now() + got := owner.waitPlan(t, func(p appproto.Plan) bool { return len(p.Chargers) == 1 && p.Chargers[0].CarID == volvo.ID }) + if took := time.Since(tapped); took > 200*time.Millisecond || !got.Chargers[0].Pending { + t.Fatalf("the tap reached the phone after %v, pending %v", took, got.Chargers[0].Pending) + } +} + +// An agent's redactor takes out what names a car, and its cars.get carries +// none of it. +func TestAgentsNeverSeeWhatNamesACar(t *testing.T) { + _, rt, volvo := carsOnEasee(t, nil) + p := &agentPorts{key: []byte("agent key"), cars: rt} + line := "charged Familjens Volvo YV1XZK7V5N2000001 after rfid-7 via volvo-cloud" + out := p.redactor().Text(line) + for _, name := range []string{"Familjens Volvo", "YV1XZK7V5N2000001", "rfid-7", "volvo-cloud"} { + if bytes.Contains([]byte(out), []byte(name)) { + t.Fatalf("the redactor kept %q: %s", name, out) + } + } + + view, ok := agentViews{cars: rt}.Cars(p.key) + if !ok || len(view.Cars) != 2 { + t.Fatalf("cars.get %+v %v", view, ok) + } + raw, err := cloudwire.Marshal(view) + if err != nil { + t.Fatal(err) + } + for _, name := range []string{"Familjens Volvo", "YV1XZK7V5N2000001", "rfid-7", "volvo-cloud", volvo.ID} { + if bytes.Contains(raw, []byte(name)) { + t.Fatalf("cars.get names the car: %q", name) + } + } + if _, ok := (agentViews{}).Cars(p.key); ok { + t.Fatal("a box without cars has a car class") + } +} diff --git a/go/cmd/ftw/cloud_link.go b/go/cmd/ftw/cloud_link.go index 2e1039452..6d4baea85 100644 --- a/go/cmd/ftw/cloud_link.go +++ b/go/cmd/ftw/cloud_link.go @@ -186,6 +186,12 @@ func (c *cloudSite) loadpoint(st loadpoint.State, now time.Time) cloudwire.Loadp // since the box started, as before cars. lp.Session = c.session(st.ID, st.PluggedIn) } + if st.PluggedIn { + // The session's car by the box's id; the link cuts it per grant + // (cloudlink.ProjectLP), and an agent's read under the agent key. + lp.CarID, lp.CarIdentifiedBy = st.CarID, st.CarIdentifiedBy + lp.CarCorrected, lp.CarCorrectedFrom, lp.Choose = st.CarCorrected, st.CarCorrectedFrom, st.Choose + } if st.PluggedIn && !st.SoCUnknown { // A visitor's car has no level; its delivered energy is not one. lp.SocPermille = ptr(units.PermilleFromFraction(st.CurrentSoC)) diff --git a/go/cmd/ftw/cloud_link_test.go b/go/cmd/ftw/cloud_link_test.go index 7c8ce3277..4943dd6e1 100644 --- a/go/cmd/ftw/cloud_link_test.go +++ b/go/cmd/ftw/cloud_link_test.go @@ -260,4 +260,13 @@ func TestCloudSessionIsTheCarsRuntimes(t *testing.T) { if lp := c.loadpoint(st, time.Now()); lp.Session == "" { t.Fatal("without the cars runtime the plug-in count is the session") } + // The session's car by the box's id: the link cuts it per grant. + st.CarID, st.CarIdentifiedBy, st.Choose = "0123456789abcdef0123456789abcdef", "tap", nil + if lp := c.loadpoint(st, time.Now()); lp.CarID != st.CarID || lp.CarIdentifiedBy != "tap" { + t.Fatalf("car %q by %q", lp.CarID, lp.CarIdentifiedBy) + } + st.PluggedIn = false + if lp := c.loadpoint(st, time.Now()); lp.CarID != "" { + t.Fatal("an unplugged charger names a car") + } } diff --git a/go/cmd/ftw/main.go b/go/cmd/ftw/main.go index 58413531b..e160af160 100644 --- a/go/cmd/ftw/main.go +++ b/go/cmd/ftw/main.go @@ -2394,7 +2394,7 @@ func main() { // Owners hear about agents through the notification service, bound // below once it exists, as FTW Cloud's grant notices are. cloudNotify := &cloudNotices{} - agents := &agentPorts{logs: logRing, cfg: cfg, cfgMu: cfgMu, st: st, notices: cloudNotify} + agents := &agentPorts{logs: logRing, cfg: cfg, cfgMu: cfgMu, st: st, notices: cloudNotify, cars: carsRT} appEnroll, appUplink, appLinkEnabled, appLinkErr := startAppLink( ctx, cfg, identityKeyPath, boxID, Version, st, tel, mpcSvc, lpMgr, lpController, carsRT, priceSvc, ctrl, ctrlMu, plannerPrefs, diff --git a/go/internal/appproto/agentargs.go b/go/internal/appproto/agentargs.go index 7b9876905..1d65b9fea 100644 --- a/go/internal/appproto/agentargs.go +++ b/go/internal/appproto/agentargs.go @@ -78,6 +78,16 @@ func (h *Handler) agentArg(op string, a protocol.AgentArg, value any, caps proto return nil, badArg(op, a.Name, s) } return id, nil + case "car": + s, ok := value.(string) + if !ok { + return nil, badArg(op, a.Name, value) + } + id, found := h.resolveCar(s) + if !found { + return nil, badArg(op, a.Name, s) + } + return id, nil case "number", "power", "duration", "ratio": n, ok := argNum(map[string]any{"v": value}, "v") if !ok || (a.Min != nil && n < *a.Min) || (a.Max != nil && n > *a.Max) { @@ -130,6 +140,12 @@ func (h *Handler) opArgs(op string, args map[string]any) *ErrorBody { } _, bad := surplusOnlyArg(op, args) return bad + case OpCarGoalSet, OpCarSoCSet: + if h.cfg.Cars == nil { + return &ErrorBody{Code: ErrUnavailable, Retryable: ErrorRetryable[ErrUnavailable], + Args: map[string]any{"op": op, "subsystem": "cars"}} + } + return carIntentArgs(op, args) } return &ErrorBody{Code: protocol.ErrOpNotGranted, Args: map[string]any{"op": op}} } @@ -139,6 +155,28 @@ func (h *Handler) agentLoadpointID(id string) string { return cloudwire.LoadpointID(h.cfg.AgentKey, id) } +// agentCarID is a car's id as agents see it: the car class's id under the +// agent key. Empty stays empty. +func (h *Handler) agentCarID(id string) string { + if id == "" { + return "" + } + return cloudwire.CarID(h.cfg.AgentKey, id) +} + +// resolveCar finds the car behind an agent's id. +func (h *Handler) resolveCar(agentID string) (string, bool) { + if h.cfg.Cars == nil { + return "", false + } + for _, c := range h.cfg.Cars.Cars() { + if h.agentCarID(c.ID) == agentID { + return c.ID, true + } + } + return "", false +} + // resolveLoadpoint finds the charger behind an agent's id. func (h *Handler) resolveLoadpoint(agentID string) (string, bool) { if h.cfg.Loadpoints == nil { @@ -228,9 +266,12 @@ func (h *Handler) forAgent(args map[string]any) map[string]any { continue case "value": if s, ok := v.(string); ok { - if args["arg"] == "id" { + switch { + case args["arg"] == "id" && (args["op"] == OpCarGoalSet || args["op"] == OpCarSoCSet): + out[k] = h.agentCarID(s) + case args["arg"] == "id" || args["arg"] == "charger_id": out[k] = h.agentLoadpointID(s) - } else if codeWord.MatchString(s) { + case codeWord.MatchString(s): out[k] = s } continue @@ -244,13 +285,31 @@ func (h *Handler) forAgent(args map[string]any) map[string]any { return out } -// forAgentResult is a command's outcome as an agent may read it. +// forAgentResult is a command's outcome as an agent may read it: a car by +// its agent id with its level and goal, a charger's car by agent ids. func (h *Handler) forAgentResult(res CmdResult) CmdResult { if res.Error != nil { e := *res.Error e.Args = h.forAgent(e.Args) res.Error = &e } + if res.Car != nil { + c := briefCar(*res.Car) + c.ID = h.agentCarID(c.ID) + res.Car = &c + } + if res.Charger != nil { + cc := *res.Charger + cc.CarID, cc.CarCorrectedFrom = h.agentCarID(cc.CarID), h.agentCarID(cc.CarCorrectedFrom) + if cc.Choose != nil { + choose := make([]string, 0, len(cc.Choose)) + for _, id := range cc.Choose { + choose = append(choose, h.agentCarID(id)) + } + cc.Choose = choose + } + res.Charger = &cc + } return res } diff --git a/go/internal/appproto/agents.go b/go/internal/appproto/agents.go index 9dd0f3428..2fa826932 100644 --- a/go/internal/appproto/agents.go +++ b/go/internal/appproto/agents.go @@ -52,6 +52,9 @@ type Access interface { type AgentViews interface { Live(key []byte) cloudwire.Live Loadpoints(key []byte) (cloudwire.LP, bool) + // Cars is the car class under key: opaque ids, levels and goals, and + // nothing that names a car. False on a box that keeps no cars. + Cars(key []byte) (cloudwire.Cars, bool) } // AuditLog keeps the box's audit rows. @@ -793,9 +796,10 @@ func (h *Handler) onRead(env Envelope) error { return h.onLogsGet(env) case protocol.MsgConfigGet: return h.onConfigGet(env) + case protocol.MsgCarsGet: + return h.onCarsGet(env) default: - // events.get, diag.run and cars.get are in the contract and not - // built yet; cars.get waits for the car contract. + // events.get and diag.run are in the contract and not built yet. return h.refuse(env.ID, ErrUnknownOp, map[string]any{"t": env.T}) } } @@ -821,6 +825,20 @@ func (h *Handler) onLoadpointsGet(env Envelope) error { return h.sendBulk(protocol.MsgLoadpoints, env.ID, lp) } +// onCarsGet is each car's level and goal by an id cut with the key: the same +// projection as FTW Cloud's car class. Names, makes, VINs, identifiers and +// sources stay home. +func (h *Handler) onCarsGet(env Envelope) error { + if h.cfg.Views == nil { + return h.refuse(env.ID, ErrUnavailable, map[string]any{"subsystem": "cars"}) + } + cars, ok := h.cfg.Views.Cars(h.cfg.AgentKey) + if !ok { + return h.refuse(env.ID, ErrUnavailable, map[string]any{"subsystem": "cars"}) + } + return h.sendBulk(protocol.MsgCars, env.ID, cars) +} + func (h *Handler) onDriversGet(env Envelope) error { snap := h.cfg.Site.Snapshot() up := h.cfg.Clock.UptimeMs() diff --git a/go/internal/appproto/agents_cars_test.go b/go/internal/appproto/agents_cars_test.go new file mode 100644 index 000000000..ceeae9813 --- /dev/null +++ b/go/internal/appproto/agents_cars_test.go @@ -0,0 +1,110 @@ +package appproto + +import ( + "bytes" + "testing" + "time" + + "github.com/srcfl/ftw/go/internal/apiauth" + "github.com/srcfl/ftw/go/protocol" + "github.com/srcfl/ftw/go/protocol/cloudwire" +) + +// carOf is a car's id as an agent reads it from cars.get. +func carOf(id string) string { return cloudwire.CarID(testAgentKey, id) } + +// An agent reads the cars by the ids it can use, with their levels and +// goals, and nothing that names them. +func TestAnAgentReadsTheCarsByItsOwnIds(t *testing.T) { + r := newAgentRig(t) + r.cars = newFakeCars() + row, _ := r.grant(apiauth.RoleAgent, []string{protocol.ScopeLiveRead}, nil, protocol.Caps{}, time.Hour, protocol.Principal{Client: "Claude"}) + h, rec := r.agentSession(row) + deliver(t, h, protocol.MsgCarsGet, ptrU32(1), struct{}{}) + got := body[cloudwire.Cars](t, rec.last(t, protocol.MsgCars)) + if len(got.Cars) != 1 || got.Cars[0].ID != carOf("c1") || *got.Cars[0].SocPermille != 140 { + t.Fatalf("cars %+v", got) + } + + // Without live.read, nothing. + blind, _ := r.grant(apiauth.RoleAgent, []string{protocol.ScopeLogsRead}, nil, protocol.Caps{}, time.Hour, protocol.Principal{}) + bh, brec := r.agentSession(blind) + deliver(t, bh, protocol.MsgCarsGet, ptrU32(2), struct{}{}) + if code := errCode(t, brec); code != ErrScopeDenied { + t.Fatalf("cars.get without live.read: %s", code) + } +} + +// An agent asks for a car's goal or level by the id cars.get gave it. The +// box checks the car and the goal as the request arrives, an owner confirms, +// the right car changes, and the agent reads the outcome by its own ids. +func TestAnAgentAsksForACarsGoalByItsOwnId(t *testing.T) { + r := newAgentRig(t) + r.cars = newFakeCars() + row, _ := r.grant(apiauth.RoleAgent, []string{protocol.ScopeGoalsRequest, protocol.ScopeLiveRead}, + []string{OpCarGoalSet, OpCarSoCSet}, protocol.Caps{PerMin: 6}, time.Hour, protocol.Principal{Client: "Claude"}) + h, rec := r.agentSession(row) + + for name, args := range map[string]map[string]any{ + "a car the box lacks": {"id": carOf("nope"), "target": 0.7, "ready_by_min": int64(420)}, + "the box's own id": {"id": "c2", "target": 0.7, "ready_by_min": int64(420)}, + "a target without a time": {"id": carOf("c2"), "target": 0.7}, + "a goal and clear": {"id": carOf("c2"), "target": 0.7, "ready_by_min": int64(420), "clear": true}, + "a key the op lacks": {"id": carOf("c2"), "name": "Mine"}, + } { + rec.reset() + deliver(t, h, protocol.MsgAgentRequest, ptrU32(1), protocol.AgentRequest{Op: OpCarGoalSet, Args: args}) + if code := errCode(t, rec); code != ErrUnknownOp { + t.Errorf("%s: %s", name, code) + } + } + + rec.reset() + deliver(t, h, protocol.MsgAgentRequest, ptrU32(2), protocol.AgentRequest{Op: OpCarGoalSet, + Args: map[string]any{"id": carOf("c2"), "target": 0.7, "ready_by_min": int64(420), "days": int64(31)}, Why: "Volvo to 70 % by 07:00"}) + reqID, _ := body[map[string]any](t, rec.last(t, protocol.MsgAgentOK))["reqId"].(string) + if reqID == "" { + t.Fatalf("no request: %s", rec.types()) + } + owner, orec := r.ownerSession() + deliver(t, owner, protocol.MsgAgentList, ptrU32(3), struct{}{}) + req := body[protocol.AgentList](t, orec.last(t, protocol.MsgAgentOK)).Requests[0] + deliver(t, owner, protocol.MsgAgentConfirm, ptrU32(4), + r.signer.Confirm(r.signedFor(), protocol.AgentConfirm{ReqID: reqID, Approve: true, AtMs: r.ms()}, req)) + if g := r.cars.cars["c2"].Goal; g == nil || g.TargetPermille != 700 || g.ReadyByMin != 420 || g.Days != 31 { + t.Fatalf("the Volvo's goal %+v", g) + } + if r.cars.cars["c1"].Goal != nil { + t.Fatal("another car changed") + } + d := body[protocol.AgentDecided](t, rec.last(t, protocol.MsgAgentDecided)) + if d.State != protocol.RequestApproved || d.Result == nil || d.Result.Car == nil || d.Result.Car.ID != carOf("c2") { + t.Fatalf("the agent's outcome %+v", d) + } + raw, _ := Marshal(d) + if bytes.Contains(raw, []byte("Volvo")) || bytes.Contains(raw, []byte(`"c2"`)) { + t.Fatal("the outcome names the car") + } +} + +// An agent reads the plan's chargers and their cars by the ids its other +// reads give it, never by the box's names. +func TestAnAgentsPlanNamesNoChargerOrCar(t *testing.T) { + r := newAgentRig(t) + chargers := &fakeChargers{} + chargers.set([]PlanCharger{{ID: "easee", CarID: "0123456789abcdef0123456789abcdef", PluggedIn: true, Windows: []PlanChargerWindow{}}}) + r.chargers = chargers + h, rec := r.agentSession(r.controlAgent()) + deliver(t, h, MsgPlanGet, ptrU32(1), struct{}{}) + plan := body[Plan](t, rec.last(t, MsgPlan)) + if len(plan.Chargers) != 1 || plan.Chargers[0].ID != cloudwire.LoadpointID(testAgentKey, "easee") || + plan.Chargers[0].CarID != carOf("0123456789abcdef0123456789abcdef") { + t.Fatalf("an agent's plan chargers %+v", plan.Chargers) + } + + owner, orec := r.ownerSession() + deliver(t, owner, MsgPlanGet, ptrU32(2), struct{}{}) + if got := body[Plan](t, orec.last(t, MsgPlan)).Chargers; got[0].ID != "easee" || got[0].CarID != "0123456789abcdef0123456789abcdef" { + t.Fatalf("a phone's plan chargers %+v", got) + } +} diff --git a/go/internal/appproto/agents_test.go b/go/internal/appproto/agents_test.go index d5087bb8b..d9acabe53 100644 --- a/go/internal/appproto/agents_test.go +++ b/go/internal/appproto/agents_test.go @@ -133,6 +133,10 @@ type agentRig struct { owner appenroll.Grant signer *approvaltest.Signer logs []redact.Line + // cars and chargers, when a test sets them, are the box's car store + // and the plan's chargers. + cars *fakeCars + chargers PlanChargers } func (r *agentRig) ms() int64 { return r.now.UnixMilli() } @@ -189,6 +193,11 @@ func (testViews) Loadpoints(key []byte) (cloudwire.LP, bool) { return cloudwire.LP{Loadpoints: []cloudwire.Loadpoint{{ID: cloudwire.LoadpointID(key, "lp1")}}}, true } +func (testViews) Cars(key []byte) (cloudwire.Cars, bool) { + soc := int64(140) + return cloudwire.Cars{Cars: []cloudwire.Car{{ID: cloudwire.CarID(key, "c1"), SocPermille: &soc, SocSource: protocol.CarSoCVehicle}}}, true +} + // handler builds a session for one enrolled row, as the uplink would. func (r *agentRig) handler(kind, dev, role string) (*Handler, *recorder) { r.t.Helper() @@ -196,7 +205,12 @@ func (r *agentRig) handler(kind, dev, role string) (*Handler, *recorder) { grants := enrolGrants{id: r.id, dev: dev} g, _ := grants.Grant() caller := apiauth.Caller{Subject: kind + ":" + dev, Kind: kind, Role: role, Scopes: ScopesForEnrolment(g)} + var carPort Cars + if r.cars != nil { + carPort = r.cars + } h, err := New(Config{ + Cars: carPort, PlanChargers: r.chargers, Clock: r.clock, Site: r.box, Info: r.box, Modes: r.box, Plans: r.box, Codec: testCodec{}, Sender: rec, Caller: caller, Grants: grants, Commands: NewCmdLog(), Loadpoints: r.lp, Access: r.access, Agents: r.agents, Audit: r.audit, @@ -747,8 +761,8 @@ func TestAPhoneReadsTheTypedReads(t *testing.T) { t.Errorf("drivers %+v", d) } deliver(t, owner, protocol.MsgCarsGet, ptrU32(3), struct{}{}) - if got := errCode(t, rec); got != ErrUnknownOp { - t.Errorf("cars.get before the car store: %s", got) + if c := body[cloudwire.Cars](t, rec.last(t, protocol.MsgCars)); len(c.Cars) != 1 || c.Cars[0].ID != carOf("c1") { + t.Errorf("cars %+v", c) } } diff --git a/go/internal/appproto/cars.go b/go/internal/appproto/cars.go index beb4c9829..653dc3d5e 100644 --- a/go/internal/appproto/cars.go +++ b/go/internal/appproto/cars.go @@ -194,6 +194,30 @@ func carRefusal(cmd Cmd, err error) (CmdResult, bool) { }, true } +// carIntentArgs checks car.goal.set's and car.soc.set's arguments as carOp +// reads them, for an agent.request before an owner is asked. +func carIntentArgs(op string, args map[string]any) *ErrorBody { + id, _ := args["id"].(string) + charger, _ := args["charger_id"].(string) + if (id == "") == (charger == "") { + return badArg(op, "id", args["id"]) + } + switch op { + case OpCarGoalSet: + if _, _, bad := goalArgs(args); bad != "" { + return badArg(op, bad, args[bad]) + } + if _, bad := optBool(args, "surplus_only"); bad != "" { + return badArg(op, bad, args[bad]) + } + case OpCarSoCSet: + if soc, ok := argNum(args, "soc"); !ok || soc < 0 || soc > 1 { + return badArg(op, "soc", args["soc"]) + } + } + return nil +} + // goalArgs reads car.goal.set's goal, flat as the box's other ops take // theirs: target, a fraction, and ready_by_min, with days and // finish_at_limit, replace it; clear: true removes it. With none of them the diff --git a/go/internal/appproto/cars_test.go b/go/internal/appproto/cars_test.go index 7334dca10..e9b9f80bc 100644 --- a/go/internal/appproto/cars_test.go +++ b/go/internal/appproto/cars_test.go @@ -18,6 +18,14 @@ type fakeCars struct { sessionGoal *protocol.CarGoal } +func (f *fakeCars) Cars() []protocol.Car { + out := make([]protocol.Car, 0, len(f.cars)) + for _, c := range f.cars { + out = append(out, c) + } + return out +} + func newFakeCars() *fakeCars { return &fakeCars{cars: map[string]protocol.Car{"c1": {ID: "c1", Name: "Tesla"}, "c2": {ID: "c2", Name: "Volvo"}}, plugged: true} } diff --git a/go/internal/appproto/handler.go b/go/internal/appproto/handler.go index fadedd061..fa049f666 100644 --- a/go/internal/appproto/handler.go +++ b/go/internal/appproto/handler.go @@ -7,6 +7,7 @@ import ( "errors" "fmt" "log/slog" + "slices" "sort" "strconv" "sync" @@ -1327,6 +1328,16 @@ func (h *Handler) writePlan(id *uint32, skipSame bool) error { ) if h.cfg.PlanChargers != nil { body.Chargers = h.cfg.PlanChargers.PlanChargers(h.cfg.Clock.Now()) + if h.isAgent() { + // An agent knows chargers and cars by the ids loadpoints.get + // and cars.get gave it, never by the box's names. A copy: the + // source may hand every session the same slice. + body.Chargers = slices.Clone(body.Chargers) + for i := range body.Chargers { + body.Chargers[i].ID = h.agentLoadpointID(body.Chargers[i].ID) + body.Chargers[i].CarID = h.agentCarID(body.Chargers[i].CarID) + } + } } same := body same.UptimeMs = 0 diff --git a/go/internal/appproto/ports.go b/go/internal/appproto/ports.go index ed5087627..050e07497 100644 --- a/go/internal/appproto/ports.go +++ b/go/internal/appproto/ports.go @@ -206,6 +206,8 @@ type Loadpoints interface { // Cars is the box's cars (docs/cars.md): the car ops, behind the same calls // GET /api/cars reads. type Cars interface { + // Cars is every car, as GET /api/cars carries it. + Cars() []protocol.Car Add(c protocol.Car) (protocol.Car, error) UpdateCar(id string, u cars.Update) (protocol.Car, error) Remove(id string) error diff --git a/go/internal/cars/ops.go b/go/internal/cars/ops.go index a4976cd7b..f28369862 100644 --- a/go/internal/cars/ops.go +++ b/go/internal/cars/ops.go @@ -457,8 +457,10 @@ func (r *Runtime) commit(reason string, car *Car) (protocol.Car, error) { } r.mu.Unlock() run(acts) - r.notify() + // The replan first: it tells the plan's listeners at once, so a phone + // sees the new car on its charger with the plan pending. r.replan(reason) + r.notify() return out, nil } diff --git a/go/internal/cars/tick.go b/go/internal/cars/tick.go index 965e278a7..ff9877fd9 100644 --- a/go/internal/cars/tick.go +++ b/go/internal/cars/tick.go @@ -60,8 +60,8 @@ func (r *Runtime) Tick() { r.deps.Bus.Publish(ev) } if changed || levelChanged { - r.notify() r.replan("car_changed") + r.notify() } } diff --git a/go/internal/cloudlink/project_test.go b/go/internal/cloudlink/project_test.go new file mode 100644 index 000000000..22ebf7972 --- /dev/null +++ b/go/internal/cloudlink/project_test.go @@ -0,0 +1,62 @@ +package cloudlink + +import ( + "bytes" + "testing" + + "github.com/srcfl/ftw/go/protocol" + "github.com/srcfl/ftw/go/protocol/cloudwire" +) + +// A charger's car goes by an id cut with the key, as its charger and driver +// do, so neither FTW Cloud nor an agent learns the box's car ids. +func TestProjectLPCutsTheCarIds(t *testing.T) { + gk := []byte("grant key") + tesla, volvo := "0123456789abcdef0123456789abcdef", "fedcba9876543210fedcba9876543210" + in := cloudwire.LP{Loadpoints: []cloudwire.Loadpoint{ + {ID: "easee", Src: "easee", CarID: tesla, CarIdentifiedBy: protocol.CarIdentifiedByTap, CarCorrected: true, CarCorrectedFrom: volvo}, + {ID: "garage", Src: "garage", CarIdentifiedBy: protocol.CarIdentifiedByAsking, Choose: []string{tesla, volvo}}, + }} + out := ProjectLP(gk, in) + a, b := out.Loadpoints[0], out.Loadpoints[1] + if a.CarID != cloudwire.CarID(gk, tesla) || a.CarCorrectedFrom != cloudwire.CarID(gk, volvo) || a.CarIdentifiedBy != protocol.CarIdentifiedByTap { + t.Fatalf("easee %+v", a) + } + if b.CarID != "" || len(b.Choose) != 2 || b.Choose[0] != cloudwire.CarID(gk, tesla) || b.Choose[1] != cloudwire.CarID(gk, volvo) { + t.Fatalf("garage %+v", b) + } + if in.Loadpoints[1].Choose[0] != tesla { + t.Fatal("the projection changed its input") + } +} + +// The car class carries each car's level and goal by an opaque id, and +// nothing that names the car: no name, maker, VIN, identifier or source. +func TestProjectCarsCarriesNothingThatNamesACar(t *testing.T) { + gk := []byte("agent key") + soc, age, limit := int64(140), int64(540), int64(800) + in := []protocol.Car{{ + ID: "0123456789abcdef0123456789abcdef", Name: "Familjebilen", Make: "Tesla", VIN: "5YJ3E7EB7KF000001", + CapacityWh: 76000, SocPermille: &soc, SocSource: protocol.CarSoCVehicle, SocAgeS: &age, EnergySinceWh: -120, + ChargeLimitPermille: &limit, Goal: &protocol.CarGoal{TargetPermille: 800, ReadyByMin: 420, Days: 0x7f}, + SurplusOnly: true, Identifiers: []string{"04:AB:CD"}, Sources: []string{"tesla-ha"}, + }} + out := ProjectCars(gk, in) + c := out.Cars[0] + if c.ID != cloudwire.CarID(gk, in[0].ID) || *c.SocPermille != 140 || *c.SocAgeS != 540 || c.EnergySinceWh != -120 || + c.Goal == nil || c.Goal.TargetPermille != 800 || c.Goal.Days != 0x7f || !c.SurplusOnly { + t.Fatalf("car %+v", c) + } + raw, err := cloudwire.Marshal(out) + if err != nil { + t.Fatal(err) + } + for _, name := range []string{"Familjebilen", "Tesla", "5YJ3E7EB7KF000001", "04:AB:CD", "tesla-ha", in[0].ID} { + if bytes.Contains(raw, []byte(name)) { + t.Fatalf("the car class names the car: %q", name) + } + } + if empty := ProjectCars(gk, nil); empty.Cars == nil { + t.Fatal("no cars should be an empty list, not null") + } +} diff --git a/go/internal/cloudlink/sender.go b/go/internal/cloudlink/sender.go index c8cda30b4..93777ae92 100644 --- a/go/internal/cloudlink/sender.go +++ b/go/internal/cloudlink/sender.go @@ -469,7 +469,7 @@ func (l *Link) projectLP(snap grantSnapshot, in cloudwire.LP) cloudwire.LP { return ProjectLP(snap.g.GrantKey, in) } -// ProjectLP replaces loadpoint and driver names with opaque ids under gk. +// ProjectLP replaces loadpoint, driver and car ids with opaque ids under gk. func ProjectLP(gk []byte, in cloudwire.LP) cloudwire.LP { out := cloudwire.LP{ControlRev: in.ControlRev} for _, lp := range in.Loadpoints { @@ -477,6 +477,15 @@ func ProjectLP(gk []byte, in cloudwire.LP) cloudwire.LP { if lp.Src != "" { lp.Src = cloudwire.SourceID(gk, lp.Src) } + lp.CarID = carID(gk, lp.CarID) + lp.CarCorrectedFrom = carID(gk, lp.CarCorrectedFrom) + if lp.Choose != nil { + choose := make([]string, 0, len(lp.Choose)) + for _, c := range lp.Choose { + choose = append(choose, carID(gk, c)) + } + lp.Choose = choose + } out.Loadpoints = append(out.Loadpoints, lp) } for _, f := range in.Feedback { @@ -488,6 +497,34 @@ func ProjectLP(gk []byte, in cloudwire.LP) cloudwire.LP { return out } +// ProjectCars is the car class under gk (docs/cars.md): each car's opaque +// id, level and goal, and nothing that names a car. FTW Cloud's copy and an +// agent's cars.get read the same projection, each under its own key. +func ProjectCars(gk []byte, in []protocol.Car) cloudwire.Cars { + out := cloudwire.Cars{Cars: make([]cloudwire.Car, 0, len(in))} + for _, c := range in { + car := cloudwire.Car{ + ID: carID(gk, c.ID), SocPermille: c.SocPermille, SocSource: c.SocSource, SocAgeS: c.SocAgeS, + EnergySinceWh: c.EnergySinceWh, SocEstimate: c.SocEstimate, ChargeLimitPermille: c.ChargeLimitPermille, + SurplusOnly: c.SurplusOnly, + } + if g := c.Goal; g != nil { + car.Goal = &cloudwire.CarGoal{TargetPermille: g.TargetPermille, ReadyByMin: g.ReadyByMin, Days: g.Days, + FinishAtLimit: g.FinishAtLimit} + } + out.Cars = append(out.Cars, car) + } + return out +} + +// carID is a car's opaque id under gk; empty stays empty. +func carID(gk []byte, id string) string { + if id == "" { + return "" + } + return cloudwire.CarID(gk, id) +} + // The change tests: encodings of what a phone must hear about, without the // fields that move every reading. From 4ed30801eb255a3de578a29bb30037242acaf069 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Fri, 16 Oct 2026 08:01:04 +0200 Subject: [PATCH 3/4] fix(cars): the reserve holds to the session's car, and a car's goal has a deadline before the replan #1657's reserve took its power from the charger and its target from the goal alone: a Volvo whose own charger takes 7.4 kW reserved 11 kW on an Easee. LimitPower moves into loadpoint, and DeadlineNeed and the plan both hold to the lower of the charger and the car, keeping the charger's lowest step, and to the car's charge limit while its level is exact. The car's binding carries that limit; its phases bound nothing beyond its own rating, so the unread CarPhases goes. A goal the car brings cleared the loadpoint's rolled deadline until the next control tick, and the replan right after a tap planned without the car. The runtime rolls the schedules before every replan and after its first sync, and replans when a sync hands a loadpoint a goal or a surplus choice. A visitor's goal ends its surplus-only charging: someone who asks for a goal wants it met, and the plan and the reserve plan from empty while the level stays unknown. Co-Authored-By: Claude Opus 5.5 --- docs/cars.md | 21 ++- go/cmd/ftw/cars_wiring.go | 37 +---- go/cmd/ftw/cars_wiring_test.go | 16 +- go/cmd/ftw/main.go | 2 +- go/internal/cars/cars.go | 11 ++ go/internal/cars/ops.go | 16 +- go/internal/cars/reserve_test.go | 170 ++++++++++++++++++++ go/internal/cars/tick.go | 27 +++- go/internal/loadpoint/car.go | 10 +- go/internal/loadpoint/deadline_need.go | 52 ++++-- go/internal/loadpoint/deadline_need_test.go | 52 ++++++ go/internal/loadpoint/loadpoint.go | 12 +- 12 files changed, 347 insertions(+), 79 deletions(-) create mode 100644 go/internal/cars/reserve_test.go create mode 100644 go/internal/loadpoint/deadline_need_test.go diff --git a/docs/cars.md b/docs/cars.md index 4bc724705..8889e75c7 100644 --- a/docs/cars.md +++ b/docs/cars.md @@ -152,10 +152,14 @@ At each plug-in the box asks, in this order (`carIdentifiedBy`): The plan is made per session, from the session's car: its level, its capacity, its goal and its surplus-only choice. Planned power is held to the -lower of the charger's maximum and the car's own (`maxW`). The charge limit -the car reports caps the plan only while the level is the car's own and -still exact. `phases` is the owner's note for now: the controller follows -the charger's phase mode. +lower of the charger's maximum and the car's own (`maxW`), keeping the +charger's lowest step when that is above the car's maximum. The charge +limit the car reports caps the plan only while the level is the car's own +and still exact. The reserve that keeps a goal charging while no plan is +accepted holds to the same power and limit. `phases` is the owner's note: +`maxW` already counts the car's phases, and the controller follows the +charger's phase mode. A change of car or goal rolls the charger's deadline +before the replan, so that plan has the car's goal in it. - **One goal per car**, used wherever the car is plugged in. `readyByMin` is in the box's time zone. @@ -167,9 +171,12 @@ the charger's phase mode. charger's `vehicle_capacity_wh`, so energy delivered still counts toward a goal, though the box shows no level. `car.goal.set` and `car.soc.set` with `charger_id` set them, and so do the charger routes; - neither ever touches a car. Until then the car charges from surplus only, - with no level. A goal set for it plans from empty, and the car stops when - it is full. The design's "or the cheapest quarters" without a goal is not + neither ever touches a car. Until someone sets a goal it charges from + surplus only, with no level. Someone who sets a goal wants it met: the + session no longer charges from surplus alone, and the plan and the reserve + plan from empty, while the level stays unknown and is never shown as a + number. The car stops when it is full. Clearing the goal brings + surplus-only back. The design's "or the cheapest quarters" without a goal is not built: with neither a level nor a goal there is no amount of energy to place. diff --git a/go/cmd/ftw/cars_wiring.go b/go/cmd/ftw/cars_wiring.go index 210bb081c..16b91311f 100644 --- a/go/cmd/ftw/cars_wiring.go +++ b/go/cmd/ftw/cars_wiring.go @@ -38,15 +38,14 @@ type evCarInputs struct { ChargeLimit float64 // Source names where the level comes from, for the planner's log. Source string - // MaxW is the car's own charger, 0 when unknown. - MaxW float64 } // evPlanCar reads the session's car off the loadpoint state the cars runtime // keeps (docs/cars.md). Before cars the planner read the charger's configured // capacity here, whatever car was plugged in. func evPlanCar(st loadpoint.State, rt *cars.Runtime) evCarInputs { - in := evCarInputs{CapacityWh: st.VehicleCapacityWh, InitialSoC: st.CurrentSoC, Source: "session", MaxW: st.CarMaxW} + // The charge limit is the one the reserve reads too (loadpoint.DeadlineNeed). + in := evCarInputs{CapacityWh: st.VehicleCapacityWh, InitialSoC: st.CurrentSoC, Source: "session", ChargeLimit: st.CarChargeLimit} if rt == nil { return in } @@ -54,41 +53,9 @@ func evPlanCar(st loadpoint.State, rt *cars.Runtime) evCarInputs { in.Source = "car:" + view.Source in.InitialSoC = view.Shown } - in.ChargeLimit = rt.PlanningLimit(st.ID) return in } -// limitPower holds a loadpoint's planned power to the session car's own -// charger: the lower of the charger's and the car's maximum. A step above the -// car's maximum is one the car cannot take. When the charger's lowest step -// is above it, the plan keeps that one step: the car takes what it can, and -// a plan with no step would never charge. -func (in evCarInputs) limitPower(st loadpoint.State, steps []float64) (minW, maxW float64, out []float64) { - minW, maxW = st.MinChargeW, st.MaxChargeW - if in.MaxW <= 0 || (maxW > 0 && in.MaxW >= maxW) { - return minW, maxW, steps - } - maxW = in.MaxW - lowest, kept := 0.0, false - for _, w := range steps { - if w <= maxW { - out = append(out, w) - kept = kept || w > 0 - } - if w > 0 && (lowest == 0 || w < lowest) { - lowest = w - } - } - if !kept && lowest > 0 { - out = append(out, lowest) - maxW = lowest - } - if minW > maxW { - minW = maxW - } - return minW, maxW, out -} - // configuredVehicleDrivers names the enabled drivers whose catalog entry // declares the vehicle capability: the sources a car can be bound to before // they report. diff --git a/go/cmd/ftw/cars_wiring_test.go b/go/cmd/ftw/cars_wiring_test.go index afce35364..a3157f328 100644 --- a/go/cmd/ftw/cars_wiring_test.go +++ b/go/cmd/ftw/cars_wiring_test.go @@ -90,22 +90,8 @@ func TestThePlanFollowsTheTappedCar(t *testing.T) { if st.TargetSoC != .6 || st.TargetTime.IsZero() || loadpoint.PlanningTarget(st, in.ChargeLimit) != .6 { t.Fatalf("goal %v by %v, want the Volvo's 60 %%", st.TargetSoC, st.TargetTime) } - minW, maxW, steps := in.limitPower(st, st.AllowedStepsW) + minW, maxW, steps := loadpoint.LimitPower(st, st.AllowedStepsW) if maxW != 7400 || minW != 4140 || len(steps) != 2 || steps[1] != 7360 { t.Fatalf("power %v–%v %v, want the Volvo's own 7.4 kW", minW, maxW, steps) } } - -// A car whose own charger takes less than the charger's lowest step still -// gets a plan: that step, which the car's charger limits, rather than none. -func TestACarBelowTheChargersLowestStepKeepsOneStep(t *testing.T) { - st := loadpoint.State{MinChargeW: 4140, MaxChargeW: 11000} - minW, maxW, steps := evCarInputs{MaxW: 3700}.limitPower(st, []float64{4140, 7360, 11000}) - if len(steps) != 1 || steps[0] != 4140 || maxW != 4140 || minW != 4140 { - t.Fatalf("power %v–%v %v, want the charger's lowest step", minW, maxW, steps) - } - minW, maxW, steps = evCarInputs{MaxW: 7400}.limitPower(st, []float64{4140, 7360, 11000}) - if len(steps) != 2 || maxW != 7400 || minW != 4140 { - t.Fatalf("power %v–%v %v", minW, maxW, steps) - } -} diff --git a/go/cmd/ftw/main.go b/go/cmd/ftw/main.go index e160af160..175b3e323 100644 --- a/go/cmd/ftw/main.go +++ b/go/cmd/ftw/main.go @@ -1738,7 +1738,7 @@ func main() { noBatteryToEV := !(ctrl.BatteryCoversEV || boostActive) ctrlMu.Unlock() charging, chargeDuration := lpMgr.ChargingPeriod(st.ID) - minW, maxW, steps := car.limitPower(st, planningStepsForLoadpoint(st, cfg)) + minW, maxW, steps := loadpoint.LimitPower(st, planningStepsForLoadpoint(st, cfg)) specs = append(specs, &mpc.LoadpointSpec{ ID: st.ID, CapacityWh: capWh, diff --git a/go/internal/cars/cars.go b/go/internal/cars/cars.go index c108ce252..a249b1606 100644 --- a/go/internal/cars/cars.go +++ b/go/internal/cars/cars.go @@ -225,6 +225,9 @@ type Runtime struct { // bound to a car before their first reading so an asleep car can be // woken. vehicleDrivers []string + // intentMoved is set while a sync hands a loadpoint a goal or surplus + // choice, so the tick replans. + intentMoved bool } // SetVehicleDrivers names the configured vehicle drivers. Each binds to the @@ -330,8 +333,12 @@ func (r *Runtime) Start(profiles []Profile) error { err = r.saveLocked() } acts := r.syncLocked(r.deps.Now()) + r.intentMoved = false r.mu.Unlock() run(acts) + // The goals the sync handed the loadpoints have no deadline until the + // schedules roll; the planner may run before the first control tick. + r.deps.Loadpoints.RollSchedules(r.deps.Now().UTC()) return err } @@ -647,7 +654,11 @@ func (r *Runtime) notify() { } } +// replan rolls the loadpoints' schedules and asks the planner to plan again. +// A goal a car brings clears the loadpoint's rolled deadline until the +// schedules roll, and a replan before that plans without the car. func (r *Runtime) replan(reason string) { + r.deps.Loadpoints.RollSchedules(r.deps.Now().UTC()) if r.deps.Replan != nil { r.deps.Replan(reason) } diff --git a/go/internal/cars/ops.go b/go/internal/cars/ops.go index f28369862..5b01d510d 100644 --- a/go/internal/cars/ops.go +++ b/go/internal/cars/ops.go @@ -353,6 +353,11 @@ func (r *Runtime) SetSessionGoal(chargerID string, goal *protocol.CarGoal, clear if _, err := lps.SetScheduleChecked(chargerID, next); err != nil { return protocol.ChargerCar{}, err } + if st.CarID == "" && surplusOnly == nil { + // A visitor's goal ends its surplus-only charging (GoalSaved); + // the loadpoint hears it now, before the replan. + lps.ApplySurplusOnly(chargerID, !next.HasTarget()) + } } if surplusOnly != nil { if _, _, err := lps.SetSurplusOnlyChecked(chargerID, *surplusOnly); err != nil { @@ -559,7 +564,16 @@ func (r *Runtime) GoalSaved(lpID string, s loadpoint.Schedule) (*loadpoint.Sched switch { case visitor: goal := goalOnly(s) - r.rec.Sessions[lpID].goal = &goal + sess := r.rec.Sessions[lpID] + sess.goal = &goal + // Someone who sets a visitor's goal wants it met: the session no + // longer charges from surplus alone, and plans from empty. With the + // goal gone it charges from surplus again. + sess.surplus = nil + if goal.HasTarget() { + off := false + sess.surplus = &off + } row := r.rowFor(lpID, s) return row, nil case car == nil: diff --git a/go/internal/cars/reserve_test.go b/go/internal/cars/reserve_test.go new file mode 100644 index 000000000..44e697860 --- /dev/null +++ b/go/internal/cars/reserve_test.go @@ -0,0 +1,170 @@ +package cars + +import ( + "math" + "testing" + "time" + + "github.com/srcfl/ftw/go/internal/loadpoint" + "github.com/srcfl/ftw/go/internal/mpc" + "github.com/srcfl/ftw/go/internal/telemetry" + "github.com/srcfl/ftw/go/protocol" +) + +// fallback is what the planner's reserve gives the Easee while it has no +// plan it accepts (#1657): whether it keeps a goal charging, and how hard. +func fallback(t *testing.T, h *harness) (ok bool, maxW float64) { + t.Helper() + svc := &mpc.Service{LoadpointStates: h.mgr.States} + d, _ := svc.SlotDirectiveAt(h.now) + return d.LoadpointFallback["easee"], d.LoadpointMaxPowerW["easee"] +} + +// A failed replan keeps the session car's goal charging, held to that car's +// own charger and level, the moment the box knows the car; never a guess for +// a car it does not know. +func TestAFailedReplanKeepsTheSessionCarsGoal(t *testing.T) { + volvoGoal := &protocol.CarGoal{TargetPermille: 600, ReadyByMin: 390, Days: 0x7f} + twoCars := func(t *testing.T) (*harness, protocol.Car, protocol.Car) { + h := newHarness(t, homeConfig(), map[string]loadpoint.Schedule{"easee": homeGoal(t)}, nil) + h.start() + tesla := h.only() + volvo := h.add(protocol.Car{Name: "Volvo", CapacityWh: 40000, MaxW: 7400, Goal: volvoGoal}) + return h, tesla, volvo + } + + t.Run("a single car at 80 % by 07:00", func(t *testing.T) { + h := newHarness(t, homeConfig(), map[string]loadpoint.Schedule{"easee": homeGoal(t)}, nil) + h.start() + h.plug("easee", "s1") + if ok, maxW := fallback(t, h); !ok || maxW != 11000 { + t.Fatalf("fallback %v at %v W", ok, maxW) + } + }) + t.Run("asking", func(t *testing.T) { + h, _, _ := twoCars(t) + h.plug("easee", "s1") + h.tick(61 * time.Second) + if st := h.state("easee"); st.CarIdentifiedBy != protocol.CarIdentifiedByAsking { + t.Fatalf("not asking: %s", st.CarIdentifiedBy) + } + if ok, _ := fallback(t, h); ok { + t.Fatal("a car the box asks about got the reserve") + } + }) + t.Run("a visitor with no goal", func(t *testing.T) { + h := newHarness(t, homeConfig(), map[string]loadpoint.Schedule{"easee": homeGoal(t)}, nil) + h.start() + if _, err := h.rt.UpdateCar(h.only().ID, Update{Identifiers: &[]string{"rfid-7"}}); err != nil { + t.Fatal(err) + } + h.rt.ChargerIdentified("easee", "rfid-guest") + h.plug("easee", "s1") + if ok, _ := fallback(t, h); ok { + t.Fatal("a visitor without a goal got the reserve") + } + }) + + h, tesla, volvo := twoCars(t) + if _, err := h.rt.SetLevel(volvo.ID, .3); err != nil { + t.Fatal(err) + } + h.plug("easee", "s1") + h.tick(61 * time.Second) + + // (d) A tap, and no control tick to roll the schedules after it. + if _, err := h.tap("easee", volvo.ID); err != nil { + t.Fatal(err) + } + if ok, maxW := fallback(t, h); !ok || maxW <= 0 || maxW > 7400 { + t.Fatalf("after the tap: fallback %v at %v W, want at most the Volvo's 7400", ok, maxW) + } + + // (e) From the Tesla to the Volvo: the Volvo's capacity and level. + if _, err := h.tap("easee", tesla.ID); err != nil { + t.Fatal(err) + } + if ok, maxW := fallback(t, h); !ok || maxW != 11000 { + t.Fatalf("the Tesla: fallback %v at %v W", ok, maxW) + } + if _, err := h.tap("easee", volvo.ID); err != nil { + t.Fatal(err) + } + st := h.state("easee") + need, peak, ok := loadpoint.DeadlineNeed(st) + want := (0.6 - 0.3) * 40000 / loadpoint.DefaultChargeEfficiency + if !ok || st.VehicleCapacityWh != 40000 || math.Abs(st.CurrentSoC-.3) > 1e-9 || math.Abs(need-want) > 1 || peak > 7400 { + t.Fatalf("the Volvo's need %v (want %v) at %v W, capacity %v, level %v", need, want, peak, st.VehicleCapacityWh, st.CurrentSoC) + } + + // (f) To a visitor: no reserve, in the same tick. + if _, err := h.tap("easee", ""); err != nil { + t.Fatal(err) + } + if ok, _ := fallback(t, h); ok { + t.Fatal("a visitor kept the Volvo's reserve") + } + + // (g) A visitor's goal: met, not surplus only, planned from empty, and + // the level still unknown. + if _, err := h.rt.SetSessionGoal("easee", &protocol.CarGoal{TargetPermille: 500, ReadyByMin: 420}, false, nil); err != nil { + t.Fatal(err) + } + st = h.state("easee") + if st.SurplusOnly || !st.SoCUnknown { + t.Fatalf("a visitor's goal: surplus only %v, unknown level %v", st.SurplusOnly, st.SoCUnknown) + } + if ok, _ := fallback(t, h); !ok { + t.Fatal("a visitor's goal got no reserve") + } + if need, _, _ := loadpoint.DeadlineNeed(st); math.Abs(need-0.5*76000/loadpoint.DefaultChargeEfficiency) > 1 { + t.Fatalf("a visitor's need %v, want it from empty", need) + } + h.tick(time.Second) + if h.state("easee").SurplusOnly { + t.Fatal("the next tick put surplus only back") + } + if _, err := h.rt.SetSessionGoal("easee", nil, true, nil); err != nil { + t.Fatal(err) + } + if st := h.state("easee"); !st.SurplusOnly { + t.Fatal("a visitor without a goal charges from surplus only again") + } +} + +// The reserve stops at the car's own charge limit while its level is its own +// exact reading, as the plan does. +func TestTheReserveStopsAtTheCarsChargeLimit(t *testing.T) { + h := newHarness(t, homeConfig(), map[string]loadpoint.Schedule{"easee": homeGoal(t)}, nil) + h.start() + car := h.only() + if _, err := h.rt.UpdateCar(car.ID, Update{Sources: &[]string{"tesla-ha"}}); err != nil { + t.Fatal(err) + } + h.plug("easee", "s1") + h.obs["tesla-ha"] = telemetry.VehicleObservation{Driver: "tesla-ha", SoC: .5, ChargeLimit: .7, At: h.now, ReportedAt: h.now} + h.tick(time.Second) + st := h.state("easee") + need, _, ok := loadpoint.DeadlineNeed(st) + if want := (0.7 - 0.5) * 76000 / loadpoint.DefaultChargeEfficiency; !ok || st.CarChargeLimit != .7 || math.Abs(need-want) > 50 { + t.Fatalf("need %v (want about %v), limit %v", need, want, st.CarChargeLimit) + } +} + +// A steady session does not replan every tick: the runtime replans when the +// car, its level source, its goal or its choices change. +func TestASteadySessionDoesNotReplanEachTick(t *testing.T) { + h := newHarness(t, homeConfig(), map[string]loadpoint.Schedule{"easee": homeGoal(t)}, nil) + replans := 0 + h.rt.deps.Replan = func(string) { replans++ } + h.start() + h.plug("easee", "s1") + h.tick(time.Second) + before := replans + for range 10 { + h.tick(time.Second) + } + if replans != before { + t.Fatalf("%d replans in ten quiet ticks", replans-before) + } +} diff --git a/go/internal/cars/tick.go b/go/internal/cars/tick.go index ff9877fd9..75c7bc322 100644 --- a/go/internal/cars/tick.go +++ b/go/internal/cars/tick.go @@ -42,12 +42,16 @@ func (r *Runtime) Tick() { changed = true } asks := r.askLocked(now) + r.intentMoved = false if len(asks) > 0 { // The chargers now say asking: listeners such as the plan push // should see it. changed = true } acts = append(acts, r.syncLocked(now)...) + // A goal or surplus choice the sync hands a loadpoint, as when someone + // set a visitor's goal through a charger route, is a plan input. + intentMoved := r.intentMoved if r.dirty || (r.levelSave && now.Sub(r.lastSave) >= levelSaveEvery) { if err := r.saveLocked(); err != nil { slog.Warn("cars: could not save; kept in memory", "err", err) @@ -59,7 +63,7 @@ func (r *Runtime) Tick() { for _, ev := range asks { r.deps.Bus.Publish(ev) } - if changed || levelChanged { + if changed || levelChanged || intentMoved { r.replan("car_changed") r.notify() } @@ -700,7 +704,9 @@ const maxChoose = 4 func (r *Runtime) syncLocked(now time.Time) []func() { lps := r.deps.Loadpoints var acts []func() - for _, st := range lps.States() { + states := lps.States() + byID := statesByID(states) + for _, st := range states { id := st.ID s := r.rec.Sessions[id] var ( @@ -714,6 +720,7 @@ func (r *Runtime) syncLocked(now time.Time) []func() { car = r.carLocked(s.CarID) if car != nil { b, goal, surplus = bindingFor(car), car.Goal, car.SurplusOnly + b.ChargeLimit = r.planningLimitLocked(car, byID) } else { // A visitor charges from surplus only until someone sets a // goal for the session. @@ -745,9 +752,11 @@ func (r *Runtime) syncLocked(now time.Time) []func() { // lands, not the one read here. next := goalOnly(goal) acts = append(acts, func() { lps.ApplyCarGoal(id, next, sameGoal) }) + r.intentMoved = true } if st.SurplusOnly != surplus { acts = append(acts, func() { lps.ApplySurplusOnly(id, surplus) }) + r.intentMoved = true } } if s != nil && st.PluggedIn && !s.seeded { @@ -764,7 +773,19 @@ func (r *Runtime) syncLocked(now time.Time) []func() { } func bindingFor(car *Car) loadpoint.CarBinding { - return loadpoint.CarBinding{ID: car.ID, Name: car.Name, CapacityWh: car.CapacityWh, MaxW: car.MaxW, Phases: car.Phases} + return loadpoint.CarBinding{ID: car.ID, Name: car.Name, CapacityWh: car.CapacityWh, MaxW: car.MaxW} +} + +// planningLimitLocked is the car's charge limit as the plan and the reserve +// read it: only while its level is its own exact reading. +func (r *Runtime) planningLimitLocked(car *Car, byID map[string]loadpoint.State) float64 { + if car.Level.Source != protocol.CarSoCVehicle { + return 0 + } + if _, energy := r.levelNowLocked(car, byID); estimate(car, energy) { + return 0 + } + return car.Level.ChargeLimit } // seedLocked is the level a session starts from. A car's level carries into diff --git a/go/internal/loadpoint/car.go b/go/internal/loadpoint/car.go index 936436d7e..e8e0c5fc8 100644 --- a/go/internal/loadpoint/car.go +++ b/go/internal/loadpoint/car.go @@ -29,9 +29,13 @@ type CarBinding struct { // holds. Zero is config.yaml's vehicle_capacity_wh, or the default when // that is unset: a visitor's capacity is not known either. CapacityWh float64 - // MaxW and Phases are the car's own charger. Zero is unknown. - MaxW float64 - Phases int + // MaxW is the most the car's own charger takes, zero when unknown. Its + // phases are in the rating already, so they bound nothing further. + MaxW float64 + // ChargeLimit is the car's own charge limit as the plan and the reserve + // read it: only while the level is the car's own exact reading. Zero is + // none. + ChargeLimit float64 } // LevelSeed starts a session's level from the car's. diff --git a/go/internal/loadpoint/deadline_need.go b/go/internal/loadpoint/deadline_need.go index 57b1cb967..2438c4d7f 100644 --- a/go/internal/loadpoint/deadline_need.go +++ b/go/internal/loadpoint/deadline_need.go @@ -4,8 +4,9 @@ import ( "math" ) -// DeadlineNeed is the remaining AC energy and legal on-power of a saved goal. -// It does not promise future site capacity or authorize a hardware command. +// DeadlineNeed is the remaining AC energy and legal on-power of a saved goal, +// held to the session car's own charger and charge limit as the plan is. It +// does not promise future site capacity or authorize a hardware command. func DeadlineNeed(st State) (energyWh, powerW float64, ok bool) { if !st.PluggedIn || st.ChargingDeclined || st.GoalComplete || st.TargetTime.IsZero() || !finite(st.CurrentSoC) || st.CurrentSoC < 0 || st.CurrentSoC > 1 || @@ -14,10 +15,9 @@ func DeadlineNeed(st State) (energyWh, powerW float64, ok bool) { !finite(st.MinChargeW) || !finite(st.MaxChargeW) || st.MinChargeW <= 0 || st.MaxChargeW < st.MinChargeW { return 0, 0, false } - target := st.TargetSoC - if st.FinishAtVehicleLimit { - target = 1 // The controller still stops at the car's own completion. - } + // A vehicle-limit goal aims at 1, and the controller still stops at the + // car's own completion; the car's own limit caps either. + target := PlanningTarget(st, st.CarChargeLimit) energyWh = (target - st.CurrentSoC) * st.VehicleCapacityWh / DefaultChargeEfficiency if energyWh <= 0 || !finite(energyWh) { return 0, 0, false @@ -26,14 +26,48 @@ func DeadlineNeed(st State) (energyWh, powerW float64, ok bool) { if !finite(step) || step < 0 || step > st.MaxChargeW { return 0, 0, false } - if step >= st.MinChargeW { + } + minW, maxW, steps := LimitPower(st, st.AllowedStepsW) + for _, step := range steps { + if step >= minW { powerW = math.Max(powerW, step) } } if len(st.AllowedStepsW) == 0 { - powerW = st.MaxChargeW + powerW = maxW + } + return energyWh, powerW, powerW >= minW +} + +// LimitPower holds a loadpoint's power to the session car's own charger: the +// lower of the charger's and the car's maximum, and the charger's steps up to +// it. When the charger's lowest step is above the car's maximum it keeps that +// one step: the car takes what it can, and a plan with no step would never +// charge. +func LimitPower(st State, steps []float64) (minW, maxW float64, out []float64) { + minW, maxW = st.MinChargeW, st.MaxChargeW + if st.CarMaxW <= 0 || (maxW > 0 && st.CarMaxW >= maxW) { + return minW, maxW, steps + } + maxW = st.CarMaxW + lowest, kept := 0.0, false + for _, w := range steps { + if w <= maxW { + out = append(out, w) + kept = kept || w > 0 + } + if w > 0 && (lowest == 0 || w < lowest) { + lowest = w + } + } + if !kept && lowest > 0 { + out = append(out, lowest) + maxW = lowest + } + if minW > maxW { + minW = maxW } - return energyWh, powerW, powerW >= st.MinChargeW + return minW, maxW, out } // CommandedEVPowerW uses the actual offers after every charger clamp. A diff --git a/go/internal/loadpoint/deadline_need_test.go b/go/internal/loadpoint/deadline_need_test.go new file mode 100644 index 000000000..d4bbb007d --- /dev/null +++ b/go/internal/loadpoint/deadline_need_test.go @@ -0,0 +1,52 @@ +package loadpoint + +import ( + "math" + "testing" + "time" +) + +// A car whose own charger takes less than the charger's lowest step still +// gets that step, rather than none; one that takes more than the charger +// leaves the charger's limits alone. +func TestLimitPowerKeepsTheChargersLowestStep(t *testing.T) { + st := State{MinChargeW: 4140, MaxChargeW: 11000, CarMaxW: 3700} + minW, maxW, steps := LimitPower(st, []float64{4140, 7360, 11000}) + if len(steps) != 1 || steps[0] != 4140 || maxW != 4140 || minW != 4140 { + t.Fatalf("power %v–%v %v, want the charger's lowest step", minW, maxW, steps) + } + st.CarMaxW = 7400 + if minW, maxW, steps = LimitPower(st, []float64{4140, 7360, 11000}); len(steps) != 2 || maxW != 7400 || minW != 4140 { + t.Fatalf("power %v–%v %v", minW, maxW, steps) + } + st.CarMaxW = 22000 + if _, maxW, steps = LimitPower(st, []float64{4140, 7360, 11000}); len(steps) != 3 || maxW != 11000 { + t.Fatalf("a car above the charger: %v %v", maxW, steps) + } +} + +// The reserve's need and on-power hold to the session car's own charger and +// its charge limit, as the plan does. +func TestDeadlineNeedHoldsToTheCar(t *testing.T) { + st := State{PluggedIn: true, VehicleCapacityWh: 40000, CurrentSoC: .3, TargetSoC: .8, TargetTime: time.Now().Add(3 * time.Hour), + MinChargeW: 4140, MaxChargeW: 11000, AllowedStepsW: []float64{0, 4140, 7360, 11000}} + if _, w, ok := DeadlineNeed(st); !ok || w != 11000 { + t.Fatalf("no car limit: %v %v", w, ok) + } + st.CarMaxW = 7400 + if _, w, ok := DeadlineNeed(st); !ok || w != 7360 { + t.Fatalf("a 7.4 kW car: %v %v", w, ok) + } + st.AllowedStepsW = nil + if _, w, ok := DeadlineNeed(st); !ok || w != 7400 { + t.Fatalf("a 7.4 kW car on a stepless charger: %v %v", w, ok) + } + st.CarChargeLimit = .6 + if need, _, ok := DeadlineNeed(st); !ok || math.Abs(need-(.6-.3)*40000/DefaultChargeEfficiency) > 1e-6 { + t.Fatalf("a 60 %% limit: %v %v", need, ok) + } + st.CurrentSoC = .6 + if _, _, ok := DeadlineNeed(st); ok { + t.Fatal("a car at its own limit still needs energy") + } +} diff --git a/go/internal/loadpoint/loadpoint.go b/go/internal/loadpoint/loadpoint.go index c770500cd..d27993533 100644 --- a/go/internal/loadpoint/loadpoint.go +++ b/go/internal/loadpoint/loadpoint.go @@ -197,10 +197,12 @@ type State struct { // one plug-in from the next. PluggedAt time.Time `json:"-"` SessionKey string `json:"-"` - // CarMaxW and CarPhases are the session car's own charger, zero when - // unknown. The planner holds power below them. - CarMaxW float64 `json:"-"` - CarPhases int `json:"-"` + // CarMaxW is the most the session car's own charger takes, and + // CarChargeLimit the car's charge limit while its level is exact; zero + // when unknown. The plan and the reserve hold to them (LimitPower, + // PlanningTarget). + CarMaxW float64 `json:"-"` + CarChargeLimit float64 `json:"-"` // MinChargeW / MaxChargeW / AllowedStepsW are repeated here so the // UI has everything for rendering in one fetch. @@ -1348,7 +1350,7 @@ func (lp *loadpointRuntime) snapshot() State { ChargingDeclined: lp.chargingDeclined, SoCRetention: lp.socRetention, CarMaxW: lp.car.MaxW, - CarPhases: lp.car.Phases, + CarChargeLimit: lp.car.ChargeLimit, CommandedW: lp.commandedW, CommandedReason: lp.commandedReason, CommandedKnown: lp.commandedKnown, From 6c3e915a5ae146daabb30e4bb31805f156161dbf Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Fri, 16 Oct 2026 09:41:23 +0200 Subject: [PATCH 4/4] fix(cars): the plan is pending before a charger shows a new car The tap test failed in CI with the Volvo's first plan not pending. The order was right: the feed hears of a change only from the planner, after RequestReplan marks the plan pending. But the feed folds changes within its 100 ms settle, and the test's replan failed at once without prices, so it had ended before the folded push was written. The test now holds the tap's replan in its read of the loadpoints, as a real solve takes seconds, and still asks that the first plan naming the Volvo be pending. A real gap remained: the loadpoint showed the new car a moment before the replan request marked the plan pending, and a push written then showed the car beside the old plan as current. The runtime now asks for a replan before it hands the loadpoints a change and again after the schedules roll; the second supersedes the first. Co-Authored-By: Claude Opus 5.5 --- go/cmd/ftw/cars_agents_test.go | 59 +++++++++++++++++++++++++++++----- go/internal/cars/cars.go | 13 ++++++++ go/internal/cars/ops.go | 5 +-- go/internal/cars/tick.go | 10 ++++-- 4 files changed, 72 insertions(+), 15 deletions(-) diff --git a/go/cmd/ftw/cars_agents_test.go b/go/cmd/ftw/cars_agents_test.go index c320b9ec7..dfe1c91ef 100644 --- a/go/cmd/ftw/cars_agents_test.go +++ b/go/cmd/ftw/cars_agents_test.go @@ -3,6 +3,8 @@ package main import ( "bytes" "path/filepath" + "sync" + "sync/atomic" "testing" "time" @@ -36,18 +38,47 @@ func carsOnEasee(t *testing.T, replan func(string)) (*loadpoint.Manager, *cars.R return mgr, rt, volvo } -// A tap reaches a streaming phone as a plan whose charger names the car: at -// once, pending, because the tap asked for a replan. +// A tap reaches a streaming phone as a plan whose charger names the car, and +// the first such plan says it is being made: the phone never sees the new car +// beside the old plan as if that were current. The tap's replan is held in +// its read of the loadpoints until the test lets it go, so it is still being +// made when the phone hears of the car, as a real solve would be. func TestATapReachesAStreamingPhoneWithTheCar(t *testing.T) { st, err := state.Open(filepath.Join(t.TempDir(), "state.db")) if err != nil { t.Fatal(err) } t.Cleanup(func() { st.Close() }) - svc := mpc.New(st, nil, "SE4", mpc.Params{Mode: mpc.ModeSelfConsumption, SoCLevels: 11, ActionLevels: 5, CapacityWh: 10000, + hour := time.Now().UTC().Truncate(time.Hour) + var prices []state.PricePoint + for i := range 6 { + prices = append(prices, state.PricePoint{ + Zone: "SE3", SlotTsMs: hour.Add(time.Duration(i) * time.Hour).UnixMilli(), SlotLenMin: 60, + SpotOreKwh: float64(140 - i*20), TotalOreKwh: float64(190 - i*20), Source: "test", FetchedAtMs: hour.UnixMilli(), + }) + } + if err := st.SavePrices(prices); err != nil { + t.Fatal(err) + } + 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: .5, SoCMin: .1, SoCMax: .95, MaxChargeW: 3000, MaxDischargeW: 3000, ChargeEfficiency: .95, DischargeEfficiency: .95}) - // Not started: the tap's replan request alone moves the plan. - svc.InstallPlan(mpc.Plan{GeneratedAtMs: time.Now().UnixMilli()}, svc.Defaults, "easee") + svc.Horizon = 5 * time.Hour + svc.BaseLoad = 500 + var held atomic.Bool + release := make(chan struct{}) + var releaseOnce sync.Once + letGo := func() { releaseOnce.Do(func() { close(release) }) } + t.Cleanup(letGo) + svc.Loadpoints = func(int) []*mpc.LoadpointSpec { + if held.Load() { + <-release + } + return nil + } mgr, rt, volvo := carsOnEasee(t, svc.RequestReplan) feed := appproto.NewPlanFeed(appproto.PlanSettle) @@ -56,14 +87,26 @@ func TestATapReachesAStreamingPhoneWithTheCar(t *testing.T) { mgr.ObserveSession("easee", true, 0, 0, true, "easee:EH1", "s1") rt.Tick() + // The plug-in's own replan and the feed's settle pass first. + for deadline := time.Now().Add(10 * time.Second); svc.IsReplanning() && time.Now().Before(deadline); { + time.Sleep(time.Millisecond) + } + time.Sleep(2 * appproto.PlanSettle) + + held.Store(true) + tapped := time.Now() if _, err := rt.SetSessionCar("easee", rt.ChargerCar("easee").Session, volvo.ID); err != nil { t.Fatal(err) } - tapped := time.Now() got := owner.waitPlan(t, func(p appproto.Plan) bool { return len(p.Chargers) == 1 && p.Chargers[0].CarID == volvo.ID }) - if took := time.Since(tapped); took > 200*time.Millisecond || !got.Chargers[0].Pending { - t.Fatalf("the tap reached the phone after %v, pending %v", took, got.Chargers[0].Pending) + took := time.Since(tapped) + if !got.Chargers[0].Pending { + t.Fatalf("the first plan naming the Volvo is not pending: %+v", got.Chargers[0]) + } + if took > appproto.PlanSettle+100*time.Millisecond { + t.Fatalf("the tap reached the phone after %v", took) } + letGo() } // An agent's redactor takes out what names a car, and its cars.get carries diff --git a/go/internal/cars/cars.go b/go/internal/cars/cars.go index a249b1606..8310c4930 100644 --- a/go/internal/cars/cars.go +++ b/go/internal/cars/cars.go @@ -654,6 +654,19 @@ func (r *Runtime) notify() { } } +// applyAndReplan hands the loadpoints what changed between two replan +// requests. The first marks the plan pending before any charger shows the +// change, so a phone that reads the plan in between sees it updating, never +// a new car beside the old plan as if that were current. The second, after +// the schedules roll, plans with the change and supersedes the first. +func (r *Runtime) applyAndReplan(acts []func(), reason string) { + if len(acts) > 0 && r.deps.Replan != nil { + r.deps.Replan(reason) + } + run(acts) + r.replan(reason) +} + // replan rolls the loadpoints' schedules and asks the planner to plan again. // A goal a car brings clears the loadpoint's rolled deadline until the // schedules roll, and a replan before that plans without the car. diff --git a/go/internal/cars/ops.go b/go/internal/cars/ops.go index 5b01d510d..893b48947 100644 --- a/go/internal/cars/ops.go +++ b/go/internal/cars/ops.go @@ -461,10 +461,7 @@ func (r *Runtime) commit(reason string, car *Car) (protocol.Car, error) { out = r.viewLocked(car, statesByID(r.deps.Loadpoints.States())) } r.mu.Unlock() - run(acts) - // The replan first: it tells the plan's listeners at once, so a phone - // sees the new car on its charger with the plan pending. - r.replan(reason) + r.applyAndReplan(acts, reason) r.notify() return out, nil } diff --git a/go/internal/cars/tick.go b/go/internal/cars/tick.go index 75c7bc322..4daf3d705 100644 --- a/go/internal/cars/tick.go +++ b/go/internal/cars/tick.go @@ -59,12 +59,16 @@ func (r *Runtime) Tick() { } r.mu.Unlock() - run(acts) + replan := changed || levelChanged || intentMoved + if replan { + r.applyAndReplan(acts, "car_changed") + } else { + run(acts) + } for _, ev := range asks { r.deps.Bus.Publish(ev) } - if changed || levelChanged || intentMoved { - r.replan("car_changed") + if replan { r.notify() } }