From b10e4c30969d77656bdfb7b9c6b6f6979661f654 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Thu, 17 Sep 2026 14:24:35 +0200 Subject: [PATCH 1/4] feat(loadpoint): keep vehicle-limit goals until the car completes Signed-off-by: Fredrik Ahlgren --- .changeset/vehicle-limit-goal.md | 4 + docs/architecture.md | 21 +++ go/cmd/ftw/main.go | 46 +++-- go/internal/api/api.go | 7 +- .../api/api_loadpoint_schedule_test.go | 22 +++ go/internal/loadpoint/controller.go | 11 +- go/internal/loadpoint/loadpoint.go | 100 +++++++--- go/internal/loadpoint/schedule.go | 11 +- go/internal/loadpoint/session_state.go | 7 +- go/internal/loadpoint/vehicle_completion.go | 65 +++++++ .../loadpoint/vehicle_completion_test.go | 171 ++++++++++++++++++ go/internal/loadpoint/vehicle_goal_state.go | 96 ++++++++++ 12 files changed, 508 insertions(+), 53 deletions(-) create mode 100644 .changeset/vehicle-limit-goal.md create mode 100644 go/internal/loadpoint/vehicle_completion.go create mode 100644 go/internal/loadpoint/vehicle_completion_test.go create mode 100644 go/internal/loadpoint/vehicle_goal_state.go diff --git a/.changeset/vehicle-limit-goal.md b/.changeset/vehicle-limit-goal.md new file mode 100644 index 000000000..9185bdf16 --- /dev/null +++ b/.changeset/vehicle-limit-goal.md @@ -0,0 +1,4 @@ +--- +"ftw": minor +--- +Support charging to the car's own limit as a distinct saved goal. Keep unfinished charging active after an estimated target or a missed deadline, retain the same session's deadline across restart, and preserve explicit percentage goals and safety limits. Clients must check Core support before offering this goal. diff --git a/docs/architecture.md b/docs/architecture.md index e2ceaca28..a1b91e047 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -33,6 +33,27 @@ A separate module requires a concrete benefit and: - no authority to bypass core's validation or safety limits; - a useful fallback or a cleanly unavailable state. +### Vehicle charge-limit goals + +A percentage goal and a goal to reach the car's own limit are distinct. +`GET /api/loadpoints` advertises `vehicle_limit_goal_supported`; clients must +require that flag before saving `schedule.finish_at_vehicle_limit`. Existing +percentage goals keep their meaning. In vehicle-limit mode, the planner uses +a fresh vehicle limit where available. Without one, 100% is a planning bound, +not a claimed vehicle setting. Final charging continues through Core's safety +clamps until the car stops accepting current; an estimate cannot prove it is +finished. A manual Stop still wins. + +The current connection's deadline uses verified charger and session identity. +It stays due after the deadline and survives restart when `goal_retention` is +`session`. `unavailable` means that identity is missing; `error` means the +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. A charger declining current is reported as a refusal, never +as an invented battery level or proof that the target was reached. + ## Product requirements across these boundaries Discovery, first-day models and controlled commissioning should establish diff --git a/go/cmd/ftw/main.go b/go/cmd/ftw/main.go index edeadd51a..05af12544 100644 --- a/go/cmd/ftw/main.go +++ b/go/cmd/ftw/main.go @@ -1627,7 +1627,7 @@ func main() { // no schedule, EV is left to the loadpoint controller's // reactive surplus-only behaviour. if effectiveTarget <= 0 || effectiveTargetTime.IsZero() || - !effectiveTargetTime.After(time.Now()) { + (!effectiveTargetTime.After(time.Now()) && !st.FinishAtVehicleLimit) { continue } // Pull capacity off the configured loadpoint. @@ -1674,31 +1674,15 @@ func main() { targetSlot = int(delta / (time.Duration(slotLenMin) * time.Minute)) } } - // Operational ceiling: the lower of the user's target - // and the vehicle-configured charge limit. The car - // won't accept current beyond charge_limit_pct anyway, - // so planning past it is wasted DP grid space. When - // the limit is unknown, fall back to the deadline - // target itself; never plan beyond what was requested. - maxSoC := effectiveTarget - if vehicleChargeLimit > 0 && vehicleChargeLimit < maxSoC { - maxSoC = vehicleChargeLimit + goal := st + goal.TargetSoC = effectiveTarget + if boostActive { + goal.FinishAtVehicleLimit = false } - // Effective deadline target: when the operator asked - // for 100% but the vehicle (Tesla via TeslaBLEProxy - // etc.) is hard-capped at, say, 60%, the DP must plan - // against the cap — otherwise the deadline-shortfall - // penalty stays elevated forever (the SoC grid maxes - // at the cap, can never reach the operator target), - // and MPC keeps committing grid charging chasing an - // unreachable goal. Cap target_pct to whatever the - // car will physically accept. - targetSoC := effectiveTarget - if vehicleChargeLimit > 0 && vehicleChargeLimit < targetSoC { - targetSoC = vehicleChargeLimit - slog.Info("mpc: target capped to vehicle charge limit", - "lp", st.ID, "operator_target", effectiveTarget, - "vehicle_limit", vehicleChargeLimit) + targetSoC := loadpoint.PlanningTarget(goal, vehicleChargeLimit) + maxSoC := targetSoC + if st.FinishAtVehicleLimit && !effectiveTargetTime.After(time.Now()) { + targetSlot = 0 } // Guard against degenerate grids: if current SoC > maxSoC // (already over target), grow the ceiling to current so @@ -2150,6 +2134,18 @@ func main() { return pick.Driver, pick.ChargingState, true }) + lpController.SetVehicleChargeState(func(lpID string) (loadpoint.VehicleChargeState, bool) { + st, ok := lpMgr.State(lpID) + if !ok || !st.PluggedIn { + return loadpoint.VehicleChargeState{}, false + } + pick := telemetry.PickBestVehicleForLoadpoint(tel, st.CurrentPowerW > loadpoint.DeliveringW, time.Now()) + if pick.Driver == "" || pick.Stale { + return loadpoint.VehicleChargeState{}, false + } + return loadpoint.VehicleChargeState{SoC: pick.SoC, Limit: pick.ChargeLimit, State: pick.ChargingState}, true + }) + // Wire the EV-available surplus computation for the // surplus_only clamp. We want the W of PV that exceeds house // load, regardless of how the home battery is currently diff --git a/go/internal/api/api.go b/go/internal/api/api.go index eb13a68bb..9a86340be 100644 --- a/go/internal/api/api.go +++ b/go/internal/api/api.go @@ -3429,7 +3429,7 @@ func (s *Server) handleEVChargers(w http.ResponseWriter, r *http.Request) { // among by charging_state ranking — see decorateWithVehicle. func (s *Server) handleLoadpoints(w http.ResponseWriter, r *http.Request) { if s.deps.Loadpoints == nil { - writeJSON(w, 200, map[string]any{"enabled": false, "loadpoints": []any{}}) + writeJSON(w, 200, map[string]any{"enabled": false, "loadpoints": []any{}, "vehicle_limit_goal_supported": true}) return } states := s.deps.Loadpoints.States() @@ -3440,8 +3440,9 @@ func (s *Server) handleLoadpoints(w http.ResponseWriter, r *http.Request) { s.decorateLoadpointsWithBatteryBoost(states) s.decorateLoadpointsWithPlan(states) writeJSON(w, 200, map[string]any{ - "enabled": true, - "loadpoints": states, + "enabled": true, + "vehicle_limit_goal_supported": true, + "loadpoints": states, }) } diff --git a/go/internal/api/api_loadpoint_schedule_test.go b/go/internal/api/api_loadpoint_schedule_test.go index 68cb3c664..b5f21f16d 100644 --- a/go/internal/api/api_loadpoint_schedule_test.go +++ b/go/internal/api/api_loadpoint_schedule_test.go @@ -301,3 +301,25 @@ func waitForSchedulePlan(t *testing.T, svc *mpc.Service) { time.Sleep(time.Millisecond) } } + +func TestVehicleLimitGoalCapabilityAndSchedule(t *testing.T) { + srv, mgr, _ := newScheduleServer(t) + rr := httptest.NewRecorder() + srv.Handler().ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/api/loadpoints", nil)) + var response map[string]any + if err := json.Unmarshal(rr.Body.Bytes(), &response); err != nil || response["vehicle_limit_goal_supported"] != true { + t.Fatal(rr.Body.String(), err) + } + saved := loadpoint.Schedule{} + mgr.SetScheduleSaver(func(_ string, s loadpoint.Schedule) error { saved = s; return nil }) + rr = putSchedule(t, srv, "garage", `{"soc":0.8,"finish_at_vehicle_limit":true,"time_of_day_min_utc":300,"recurring":true}`) + st, _ := mgr.State("garage") + if rr.Code != 200 || !saved.FinishAtVehicleLimit || saved.SoC != .8 || !st.FinishAtVehicleLimit || st.TargetSoC != 1 { + t.Fatal(rr.Code, saved, st) + } + rr = putSchedule(t, srv, "garage", `{"soc":0.8,"finish_at_vehicle_limit":false,"time_of_day_min_utc":300,"recurring":true}`) + st, _ = mgr.State("garage") + if rr.Code != 200 || st.FinishAtVehicleLimit || st.TargetSoC != .8 { + t.Fatal(rr.Code, st) + } +} diff --git a/go/internal/loadpoint/controller.go b/go/internal/loadpoint/controller.go index 14efaeb9b..e4d74785f 100644 --- a/go/internal/loadpoint/controller.go +++ b/go/internal/loadpoint/controller.go @@ -155,7 +155,8 @@ type Controller struct { // trigger a charge_start command when the EV detached mid- // session ("Stopped") while we're trying to deliver power. // nil disables the wake feature. - vehicleStatus func(loadpointID string) (driver, chargingState string, ok bool) + vehicleStatus func(loadpointID string) (driver, chargingState string, ok bool) + vehicleChargeState func(loadpointID string) (VehicleChargeState, bool) // peakRemainingSurplusW returns the peak PV-minus-load surplus // expected for the rest of the local day, used by surplus_only @@ -1759,6 +1760,11 @@ func (c *Controller) tickOne(ctx context.Context, now time.Time, lpCfg Config, s cmdW = 0 cmdReason = "no_plan_budget" } + finishW, finishing := c.vehicleCompletionOffer(lpCfg, now) + if finishing { + cmdW = finishW + cmdReason = "vehicle_limit_completion" + } // Surplus-only live clamp: regardless of what the MPC slot // budget said for this 15-minute window, the EV must not // import grid right now. We smooth the pause/resume decision @@ -1839,6 +1845,9 @@ func (c *Controller) tickOne(ctx context.Context, now time.Time, lpCfg Config, s cmdReason = "wake_kick" } } + if finishing && finishW == 0 { + cmdW, cmdReason = 0, "vehicle_complete" + } // Fuse protection: applied LAST (after MPC budget, surplus // clamp, wake-kick) so all upstream sources see their nominal // wantW; only the actual ceiling we send to the wallbox is diff --git a/go/internal/loadpoint/loadpoint.go b/go/internal/loadpoint/loadpoint.go index 6699f466a..c0438a574 100644 --- a/go/internal/loadpoint/loadpoint.go +++ b/go/internal/loadpoint/loadpoint.go @@ -10,6 +10,7 @@ package loadpoint import ( + "crypto/rand" "sort" "sync" "time" @@ -128,20 +129,23 @@ type State struct { // ChargingDeclined is a sustained vehicle-side refusal, not a battery level. ChargingDeclined bool `json:"charging_declined"` // SoCRetention reports whether the confirmed estimate can survive restart. - EnergySource string `json:"energy_source,omitempty"` - EnergyUpdatedAtMs int64 `json:"energy_updated_at_ms,omitempty"` - PowerUpdatedAtMs int64 `json:"power_updated_at_ms,omitempty"` - PowerUnavailable bool `json:"power_unavailable,omitempty"` - SoCRetention string `json:"soc_retention,omitempty"` - ID string `json:"id"` - DriverName string `json:"driver_name"` - PluggedIn bool `json:"plugged_in"` - CurrentSoC float64 `json:"current_soc"` // observed or estimated - CurrentPowerW float64 `json:"current_power_w"` // actual draw (site sign: + = charging) - DeliveredWhSession float64 `json:"delivered_wh_session"` // since plug-in - TargetSoC float64 `json:"target_soc"` // user intent - TargetTime time.Time `json:"target_time,omitempty"` // user intent - UpdatedAtMs int64 `json:"updated_at_ms"` + EnergySource string `json:"energy_source,omitempty"` + EnergyUpdatedAtMs int64 `json:"energy_updated_at_ms,omitempty"` + PowerUpdatedAtMs int64 `json:"power_updated_at_ms,omitempty"` + PowerUnavailable bool `json:"power_unavailable,omitempty"` + SoCRetention string `json:"soc_retention,omitempty"` + ID string `json:"id"` + DriverName string `json:"driver_name"` + PluggedIn bool `json:"plugged_in"` + CurrentSoC float64 `json:"current_soc"` // observed or estimated + CurrentPowerW float64 `json:"current_power_w"` // actual draw (site sign: + = charging) + DeliveredWhSession float64 `json:"delivered_wh_session"` // since plug-in + FinishAtVehicleLimit bool `json:"finish_at_vehicle_limit,omitempty"` + GoalComplete bool `json:"goal_complete,omitempty"` + GoalRetention string `json:"goal_retention,omitempty"` + TargetSoC float64 `json:"target_soc"` // user intent + TargetTime time.Time `json:"target_time,omitempty"` // user intent + UpdatedAtMs int64 `json:"updated_at_ms"` // Vehicle-side telemetry, populated by the API layer from the most // recent DerVehicle reading whose charging_state indicates a likely @@ -350,13 +354,20 @@ type loadpointRuntime struct { completionNotified bool Config - pluggedIn bool - currentSoC float64 - currentPowerW float64 - deliveredWhSession float64 - targetSoC float64 - targetTime time.Time - updatedAtMs int64 + pluggedIn bool + currentSoC float64 + currentPowerW float64 + deliveredWhSession float64 + finishAtVehicleLimit bool + finishGoalCompleted bool + finishGoalSavedCompleted bool + finishGoalChecked bool + finishGoalExplicit bool + finishGoalSaved time.Time + finishGoalRetention string + targetSoC float64 + targetTime time.Time + updatedAtMs int64 // Plug-in anchor: the SoC we believe the vehicle was at when // this session began. Persisted across Observe() calls so SoC @@ -556,6 +567,13 @@ func (m *Manager) Load(cfgs []Config) { lp.lastSavedEnergyWh = existing.lastSavedEnergyWh lp.lastSavedEnergyAt = existing.lastSavedEnergyAt lp.targetSoC = existing.targetSoC + lp.finishAtVehicleLimit = existing.finishAtVehicleLimit + lp.finishGoalCompleted = existing.finishGoalCompleted + lp.finishGoalSavedCompleted = existing.finishGoalSavedCompleted + lp.finishGoalChecked = existing.finishGoalChecked + lp.finishGoalExplicit = existing.finishGoalExplicit + lp.finishGoalSaved = existing.finishGoalSaved + lp.finishGoalRetention = existing.finishGoalRetention lp.targetTime = existing.targetTime lp.updatedAtMs = existing.updatedAtMs lp.sessionPluginSoC = existing.sessionPluginSoC @@ -901,6 +919,8 @@ func (m *Manager) SetTarget(id string, soc float64, targetTime time.Time) bool { lp.chargingDeclined = false lp.notRequestingSince = time.Time{} } + lp.finishAtVehicleLimit = false + lp.finishGoalCompleted = false lp.targetSoC = units.ClampFraction(soc) lp.targetTime = targetTime return true @@ -1134,6 +1154,9 @@ func (lp *loadpointRuntime) snapshot() State { CurrentPowerW: lp.currentPowerW, DeliveredWhSession: lp.deliveredWhSession, TargetSoC: lp.targetSoC, + FinishAtVehicleLimit: lp.finishAtVehicleLimit, + GoalRetention: lp.finishGoalRetention, + GoalComplete: lp.finishGoalCompleted, TargetTime: lp.targetTime, UpdatedAtMs: lp.updatedAtMs, MinChargeW: lp.MinChargeW, @@ -1203,6 +1226,17 @@ func (m *Manager) SetScheduleChecked(id string, s Schedule) (bool, error) { return false, nil } s.Normalize() + if s.FinishAtVehicleLimit { + s.IntentID = rand.Text() + if !s.Recurring { + s.FirstDeadlineMS = s.NextDeadlineUTC(m.now(), m.loc).UnixMilli() + } else { + s.FirstDeadlineMS = 0 + } + } else { + s.IntentID = "" + s.FirstDeadlineMS = 0 + } // 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 @@ -1220,6 +1254,10 @@ func (m *Manager) SetScheduleChecked(id string, s Schedule) (bool, error) { lp.notRequestingSince = time.Time{} } lp.schedule = s + lp.finishGoalCompleted = false + lp.finishGoalSavedCompleted = false + lp.finishGoalExplicit = true + lp.finishGoalSaved = time.Time{} // Force RollSchedules to re-evaluate on next call — operator just // changed the contract so any previous idempotence cache is stale. lp.lastRolledFor = time.Time{} @@ -1234,6 +1272,7 @@ func (m *Manager) SetScheduleChecked(id string, s Schedule) (bool, error) { // non-recurring saves. lp.targetTime = time.Time{} lp.targetSoC = 0 + lp.finishAtVehicleLimit = false return true, nil } @@ -1317,13 +1356,28 @@ func (m *Manager) RollSchedules(now time.Time) { if s.Empty() { continue } + // An unfinished vehicle-limit goal remains due after its deadline. + // Moving it to tomorrow would defer the remaining charge again. + if lp.finishGoalCompleted { + continue + } + if lp.finishAtVehicleLimit && lp.pluggedIn && !lp.chargingDeclined && !lp.targetTime.IsZero() { + continue + } next := s.NextDeadlineUTC(now, m.loc) + if s.FinishAtVehicleLimit && !s.Recurring && s.FirstDeadlineMS > 0 { + next = time.UnixMilli(s.FirstDeadlineMS) + } if s.Recurring { if !lp.targetTime.IsZero() && lp.targetTime.After(now) { continue } lp.targetTime = next + lp.finishAtVehicleLimit = s.FinishAtVehicleLimit lp.targetSoC = s.SoC + if s.FinishAtVehicleLimit { + lp.targetSoC = 1 + } lp.lastRolledFor = next continue } @@ -1332,7 +1386,11 @@ func (m *Manager) RollSchedules(now time.Time) { // re-save with a non-recurring schedule re-seeds. if lp.lastRolledFor.IsZero() { lp.targetTime = next + lp.finishAtVehicleLimit = s.FinishAtVehicleLimit lp.targetSoC = s.SoC + if s.FinishAtVehicleLimit { + lp.targetSoC = 1 + } lp.lastRolledFor = next } } diff --git a/go/internal/loadpoint/schedule.go b/go/internal/loadpoint/schedule.go index 9ef00dcf5..118f37ebc 100644 --- a/go/internal/loadpoint/schedule.go +++ b/go/internal/loadpoint/schedule.go @@ -23,6 +23,13 @@ import ( // Zero value (Empty) means "no schedule configured". Persistence keys // off this — Empty schedules are not written to disk. type Schedule struct { + // FinishAtVehicleLimit keeps the goal pending until the car ends charging. + // SoC remains the explicit percentage goal when this option is false. + FinishAtVehicleLimit bool `json:"finish_at_vehicle_limit,omitempty"` + // Core assigns these when the owner saves a vehicle-limit goal. They + // keep a completed one-shot goal distinct from a later explicit request. + IntentID string `json:"intent_id,omitempty"` + FirstDeadlineMS int64 `json:"first_deadline_ms,omitempty"` SoC float64 `json:"soc"` TimeOfDayMinUTC int `json:"time_of_day_min_utc"` // 0..1439 Recurring bool `json:"recurring"` @@ -78,14 +85,14 @@ func (s *Schedule) UnmarshalJSON(b []byte) error { // surplus clamps may add to it but never throttle it (see // Controller.surplusActive and surplusAddsToPlan, and the planner spec // gate in main.go). -func (s Schedule) HasTarget() bool { return s.SoC > 0 } +func (s Schedule) HasTarget() bool { return s.SoC > 0 || s.FinishAtVehicleLimit } // Empty reports whether the schedule carries no operator intent. The // persistence layer writes nothing on Empty so a stale-loadpoint // schedule on disk is naturally GC'd when the operator clears it via // the API. func (s Schedule) Empty() bool { - return s.SoC == 0 && s.TimeOfDayMinUTC == 0 && !s.Recurring && s.SurplusUnlockBatSoC == 0 + return !s.FinishAtVehicleLimit && s.SoC == 0 && s.TimeOfDayMinUTC == 0 && !s.Recurring && s.SurplusUnlockBatSoC == 0 } // NextDailyUTC returns the next time-of-day deadline (in UTC) strictly diff --git a/go/internal/loadpoint/session_state.go b/go/internal/loadpoint/session_state.go index 748c13e0d..17eeb087b 100644 --- a/go/internal/loadpoint/session_state.go +++ b/go/internal/loadpoint/session_state.go @@ -90,6 +90,7 @@ func (m *Manager) ObserveSample(id string, sample EVSample) { return } previousDevice, previousSession := lp.sessionDeviceID, lp.sessionID + wasPlugged := lp.pluggedIn regressed := pluggedIn && lp.pluggedIn && lp.energy != nil && lp.energy.counterRegressed(sample) firstSessionProof := deviceID != "" && previousDevice == deviceID && previousSession == "" && sessionID != "" && lp.pluggedIn && pluggedIn && !regressed @@ -97,6 +98,9 @@ func (m *Manager) ObserveSample(id string, sample EVSample) { // A changed session can arrive after an unseen unplug while core was // offline. Run the ordinary plug-in reset even if connected stayed true. if changed || regressed { + lp.finishGoalChecked = false + lp.finishGoalSaved = time.Time{} + lp.finishGoalRetention = "unavailable" m.nextSessionGeneration++ lp.sessionGeneration = m.nextSessionGeneration lp.pluggedIn = false @@ -134,7 +138,7 @@ func (m *Manager) ObserveSample(id string, sample EVSample) { confirmed := lp.socConfirmed && lp.pluggedIn m.mu.Unlock() - if !pluggedIn || regressed { + if (!pluggedIn && (wasPlugged || changed)) || regressed { // Tombstone the hardware record. A later reconnect cannot resurrect a // level from before an observed unplug or a session-counter reset. if m.sessionStore != nil { @@ -204,6 +208,7 @@ func (m *Manager) ObserveSample(id string, sample EVSample) { // the level the owner entered while waiting and now make it durable. m.persistSession(id) } + m.retainFinishGoal(id) _ = m.flushManualHold(id) } diff --git a/go/internal/loadpoint/vehicle_completion.go b/go/internal/loadpoint/vehicle_completion.go new file mode 100644 index 000000000..27f71c997 --- /dev/null +++ b/go/internal/loadpoint/vehicle_completion.go @@ -0,0 +1,65 @@ +package loadpoint + +import "time" + +// VehicleChargeState must come from a fresh, matched vehicle reading. A +// charger counter or an old BMS anchor cannot prove that charging is complete. +type VehicleChargeState struct { + SoC float64 + Limit float64 + State string +} + +func (c *Controller) SetVehicleChargeState(read func(string) (VehicleChargeState, bool)) { + if c != nil { + c.vehicleChargeState = read + } +} + +// PlanningTarget uses the actual car limit when available. A vehicle-limit +// goal with no such reading reserves up to 100%; that is a planning bound, +// not a claim about the car's charge limit or current battery level. +func PlanningTarget(st State, vehicleLimit float64) float64 { + target := st.TargetSoC + if st.FinishAtVehicleLimit { + target = 1 + } + if vehicleLimit > 0 && vehicleLimit <= 1 && vehicleLimit < target { + target = vehicleLimit + } + return target +} + +// Keep safe current available after the model runs out of estimated energy +// or a deadline passes. The car decides when it is done. Ordinary price +// scheduling runs until then; manual Stop and all safety clamps still win. +func (c *Controller) vehicleCompletionOffer(cfg Config, now time.Time) (float64, bool) { + st, ok := c.manager.State(cfg.ID) + if !ok || !st.FinishAtVehicleLimit || !st.PluggedIn { + return 0, false + } + if st.GoalComplete { + return 0, true + } + soc, target := st.CurrentSoC, PlanningTarget(st, 0) + if c.vehicleChargeState != nil { + if car, fresh := c.vehicleChargeState(cfg.ID); fresh { + if car.State == "Complete" { + c.manager.completeVehicleGoal(cfg.ID) + return 0, true + } + if st.ChargingDeclined && (car.State == "Charging" || car.State == "Starting") { + c.manager.RetryCharging(cfg.ID) + st.ChargingDeclined = false + } + soc, target = car.SoC, PlanningTarget(st, car.Limit) + } + } + if st.ChargingDeclined { + return 0, true + } + if soc < target && (st.TargetTime.IsZero() || st.TargetTime.After(now)) { + return 0, false + } + return cfg.MaxChargeW, true +} diff --git a/go/internal/loadpoint/vehicle_completion_test.go b/go/internal/loadpoint/vehicle_completion_test.go new file mode 100644 index 000000000..469ae2078 --- /dev/null +++ b/go/internal/loadpoint/vehicle_completion_test.go @@ -0,0 +1,171 @@ +package loadpoint + +import ( + "context" + "encoding/json" + "testing" + "time" +) + +func TestVehicleLimitPlanningDoesNotFinishAtEstimated80(t *testing.T) { + st := State{TargetSoC: .8, CurrentSoC: .800106752726535, FinishAtVehicleLimit: true} + if got := PlanningTarget(st, 0); got != 1 || got <= st.CurrentSoC { + t.Fatal(got) + } + if got := PlanningTarget(st, .9); got != .9 { + t.Fatal(got) + } + st.FinishAtVehicleLimit = false + if got := PlanningTarget(st, .9); got != .8 { + t.Fatal("changed explicit lower target", got) + } + if got := PlanningTarget(st, .7); got != .7 { + t.Fatal("ignored car limit", got) + } +} + +func TestVehicleLimitCompletionKeepsChargingAndSafetyWins(t *testing.T) { + now := time.Date(2026, 9, 17, 4, 0, 0, 0, time.UTC) + cfg := chargeNowLoadpoint() + cfg.PluginSoC = 1 // An exhausted estimate cannot prove the car is done. + samples := map[string]EVSample{cfg.DriverName: {Connected: true, RequestActive: true, PowerW: 3600}} + sender := &fakeSender{} + dir := &Directive{SlotStart: now.Add(-time.Second), SlotEnd: now.Add(time.Hour), LoadpointEnergyWh: map[string]float64{cfg.ID: 0}} + c := newTestController(t, []Config{cfg}, dir, samples, sender) + c.manager.SetSchedule(cfg.ID, Schedule{FinishAtVehicleLimit: true, TimeOfDayMinUTC: 5 * 60, Recurring: true}) + c.manager.RollSchedules(now) + c.SetSiteFuse(SiteFuse{MaxAmps: 16, Voltage: 230, PhaseCnt: 3}) + check := func(at time.Time, allowed bool, wantPositive bool) { + t.Helper() + c.TickWithDispatch(context.Background(), at, allowed) + last, ok := lastSetCurrent(sender.calls) + if !ok || (last.power > 0) != wantPositive { + t.Fatal(last, ok) + } + } + check(now, true, true) + c.SetFuseEVMax(func() (float64, bool) { return 0, true }) + check(now.Add(time.Second), true, false) + c.SetFuseEVMax(nil) + // A full safety standdown does not erase the pending goal. + check(now.Add(6*time.Minute), false, false) + check(now.Add(6*time.Minute+time.Second), true, true) + c.SetManualHold(cfg.ID, ManualHold{Persistent: true, PowerW: 0}) + check(now.Add(7*time.Minute), true, false) + c.ClearManualHold(cfg.ID) + check(now.Add(8*time.Minute), true, true) + // Fresh BMS below its limit must continue after a passed deadline. + c.SetVehicleChargeState(func(string) (VehicleChargeState, bool) { + return VehicleChargeState{SoC: .78, Limit: .8, State: "Charging"}, true + }) + c.manager.RollSchedules(now.Add(2 * time.Hour)) + check(now.Add(2*time.Hour), true, true) + st, _ := c.manager.State(cfg.ID) + if st.TargetTime.After(now.Add(time.Hour)) { + t.Fatal("unfinished goal moved to tomorrow", st.TargetTime) + } + // Complete is the car's choice, including a lower car-side limit. + c.SetVehicleChargeState(func(string) (VehicleChargeState, bool) { + return VehicleChargeState{SoC: .8, Limit: .8, State: "Complete"}, true + }) + samples[cfg.DriverName] = EVSample{Connected: true, RequestActive: false} + check(now.Add(2*time.Hour+time.Second), true, false) + // Stale vehicle information must not hold a false Complete indefinitely. + c.SetVehicleChargeState(func(string) (VehicleChargeState, bool) { + return VehicleChargeState{SoC: .8, Limit: .8, State: "Complete"}, false + }) + check(now.Add(2*time.Hour+2*time.Second), true, true) +} + +func TestVehicleLimitKeepsPricePauseBeforeFinishing(t *testing.T) { + now := time.Now().UTC() + cfg := chargeNowLoadpoint() + samples := map[string]EVSample{cfg.DriverName: {Connected: true, RequestActive: true}} + sender := &fakeSender{} + dir := &Directive{SlotStart: now.Add(-time.Second), SlotEnd: now.Add(time.Hour), LoadpointEnergyWh: map[string]float64{cfg.ID: 0}} + c := newTestController(t, []Config{cfg}, dir, samples, sender) + c.manager.SetSchedule(cfg.ID, Schedule{FinishAtVehicleLimit: true, TimeOfDayMinUTC: (now.Hour()*60 + now.Minute() + 60) % 1440, Recurring: true}) + c.manager.RollSchedules(now) + c.Tick(context.Background(), now) + if got, ok := lastSetCurrent(sender.calls); !ok || got.power != 0 { + t.Fatal(got) + } +} + +func TestVehicleLimitDeadlineSurvivesSameSessionRestart(t *testing.T) { + store := &sessionMemory{data: map[string]string{}} + before := time.Date(2026, 9, 17, 4, 0, 0, 0, time.UTC) + schedule := Schedule{FinishAtVehicleLimit: true, TimeOfDayMinUTC: 5 * 60, Recurring: true} + m := sessionManager(store, "garage", "charger") + m.SetSchedule("garage", schedule) + m.RollSchedules(before) + schedule, _ = m.GetSchedule("garage") + m.ObserveSession("garage", true, 4300, 1000, true, "easee:test", "session-a") + deadline := before.Add(time.Hour) + restart := func(session string) *Manager { + next := sessionManager(store, "renamed", "renamed-driver") + next.HydrateSchedules(func(string) (Schedule, bool) { return schedule, true }) + next.RollSchedules(before.Add(2 * time.Hour)) + next.ObserveSession("renamed", true, 4300, 1200, true, "easee:test", session) + return next + } + same := restart("session-a") + st, _ := same.State("renamed") + if st.TargetTime != deadline || st.GoalRetention != "session" || st.SoCSource != "assumed" { + t.Fatal(st) + } + next := restart("session-b") + st, _ = next.State("renamed") + if !st.TargetTime.After(before.Add(2 * time.Hour)) { + t.Fatal("old deadline crossed a plug session", st.TargetTime) + } +} + +func TestVehicleLimitScheduleRoundTripAndExplicitTarget(t *testing.T) { + var schedule Schedule + if err := json.Unmarshal([]byte(`{"finish_at_vehicle_limit":true,"time_of_day_min_utc":300,"recurring":true}`), &schedule); err != nil { + t.Fatal(err) + } + if schedule.Empty() || !schedule.HasTarget() { + t.Fatal(schedule) + } + m := sessionManager(&sessionMemory{data: map[string]string{}}, "garage", "charger") + m.SetSchedule("garage", schedule) + m.RollSchedules(time.Now()) + m.SetTarget("garage", .6, time.Now().Add(time.Hour)) + if st, _ := m.State("garage"); st.FinishAtVehicleLimit || st.TargetSoC != .6 { + t.Fatal(st) + } +} + +func TestVehicleLimitOneShotCompletionSurvivesNewSessionAndRestart(t *testing.T) { + store := &sessionMemory{data: map[string]string{}} + now := time.Date(2026, 9, 17, 4, 0, 0, 0, time.UTC) + m := sessionManager(store, "garage", "charger") + m.SetNowFn(func() time.Time { return now }) + m.SetSchedule("garage", Schedule{FinishAtVehicleLimit: true, TimeOfDayMinUTC: 5 * 60}) + m.RollSchedules(now) + goal, _ := m.GetSchedule("garage") + if goal.IntentID == "" || goal.FirstDeadlineMS != now.Add(time.Hour).UnixMilli() { + t.Fatal(goal) + } + m.ObserveSession("garage", true, 4300, 1000, true, "easee:test", "session-a") + m.completeVehicleGoal("garage") + m.ObserveSession("garage", false, 0, 1000, false, "easee:test", "") + next := sessionManager(store, "garage", "charger") + next.HydrateSchedules(func(string) (Schedule, bool) { return goal, true }) + next.RollSchedules(now.Add(2 * time.Hour)) + next.ObserveSession("garage", true, 0, 0, true, "easee:test", "session-b") + st, _ := next.State("garage") + if !st.GoalComplete || st.TargetSoC != 0 || st.GoalRetention != "session" { + t.Fatal(st) + } + // Saving the same visible settings creates a new explicit request. + next.SetSchedule("garage", goal) + next.RollSchedules(now) + next.ObserveSession("garage", true, 0, 0, true, "easee:test", "session-b") + st, _ = next.State("garage") + if st.GoalComplete || st.TargetSoC != 1 || st.Schedule.IntentID == goal.IntentID { + t.Fatal(st) + } +} diff --git a/go/internal/loadpoint/vehicle_goal_state.go b/go/internal/loadpoint/vehicle_goal_state.go new file mode 100644 index 000000000..d3a6bf39a --- /dev/null +++ b/go/internal/loadpoint/vehicle_goal_state.go @@ -0,0 +1,96 @@ +package loadpoint + +import ( + "encoding/json" + "time" +) + +type savedFinishGoal struct { + DeviceID string `json:"device_id"` + SessionID string `json:"session_id"` + Deadline time.Time `json:"deadline"` + Schedule Schedule `json:"schedule"` + Completed bool `json:"completed,omitempty"` +} + +func finishGoalKey(device string) string { return "ev_finish:" + sessionKey(device) } + +// Called under sessionMu after fresh charger identity has been observed. The +// deadline belongs to that physical connection, not to a guessed car SoC. +func (m *Manager) retainFinishGoal(id string) { + m.mu.Lock() + lp := m.byID[id] + if lp == nil || !lp.pluggedIn || !lp.finishAtVehicleLimit { + m.mu.Unlock() + return + } + if lp.sessionDeviceID == "" || lp.sessionID == "" || m.sessionStore == nil { + lp.finishGoalRetention = "unavailable" + m.mu.Unlock() + return + } + record := savedFinishGoal{lp.sessionDeviceID, lp.sessionID, lp.targetTime, lp.schedule, lp.finishGoalCompleted} + check := !lp.finishGoalChecked && !lp.finishGoalExplicit + lp.finishGoalChecked = true + m.mu.Unlock() + if check { + if raw, ok := m.sessionStore.LoadConfig(finishGoalKey(record.DeviceID)); ok { + var saved savedFinishGoal + if json.Unmarshal([]byte(raw), &saved) == nil && saved.DeviceID == record.DeviceID && (saved.SessionID == record.SessionID || (saved.Completed && !record.Schedule.Recurring)) && saved.Schedule == record.Schedule && !saved.Deadline.IsZero() { + m.mu.Lock() + if lp.finishGoalExplicit || !lp.finishAtVehicleLimit || lp.schedule != record.Schedule || lp.targetTime != record.Deadline { + m.mu.Unlock() + return + } + record.Deadline = saved.Deadline + record.Completed = saved.Completed + lp.finishGoalCompleted = saved.Completed + lp.finishGoalSavedCompleted = saved.Completed + if saved.Completed { + lp.targetSoC = 0 + } + lp.targetTime = saved.Deadline + lp.lastRolledFor = saved.Deadline + lp.finishGoalSaved = saved.Deadline + lp.finishGoalRetention = "session" + m.mu.Unlock() + } + } + } + m.mu.RLock() + due := !record.Deadline.IsZero() && (lp.finishGoalSaved != record.Deadline || lp.finishGoalSavedCompleted != record.Completed) + m.mu.RUnlock() + if !due { + return + } + raw, err := json.Marshal(record) + if err == nil { + err = m.sessionStore.SaveConfig(finishGoalKey(record.DeviceID), string(raw)) + } + m.mu.Lock() + if err != nil { + lp.finishGoalRetention = "error" + } else { + lp.finishGoalSaved = record.Deadline + lp.finishGoalSavedCompleted = record.Completed + lp.finishGoalRetention = "session" + } + m.mu.Unlock() +} + +// A one-shot goal stays finished across restart and later plug sessions. +// Recurring goals keep their ordinary daily schedule. +func (m *Manager) completeVehicleGoal(id string) { + m.sessionMu.Lock() + defer m.sessionMu.Unlock() + m.mu.Lock() + lp := m.byID[id] + if lp == nil || !lp.finishAtVehicleLimit || lp.schedule.Recurring || !lp.pluggedIn { + m.mu.Unlock() + return + } + lp.finishGoalCompleted = true + lp.targetSoC = 0 + m.mu.Unlock() + m.retainFinishGoal(id) +} From ebb28ef223a671eb2bd22a191375d7b530e35463 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Thu, 17 Sep 2026 15:42:05 +0200 Subject: [PATCH 2/4] fix(loadpoint): preserve completed goals and guard incompatible rollback Signed-off-by: Fredrik Ahlgren --- docs/architecture.md | 1 + .../loadpoint/vehicle_completion_test.go | 22 +++++++++++++++++++ go/internal/state/store.go | 2 +- state-schema.json | 2 +- 4 files changed, 25 insertions(+), 2 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index a1b91e047..c6210bdea 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -36,6 +36,7 @@ A separate module requires a concrete benefit and: ### Vehicle charge-limit goals A percentage goal and a goal to reach the car's own limit are distinct. +State schema 7 blocks rollback to a Core that would ignore the saved goal mode. `GET /api/loadpoints` advertises `vehicle_limit_goal_supported`; clients must require that flag before saving `schedule.finish_at_vehicle_limit`. Existing percentage goals keep their meaning. In vehicle-limit mode, the planner uses diff --git a/go/internal/loadpoint/vehicle_completion_test.go b/go/internal/loadpoint/vehicle_completion_test.go index 469ae2078..e0f028dc3 100644 --- a/go/internal/loadpoint/vehicle_completion_test.go +++ b/go/internal/loadpoint/vehicle_completion_test.go @@ -169,3 +169,25 @@ func TestVehicleLimitOneShotCompletionSurvivesNewSessionAndRestart(t *testing.T) t.Fatal(st) } } + +func TestVehicleLimitReadsCompleteAfterChargerDecline(t *testing.T) { + now := time.Now().UTC() + cfg := chargeNowLoadpoint() + samples := map[string]EVSample{cfg.DriverName: {Connected: true, RequestActive: false}} + c := newTestController(t, []Config{cfg}, &Directive{}, samples, &fakeSender{}) + c.manager.SetSchedule(cfg.ID, Schedule{FinishAtVehicleLimit: true, TimeOfDayMinUTC: 300}) + c.manager.RollSchedules(now) + c.manager.ObserveSample(cfg.ID, samples[cfg.DriverName]) + c.manager.mu.Lock() + c.manager.byID[cfg.ID].chargingDeclined = true + c.manager.mu.Unlock() + c.SetVehicleChargeState(func(string) (VehicleChargeState, bool) { + return VehicleChargeState{SoC: .8, Limit: .8, State: "Complete"}, true + }) + if watts, finish := c.vehicleCompletionOffer(cfg, now); watts != 0 || !finish { + t.Fatal(watts, finish) + } + if st, _ := c.manager.State(cfg.ID); !st.GoalComplete { + t.Fatal("fresh completion was hidden by charger refusal", st) + } +} diff --git a/go/internal/state/store.go b/go/internal/state/store.go index 58a1cef67..024b6c6e0 100644 --- a/go/internal/state/store.go +++ b/go/internal/state/store.go @@ -25,7 +25,7 @@ const ( // SchemaVersion identifies the on-disk state format for update rollback. // Increase it before a release that cannot safely reopen the same state.db // with the prior Core version. - SchemaVersion = 6 + SchemaVersion = 7 // HotRetention = 30 days at 5s resolution HotRetention = 30 * 24 * time.Hour // WarmRetention = 12 months at 15-min buckets diff --git a/state-schema.json b/state-schema.json index e94fe1b7c..15988ef68 100644 --- a/state-schema.json +++ b/state-schema.json @@ -1,3 +1,3 @@ { - "version": 6 + "version": 7 } From 1dc96e84d6ed9fad65bac4ec4101327f014f35fd Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Thu, 17 Sep 2026 15:48:31 +0200 Subject: [PATCH 3/4] fix(loadpoint): reject ambiguous completion and roll finished recurring goals Signed-off-by: Fredrik Ahlgren --- docs/architecture.md | 4 +- go/cmd/ftw/main.go | 8 +- go/internal/loadpoint/controller.go | 5 +- go/internal/loadpoint/loadpoint.go | 9 ++- go/internal/loadpoint/vehicle_completion.go | 36 ++++++++- .../loadpoint/vehicle_completion_test.go | 81 ++++++++++++++++++- go/internal/loadpoint/vehicle_goal_state.go | 21 ++++- go/internal/telemetry/vehicle.go | 10 +++ go/internal/telemetry/vehicle_test.go | 19 +++++ 9 files changed, 176 insertions(+), 17 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index c6210bdea..20bb12a7d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -52,7 +52,9 @@ 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. A charger declining current is reported as a refusal, never +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 as an invented battery level or proof that the target was reached. ## Product requirements across these boundaries diff --git a/go/cmd/ftw/main.go b/go/cmd/ftw/main.go index 05af12544..11109e344 100644 --- a/go/cmd/ftw/main.go +++ b/go/cmd/ftw/main.go @@ -2135,12 +2135,8 @@ func main() { }) lpController.SetVehicleChargeState(func(lpID string) (loadpoint.VehicleChargeState, bool) { - st, ok := lpMgr.State(lpID) - if !ok || !st.PluggedIn { - return loadpoint.VehicleChargeState{}, false - } - pick := telemetry.PickBestVehicleForLoadpoint(tel, st.CurrentPowerW > loadpoint.DeliveringW, time.Now()) - if pick.Driver == "" || pick.Stale { + pick := telemetry.PickVehicleForCompletion(tel, time.Now()) + if pick.Driver == "" || pick.Stale || !lpMgr.VehicleObservationApplies(lpID, pick.UpdatedAt) { return loadpoint.VehicleChargeState{}, false } return loadpoint.VehicleChargeState{SoC: pick.SoC, Limit: pick.ChargeLimit, State: pick.ChargingState}, true diff --git a/go/internal/loadpoint/controller.go b/go/internal/loadpoint/controller.go index e4d74785f..a818cbed7 100644 --- a/go/internal/loadpoint/controller.go +++ b/go/internal/loadpoint/controller.go @@ -1846,7 +1846,10 @@ func (c *Controller) tickOne(ctx context.Context, now time.Time, lpCfg Config, s } } if finishing && finishW == 0 { - cmdW, cmdReason = 0, "vehicle_complete" + cmdW, cmdReason = 0, "vehicle_not_requesting" + if state, ok := c.manager.State(lpCfg.ID); ok && state.GoalComplete { + cmdReason = "vehicle_complete" + } } // Fuse protection: applied LAST (after MPC budget, surplus // clamp, wake-kick) so all upstream sources see their nominal diff --git a/go/internal/loadpoint/loadpoint.go b/go/internal/loadpoint/loadpoint.go index c0438a574..c856377bc 100644 --- a/go/internal/loadpoint/loadpoint.go +++ b/go/internal/loadpoint/loadpoint.go @@ -355,6 +355,7 @@ type loadpointRuntime struct { Config pluggedIn bool + connectionObservedAt time.Time currentSoC float64 currentPowerW float64 deliveredWhSession float64 @@ -558,6 +559,7 @@ func (m *Manager) Load(cfgs []Config) { // SoC reference and reset the estimate back to // PluginSoC even though delivered_wh has grown. lp.pluggedIn = existing.pluggedIn + lp.connectionObservedAt = existing.connectionObservedAt lp.currentSoC = existing.currentSoC lp.currentPowerW = existing.currentPowerW lp.deliveredWhSession = existing.deliveredWhSession @@ -744,6 +746,7 @@ func (m *Manager) observe(id string, pluggedIn bool, powerW, deliveredWh float64 fired = append(fired, events.ChargingConnected{LoadpointID: id, At: now}) } if pluggedIn && !lp.pluggedIn { + lp.connectionObservedAt = now // Plug-in transition: seed the session anchor and clear any // session-completion latched from a prior session. anchor := lp.PluginSoC @@ -1359,7 +1362,11 @@ func (m *Manager) RollSchedules(now time.Time) { // An unfinished vehicle-limit goal remains due after its deadline. // Moving it to tomorrow would defer the remaining charge again. if lp.finishGoalCompleted { - continue + if !s.Recurring || lp.targetTime.After(now) { + continue + } + lp.finishGoalCompleted = false + lp.targetTime = time.Time{} } if lp.finishAtVehicleLimit && lp.pluggedIn && !lp.chargingDeclined && !lp.targetTime.IsZero() { continue diff --git a/go/internal/loadpoint/vehicle_completion.go b/go/internal/loadpoint/vehicle_completion.go index 27f71c997..c9a758785 100644 --- a/go/internal/loadpoint/vehicle_completion.go +++ b/go/internal/loadpoint/vehicle_completion.go @@ -10,6 +10,24 @@ type VehicleChargeState struct { State string } +// 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. +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 +} + func (c *Controller) SetVehicleChargeState(read func(string) (VehicleChargeState, bool)) { if c != nil { c.vehicleChargeState = read @@ -38,24 +56,34 @@ func (c *Controller) vehicleCompletionOffer(cfg Config, now time.Time) (float64, if !ok || !st.FinishAtVehicleLimit || !st.PluggedIn { return 0, false } - if st.GoalComplete { + if st.GoalComplete && !st.Schedule.Recurring { return 0, true } soc, target := st.CurrentSoC, PlanningTarget(st, 0) if c.vehicleChargeState != nil { if car, fresh := c.vehicleChargeState(cfg.ID); fresh { if car.State == "Complete" { - c.manager.completeVehicleGoal(cfg.ID) - return 0, true + if st.CurrentPowerW < DeliveringW { + c.manager.completeVehicleGoal(cfg.ID) + return 0, true + } + // Measured delivery contradicts Complete. Do not retain it. + return cfg.MaxChargeW, true } if st.ChargingDeclined && (car.State == "Charging" || car.State == "Starting") { c.manager.RetryCharging(cfg.ID) st.ChargingDeclined = false } + if st.GoalComplete && (car.State == "Charging" || car.State == "Starting" || (car.Limit > 0 && car.SoC < car.Limit)) { + c.manager.resumeRecurringVehicleGoal(cfg.ID) + c.manager.RetryCharging(cfg.ID) + st.GoalComplete = false + st.ChargingDeclined = false + } soc, target = car.SoC, PlanningTarget(st, car.Limit) } } - if st.ChargingDeclined { + if st.GoalComplete || st.ChargingDeclined { return 0, true } if soc < target && (st.TargetTime.IsZero() || st.TargetTime.After(now)) { diff --git a/go/internal/loadpoint/vehicle_completion_test.go b/go/internal/loadpoint/vehicle_completion_test.go index e0f028dc3..6de2b8ec6 100644 --- a/go/internal/loadpoint/vehicle_completion_test.go +++ b/go/internal/loadpoint/vehicle_completion_test.go @@ -70,11 +70,11 @@ func TestVehicleLimitCompletionKeepsChargingAndSafetyWins(t *testing.T) { }) samples[cfg.DriverName] = EVSample{Connected: true, RequestActive: false} check(now.Add(2*time.Hour+time.Second), true, false) - // Stale vehicle information must not hold a false Complete indefinitely. + // Losing telemetry must not erase a completion already confirmed. c.SetVehicleChargeState(func(string) (VehicleChargeState, bool) { return VehicleChargeState{SoC: .8, Limit: .8, State: "Complete"}, false }) - check(now.Add(2*time.Hour+2*time.Second), true, true) + check(now.Add(2*time.Hour+2*time.Second), true, false) } func TestVehicleLimitKeepsPricePauseBeforeFinishing(t *testing.T) { @@ -191,3 +191,80 @@ func TestVehicleLimitReadsCompleteAfterChargerDecline(t *testing.T) { t.Fatal("fresh completion was hidden by charger refusal", st) } } + +func TestVehicleLimitRejectsPreviousConnectionAndAmbiguousLoadpoint(t *testing.T) { + m := NewManager() + now := time.Now().UTC() + m.SetNowFn(func() time.Time { return now }) + m.Load([]Config{{ID: "one", DriverName: "charger-one"}, {ID: "two", DriverName: "charger-two"}}) + m.ObserveSession("one", true, 0, 0, true, "device-one", "session-a") + if m.VehicleObservationApplies("one", now.Add(-time.Second)) || m.VehicleObservationApplies("one", time.Time{}) { + t.Fatal("reading from before this connection could finish the goal") + } + if !m.VehicleObservationApplies("one", now) { + 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") + } + m.ObserveSession("two", false, 0, 0, false, "device-two", "") + now = now.Add(time.Minute) + m.ObserveSession("one", true, 0, 0, true, "device-one", "session-c") + if m.VehicleObservationApplies("one", now.Add(-time.Second)) { + t.Fatal("old reading crossed a hardware session change") + } +} + +func TestVehicleLimitMeasuredDeliveryRejectsComplete(t *testing.T) { + cfg := chargeNowLoadpoint() + m := sessionManager(&sessionMemory{data: map[string]string{}}, cfg.ID, cfg.DriverName) + m.SetSchedule(cfg.ID, Schedule{FinishAtVehicleLimit: true, TimeOfDayMinUTC: 300}) + m.RollSchedules(time.Now()) + m.ObserveSession(cfg.ID, true, 3600, 1000, true, "charger", "session") + c := &Controller{manager: m} + c.SetVehicleChargeState(func(string) (VehicleChargeState, bool) { + return VehicleChargeState{SoC: .8, Limit: .8, State: "Complete"}, true + }) + if watts, finish := c.vehicleCompletionOffer(cfg, time.Now()); watts <= 0 || !finish { + t.Fatal(watts, finish) + } + if st, _ := m.State(cfg.ID); st.GoalComplete { + t.Fatal("retained completion while charger still measured delivery") + } +} + +func TestVehicleLimitRecurringCompleteRollsAndNewDemandReopens(t *testing.T) { + cfg := chargeNowLoadpoint() + now := time.Date(2026, 9, 17, 4, 0, 0, 0, time.UTC) + m := sessionManager(&sessionMemory{data: map[string]string{}}, cfg.ID, cfg.DriverName) + m.SetNowFn(func() time.Time { return now }) + m.SetSchedule(cfg.ID, Schedule{FinishAtVehicleLimit: true, Recurring: true, TimeOfDayMinUTC: 300}) + m.RollSchedules(now) + m.ObserveSession(cfg.ID, true, 0, 0, true, "charger", "session") + c := &Controller{manager: m} + car := VehicleChargeState{SoC: .8, Limit: .8, State: "Complete"} + c.SetVehicleChargeState(func(string) (VehicleChargeState, bool) { return car, true }) + c.vehicleCompletionOffer(cfg, now) + if st, _ := m.State(cfg.ID); !st.GoalComplete { + t.Fatal("recurring completion not recorded", st) + } + // Raising the car limit while it stays plugged in reopens today's goal. + car = VehicleChargeState{SoC: .8, Limit: .9, State: "NoPower"} + c.vehicleCompletionOffer(cfg, now) + if st, _ := m.State(cfg.ID); st.GoalComplete || st.TargetSoC != 1 { + t.Fatal("new vehicle demand did not reopen recurring goal", st) + } + car = VehicleChargeState{SoC: .9, Limit: .9, State: "Complete"} + c.vehicleCompletionOffer(cfg, now) + now = now.Add(2 * time.Hour) + m.RollSchedules(now) + st, _ := m.State(cfg.ID) + if st.GoalComplete || !st.TargetTime.After(now) || st.TargetTime.Hour() != 5 { + t.Fatal("completed recurring goal kept old deadline", st) + } + c.SetVehicleChargeState(nil) + if watts, override := c.vehicleCompletionOffer(cfg, now); override || watts != 0 { + t.Fatal("missing telemetry converted next day's goal to immediate max charge", watts, override) + } +} diff --git a/go/internal/loadpoint/vehicle_goal_state.go b/go/internal/loadpoint/vehicle_goal_state.go index d3a6bf39a..ddf170bcf 100644 --- a/go/internal/loadpoint/vehicle_goal_state.go +++ b/go/internal/loadpoint/vehicle_goal_state.go @@ -79,13 +79,13 @@ func (m *Manager) retainFinishGoal(id string) { } // A one-shot goal stays finished across restart and later plug sessions. -// Recurring goals keep their ordinary daily schedule. +// A recurring goal can roll to the next deadline once this one is complete. func (m *Manager) completeVehicleGoal(id string) { m.sessionMu.Lock() defer m.sessionMu.Unlock() m.mu.Lock() lp := m.byID[id] - if lp == nil || !lp.finishAtVehicleLimit || lp.schedule.Recurring || !lp.pluggedIn { + if lp == nil || !lp.finishAtVehicleLimit || !lp.pluggedIn { m.mu.Unlock() return } @@ -94,3 +94,20 @@ func (m *Manager) completeVehicleGoal(id string) { m.mu.Unlock() m.retainFinishGoal(id) } + +// Fresh evidence of renewed demand reopens a recurring goal, for example +// when the owner raises the car's limit while it stays connected. +func (m *Manager) resumeRecurringVehicleGoal(id string) { + m.sessionMu.Lock() + defer m.sessionMu.Unlock() + m.mu.Lock() + lp := m.byID[id] + if lp == nil || !lp.finishAtVehicleLimit || !lp.schedule.Recurring || !lp.pluggedIn { + m.mu.Unlock() + return + } + lp.finishGoalCompleted = false + lp.targetSoC = 1 + m.mu.Unlock() + m.retainFinishGoal(id) +} diff --git a/go/internal/telemetry/vehicle.go b/go/internal/telemetry/vehicle.go index 5d349bd9d..c1c54766c 100644 --- a/go/internal/telemetry/vehicle.go +++ b/go/internal/telemetry/vehicle.go @@ -103,6 +103,16 @@ func PickBestVehicleForLoadpoint(s *Store, lpDeliveringPower bool, now time.Time return pickBestVehicle(s, minRank, now) } +// 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) +} + func pickBestVehicle(s *Store, minRank int, now time.Time) VehiclePick { if s == nil { return VehiclePick{} diff --git a/go/internal/telemetry/vehicle_test.go b/go/internal/telemetry/vehicle_test.go index 640d6dc27..94e745083 100644 --- a/go/internal/telemetry/vehicle_test.go +++ b/go/internal/telemetry/vehicle_test.go @@ -28,6 +28,25 @@ func TestVehicleConnectedRankOrdering(t *testing.T) { } } +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. From 82c5a86db7f63cfc4b3cf4bae1fd7e3ef90ddb57 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Thu, 17 Sep 2026 15:50:10 +0200 Subject: [PATCH 4/4] fix(loadpoint): reopen recurring goals for a new plug session Signed-off-by: Fredrik Ahlgren --- go/internal/loadpoint/loadpoint.go | 6 ++++++ go/internal/loadpoint/vehicle_completion_test.go | 6 ++++++ 2 files changed, 12 insertions(+) diff --git a/go/internal/loadpoint/loadpoint.go b/go/internal/loadpoint/loadpoint.go index c856377bc..03ac0797e 100644 --- a/go/internal/loadpoint/loadpoint.go +++ b/go/internal/loadpoint/loadpoint.go @@ -747,6 +747,12 @@ func (m *Manager) observe(id string, pluggedIn bool, powerW, deliveredWh float64 } if pluggedIn && !lp.pluggedIn { lp.connectionObservedAt = now + if lp.finishAtVehicleLimit && lp.schedule.Recurring && lp.finishGoalCompleted { + lp.finishGoalCompleted = false + lp.targetSoC = 1 + lp.targetTime = lp.schedule.NextDeadlineUTC(now, m.loc) + lp.lastRolledFor = lp.targetTime + } // Plug-in transition: seed the session anchor and clear any // session-completion latched from a prior session. anchor := lp.PluginSoC diff --git a/go/internal/loadpoint/vehicle_completion_test.go b/go/internal/loadpoint/vehicle_completion_test.go index 6de2b8ec6..096eea033 100644 --- a/go/internal/loadpoint/vehicle_completion_test.go +++ b/go/internal/loadpoint/vehicle_completion_test.go @@ -267,4 +267,10 @@ func TestVehicleLimitRecurringCompleteRollsAndNewDemandReopens(t *testing.T) { if watts, override := c.vehicleCompletionOffer(cfg, now); override || watts != 0 { t.Fatal("missing telemetry converted next day's goal to immediate max charge", watts, override) } + m.completeVehicleGoal(cfg.ID) + m.ObserveSession(cfg.ID, false, 0, 0, false, "charger", "") + m.ObserveSession(cfg.ID, true, 0, 0, true, "charger", "next-session") + if st, _ := m.State(cfg.ID); st.GoalComplete || st.TargetSoC != 1 || !st.TargetTime.After(now) { + t.Fatal("completed recurring goal suppressed a new car session", st) + } }