From ab8f5ac718d0a511558996adfe39ac9ddb88d802 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 18 Aug 2026 17:25:00 +0000 Subject: [PATCH 01/57] fix(mpc): surplus-only EV can take leftover PV beside battery grid-charge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surplus-only is an EV policy, not a site import ban. The car may use PV left after house load in the same slot the home battery buys from the grid. Live surplus and the near-term 3Φ gate follow the same accounting. Add a joined EV site harness (plan → charger Tick → ComputeDispatch → site identity). Isolated planner, charger and dispatch suites never ran on one clock, which is why the combo stayed invisible. Signed-off-by: Cursor Agent Co-authored-by: Fredrik Ahlgren --- .../surplus-only-ev-pv-beside-battery-grid.md | 5 + go/cmd/ftw/main.go | 90 +--- go/internal/control/ev_site_harness_test.go | 510 ++++++++++++++++++ go/internal/control/ev_site_test.go | 202 +++++++ go/internal/loadpoint/surplus_reserve.go | 48 ++ go/internal/loadpoint/surplus_reserve_test.go | 46 ++ go/internal/mpc/external_optimizer.go | 4 +- go/internal/mpc/external_optimizer_test.go | 28 + go/internal/mpc/loadpoint_service_test.go | 62 ++- go/internal/mpc/loadpoint_spec.go | 31 +- go/internal/mpc/loadpoint_spec_test.go | 24 +- go/internal/mpc/mpc.go | 33 +- optimizer/ftw_optimizer/model.py | 8 +- optimizer/tests/test_model.py | 35 ++ 14 files changed, 1023 insertions(+), 103 deletions(-) create mode 100644 .changeset/surplus-only-ev-pv-beside-battery-grid.md create mode 100644 go/internal/control/ev_site_harness_test.go create mode 100644 go/internal/control/ev_site_test.go diff --git a/.changeset/surplus-only-ev-pv-beside-battery-grid.md b/.changeset/surplus-only-ev-pv-beside-battery-grid.md new file mode 100644 index 00000000..480bdeac --- /dev/null +++ b/.changeset/surplus-only-ev-pv-beside-battery-grid.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +A surplus-only EV can take leftover PV while the home battery buys from the grid. Surplus-only is an EV policy, not a site-wide import ban: the car still cannot import, and the home battery still cannot feed the car. diff --git a/go/cmd/ftw/main.go b/go/cmd/ftw/main.go index 92bf2ca7..3f734e0c 100644 --- a/go/cmd/ftw/main.go +++ b/go/cmd/ftw/main.go @@ -1471,31 +1471,12 @@ func main() { if lpController != nil { lpController.SetGridDeferred(st.ID, deferGridPlan) } - // Surplus-only sources, in order of precedence: - // 1. Operator's explicit surplus_only flag on the LP - // 2. MPC grid-funded planning deferral (target past - // published prices) - // 3. Runtime bat-SoC unlock arming — when the home - // battery is at/above the schedule's threshold AND - // live PV surplus is available, the dispatch layer - // already treats the LP as surplus-only. Without - // threading it into the MPC spec here, the plan - // would prescribe battery→EV transfers that - // dispatch then has to censor — producing - // misleading slot entries the operator sees in - // /api/mpc/plan that never actually execute. - batSoCArmed := false - if lpController != nil { - batSoCArmed = lpController.IsBatSoCArmed(st.ID) - } - // NoBatteryToEV mirrors the site-wide ctrl.BatteryCoversEV - // flag (inverted). Plumbing the constraint into the DP - // here means the planner stops scheduling battery→EV - // transfers that dispatch's safety net would just clamp - // at runtime; this closes the plan↔reality divergence - // where operators saw "plan: 7 kW discharge + 11 kW EV" - // while live execution held the battery at house-only - // levels. Take ctrlMu for the bool read. + // Surplus-only on the 48 h spec is the operator flag or + // the "deadline is past published prices" deferral. + // The bat-SoC unlock is a this-tick opportunistic clamp + // and must not poison night-time grid EV in a plan + // computed while the sun is still up. Battery→EV is + // already blocked by NoBatteryToEV below. ctrlMu.Lock() noBatteryToEV := !(ctrl.BatteryCoversEV || boostActive) ctrlMu.Unlock() @@ -1512,7 +1493,7 @@ func main() { MaxChargeW: st.MaxChargeW, AllowedStepsW: st.AllowedStepsW, ChargeEfficiency: 0.9, - SurplusOnly: st.SurplusOnly || deferGridPlan || batSoCArmed, + SurplusOnly: loadpoint.PlannerTreatsLoadpointAsSurplusOnly(st.SurplusOnly, deferGridPlan), NoBatteryToEV: noBatteryToEV, }) } @@ -1996,20 +1977,10 @@ func main() { break } // Net PV headroom for non-battery loads: positive when - // PV export exceeds load + planned battery charge. - // BatteryW is site-signed: positive = charge (import), - // negative = discharge (export). Only subtract planned - // CHARGE — planned discharge is already earmarked to - // cover house load (or grid export in arbitrage), not - // available room for the EV to claim. Counting it would - // route plan-discharge → EV → re-charge cycles: the EV - // takes power the plan reserved for load coverage, then - // the dispatch has to re-import or further discharge to - // keep the original balance. - plannedChargeW := a.BatteryW - if plannedChargeW < 0 { - plannedChargeW = 0 - } + // PV export exceeds load + planned PV-soak battery charge. + // Grid-funded battery charge does not consume leftover PV + // a surplus-only EV can take. + plannedChargeW := loadpoint.PlannedPVSoakW(a.BatteryW, a.GridW) surplus := -a.PVW - a.LoadW - plannedChargeW if !any || surplus > peak { peak = surplus @@ -2057,42 +2028,17 @@ func main() { batW += r.SmoothedW } evW := tel.SumOnlineEVW() - // Surplus-only EV priority: when any loadpoint is in - // surplus-only mode, battery charging power is NOT - // available for the EV. The original formula assumed - // "if I told the battery to stop, that surplus would - // free up for the EV" — but the MPC may still - // legitimately charge the battery from PV surplus - // (and, in active arbitrage, from the grid). If we - // hand that power back to the EV, the controller - // commands the EV on, the battery loses its share, - // the planner re-budgets the EV down → flap. The - // truthful surplus for an EV under surplus-only is - // what's left AFTER the battery has taken its share: - // -gridW + max(0, -batW) (battery counts only if - // it's discharging, contributing to site supply). - // A bat-SoC-armed loadpoint is just as much a "PV-priority" - // claimant as a configured surplus_only LP — both want PV - // routed to the EV ahead of the home battery. Counting - // either via the controller's combined view (configured OR - // armed) keeps the flap-avoidance protection symmetric and - // closes the loophole where an armed LP would inflate the - // apparent surplus by the battery's PV-charge rate. + // Surplus-only EV may take leftover PV after house load. + // When the battery is soaking PV, that charge is not offered + // this tick (EV dispatch runs first). When the battery is + // already importing, leftover PV is the car's — surplus-only + // is an EV policy, not a site import ban. See + // loadpoint.SurplusAvailableForEVW. surplusOnlyActive := false if lpController != nil && lpController.AnyLoadpointSurplusActive() { surplusOnlyActive = true } - if surplusOnlyActive && batW > 0 { - batW = 0 - } - // Open follow-up: in self-consumption / planner_self mode, - // the dispatch PI absorbs PV into the battery before the - // EV controller sees it, defeating surplus-only priority. - // The MPC arbitrage path is covered by the new mpc.go - // feasibility constraint; the self-consumption fallback - // needs a battery-charge cap in control/dispatch.go to - // match. Tracked separately to keep this change focused. - return -gridW + batW + evW, true + return loadpoint.SurplusAvailableForEVW(gridW, batW, evW, surplusOnlyActive), true }) // Bat-SoC surplus-unlock: feed the controller a live home-battery diff --git a/go/internal/control/ev_site_harness_test.go b/go/internal/control/ev_site_harness_test.go new file mode 100644 index 00000000..d2538f82 --- /dev/null +++ b/go/internal/control/ev_site_harness_test.go @@ -0,0 +1,510 @@ +package control + +// Joined EV + home-battery + planner clock. +// +// Isolated suites did not catch "EV charging is broken beside the battery": +// +// - mpc tests call Optimize / ValidatePlan and never Tick the charger +// - loadpoint tests inject a Directive and never run ComputeDispatch +// - control golden / forecast_scenarios call ComputeDispatch with a +// SlotDirective and a pre-baked EVChargingW — no loadpoint controller +// - go/test/e2e has Ferroamp / Sungrow batteries and no EV charger +// +// This harness is the missing seam. One pinned clock runs: +// +// Optimize (optional) → map Action to SlotDirective + loadpoint.Directive +// → loadpoint.Controller.Tick → ComputeDispatch → site identity +// +// Tick order matches go/cmd/ftw/main.go: charger first, then battery +// dispatch, then the next meter sample sees both commands. Surplus-only +// leftover uses loadpoint.SurplusAvailableForEVW, the same helper main.go +// wires into SetSiteSurplusForEV. +// +// Run: go test -run 'TestEVSite' ./go/internal/control + +import ( + "context" + "encoding/json" + "math" + "testing" + "time" + + "github.com/srcfl/ftw/go/internal/loadpoint" + "github.com/srcfl/ftw/go/internal/mpc" + "github.com/srcfl/ftw/go/internal/telemetry" +) + +const ( + evSiteMeter = "meter" + evSiteBattery = "pixii" + evSitePV = "pv" + evSiteCharger = "easee" + evSiteLP = "garage" + + evSiteTickS = 5 +) + +// evSiteConfig is one joined-site scenario. Live PV/load stay at the +// values given here for the whole run (happy-path: live matches the +// slot the plan was built from). Plan is either injected or produced +// by mpc.Optimize when OptimizeSlots/OptimizeParams are set. +type evSiteConfig struct { + Start time.Time + Plan mpc.Plan + + OptimizeSlots []mpc.Slot + OptimizeParams mpc.Params + + LP loadpoint.Config + + LoadW float64 + PVW float64 // site-signed (generation is negative) + + BatCapWh float64 + BatEnergyWh float64 + BatMaxCharge float64 + + EVCapWh float64 + EVEnergyWh float64 + + FuseMaxW float64 +} + +type evSiteTick struct { + N int + At time.Time + LoadW, PVW float64 + BatW, EVW, GridW float64 + BatCmdW, EVCmdW float64 + SurplusW float64 + PlanBatW, PlanEVW float64 + PlanGridW float64 +} + +type evCmdSender struct { + lastW float64 + lastSet bool +} + +func (s *evCmdSender) Send(_ context.Context, _ string, payload []byte) error { + var d struct { + PowerW float64 `json:"power_w"` + } + if err := json.Unmarshal(payload, &d); err != nil { + return err + } + s.lastW = d.PowerW + s.lastSet = true + return nil +} + +type evSite struct { + t *testing.T + cfg evSiteConfig + now time.Time + dt time.Duration + + loadW, pvW float64 + batW, evW, gridW float64 + batEnergyWh float64 + evEnergyWh float64 + sessionWh float64 + surplusOnly bool + plan mpc.Plan + + store *telemetry.Store + st *State + mgr *loadpoint.Manager + lp *loadpoint.Controller + sender *evCmdSender + caps map[string]float64 + fuseW float64 + + ticks []evSiteTick +} + +func newEVSite(t *testing.T, cfg evSiteConfig) *evSite { + t.Helper() + if cfg.Start.IsZero() { + cfg.Start = time.Date(2026, 8, 18, 12, 0, 1, 0, time.UTC) + } + if cfg.BatCapWh <= 0 { + cfg.BatCapWh = 20000 + } + if cfg.BatEnergyWh <= 0 { + cfg.BatEnergyWh = 4000 + } + if cfg.BatMaxCharge <= 0 { + cfg.BatMaxCharge = 10000 + } + if cfg.EVCapWh <= 0 { + cfg.EVCapWh = 60000 + } + if cfg.FuseMaxW <= 0 { + cfg.FuseMaxW = 25 * 230 * 3 // 17.25 kW — combo charge must fit + } + if cfg.LP.ID == "" { + cfg.LP.ID = evSiteLP + } + if cfg.LP.DriverName == "" { + cfg.LP.DriverName = evSiteCharger + } + if cfg.LP.MinChargeW <= 0 { + cfg.LP.MinChargeW = 1380 + } + if cfg.LP.MaxChargeW <= 0 { + cfg.LP.MaxChargeW = 11040 + } + if len(cfg.LP.AllowedStepsW) == 0 { + cfg.LP.AllowedStepsW = []float64{0, 1380, 4140, 6900, 11040} + } + if cfg.LP.PhaseSplitW <= 0 { + cfg.LP.PhaseSplitW = 3680 + } + + plan := cfg.Plan + if len(cfg.OptimizeSlots) > 0 { + plan = mpc.Optimize(cfg.OptimizeSlots, cfg.OptimizeParams) + if len(plan.Actions) == 0 { + t.Fatalf("Optimize returned no actions") + } + } + if len(plan.Actions) == 0 { + t.Fatal("ev site needs an injected plan or OptimizeSlots") + } + + s := &evSite{ + t: t, + cfg: cfg, + now: cfg.Start, + dt: evSiteTickS * time.Second, + loadW: cfg.LoadW, + pvW: cfg.PVW, + batEnergyWh: cfg.BatEnergyWh, + evEnergyWh: cfg.EVEnergyWh, + surplusOnly: cfg.LP.SurplusOnly, + plan: plan, + store: telemetry.NewStore(), + sender: &evCmdSender{}, + caps: map[string]float64{evSiteBattery: cfg.BatCapWh}, + fuseW: cfg.FuseMaxW, + } + s.gridW = s.loadW + s.pvW + s.batW + s.evW + + st := NewState(0, 0, evSiteMeter) + st.Mode = ModePlannerArbitrage + st.UseEnergyDispatch = true + st.SlewEnabled = false + st.SlewRateW = 100_000 + st.MinDispatchIntervalS = 0 + st.BatteryCoversEV = false + st.DriverLimits = map[string]PowerLimits{ + evSiteBattery: {MaxChargeW: cfg.BatMaxCharge, MaxDischargeW: cfg.BatMaxCharge}, + } + st.clock = func() time.Time { return s.now } + st.SlotDirective = func(now time.Time) (SlotDirective, bool) { + d, _, ok := s.directives(now) + return d, ok + } + s.st = st + + mgr := loadpoint.NewManager() + mgr.Load([]loadpoint.Config{cfg.LP}) + s.mgr = mgr + + lp := loadpoint.NewController(mgr, + func(now time.Time) (loadpoint.Directive, bool) { + _, d, ok := s.directives(now) + return d, ok + }, + func(driver string) (loadpoint.EVSample, bool) { + if driver != cfg.LP.DriverName { + return loadpoint.EVSample{}, false + } + return loadpoint.EVSample{ + PowerW: s.evW, + SessionWh: s.sessionWh, + Connected: true, + RequestActive: true, + }, true + }, + s.sender.Send, + ) + lp.SetSiteSurplusForEV(func() (float64, bool) { + return loadpoint.SurplusAvailableForEVW(s.gridW, s.batW, s.evW, lp.AnyLoadpointSurplusActive()), true + }) + lp.SetNearTermPeakSurplusW(func(time.Duration) (float64, bool) { + leftover := -(s.loadW + s.pvW) + if leftover < 0 { + leftover = 0 + } + return leftover, true + }) + s.lp = lp + s.publish() + return s +} + +func (s *evSite) directives(now time.Time) (SlotDirective, loadpoint.Directive, bool) { + s.t.Helper() + nowMs := now.UnixMilli() + for _, a := range s.plan.Actions { + endMs := a.SlotStartMs + int64(a.SlotLenMin)*60*1000 + if nowMs < a.SlotStartMs || nowMs >= endMs { + continue + } + hours := float64(a.SlotLenMin) / 60.0 + lpWh := map[string]float64{} + if a.LoadpointW != 0 { + lpWh[s.cfg.LP.ID] = a.LoadpointW * hours + } + start := time.UnixMilli(a.SlotStartMs) + end := time.UnixMilli(endMs) + return SlotDirective{ + SlotStart: start, + SlotEnd: end, + BatteryEnergyWh: a.BatteryW * hours, + Strategy: "arbitrage", + PlannedGridW: a.GridW, + HasPlannedGridW: true, + LoadpointEnergyWh: lpWh, + }, loadpoint.Directive{ + SlotStart: start, + SlotEnd: end, + LoadpointEnergyWh: lpWh, + }, true + } + return SlotDirective{}, loadpoint.Directive{}, false +} + +func (s *evSite) actionAt(now time.Time) (mpc.Action, bool) { + nowMs := now.UnixMilli() + for _, a := range s.plan.Actions { + endMs := a.SlotStartMs + int64(a.SlotLenMin)*60*1000 + if nowMs >= a.SlotStartMs && nowMs < endMs { + return a, true + } + } + return mpc.Action{}, false +} + +func (s *evSite) publish() { + s.t.Helper() + soc := s.batEnergyWh / s.cfg.BatCapWh + if soc < 0 { + soc = 0 + } + if soc > 1 { + soc = 1 + } + // Kalman first-sample is exact; repeats settle after a step so + // ComputeDispatch sees approximately the physics, not a lag that + // would hide the combo under test. + for i := 0; i < 8; i++ { + s.store.Update(evSiteMeter, telemetry.DerMeter, s.gridW, nil, nil) + s.store.Update(evSiteBattery, telemetry.DerBattery, s.batW, &soc, nil) + s.store.Update(evSitePV, telemetry.DerPV, s.pvW, nil, nil) + s.store.Update(evSiteCharger, telemetry.DerEV, s.evW, nil, nil) + } + s.store.DriverHealthMut(evSiteMeter).RecordSuccess() + s.store.DriverHealthMut(evSiteBattery).RecordSuccess() + s.store.DriverHealthMut(evSitePV).RecordSuccess() + s.store.DriverHealthMut(evSiteCharger).RecordSuccess() +} + +func (s *evSite) tick() evSiteTick { + s.t.Helper() + s.publish() + surplus := loadpoint.SurplusAvailableForEVW(s.gridW, s.batW, s.evW, s.lp.AnyLoadpointSurplusActive()) + planA, _ := s.actionAt(s.now) + + s.sender.lastSet = false + s.lp.Tick(context.Background(), s.now) + evCmd := 0.0 + if s.sender.lastSet { + evCmd = s.sender.lastW + } + + lpStates := s.mgr.States() + s.st.EVSurplusOnlyReserveW = loadpoint.SurplusReserveW(lpStates, nil) + s.st.EVSurplusOnlyChargingW = loadpoint.SurplusChargingW(lpStates) + s.st.EVCurtailHeadroomW = loadpoint.SurplusPotentialW(lpStates) + + targets := ComputeDispatch(s.store, s.st, s.caps, s.fuseW) + var batCmd float64 + for _, tg := range targets { + if tg.Driver == evSiteBattery { + batCmd = tg.TargetW + } + } + + hours := s.dt.Hours() + s.evW = evCmd + if s.evW < 0 { + s.evW = 0 + } + s.batW = batCmd + headroomWh := s.cfg.BatCapWh*0.95 - s.batEnergyWh + if s.batW > 0 && s.batW*hours > headroomWh && hours > 0 { + s.batW = headroomWh / hours + if s.batW < 0 { + s.batW = 0 + } + } + floorWh := s.cfg.BatCapWh * 0.10 + if s.batW < 0 && s.batEnergyWh+s.batW*hours/0.95 < floorWh && hours > 0 { + s.batW = -(s.batEnergyWh - floorWh) * 0.95 / hours + if s.batW > 0 { + s.batW = 0 + } + } + s.gridW = s.loadW + s.pvW + s.batW + s.evW + if s.batW >= 0 { + s.batEnergyWh += s.batW * hours * 0.95 + } else { + s.batEnergyWh += s.batW * hours / 0.95 + } + s.evEnergyWh += s.evW * hours * 0.90 + s.sessionWh += s.evW * hours + + rec := evSiteTick{ + N: len(s.ticks), + At: s.now, + LoadW: s.loadW, + PVW: s.pvW, + BatW: s.batW, + EVW: s.evW, + GridW: s.gridW, + BatCmdW: batCmd, + EVCmdW: evCmd, + SurplusW: surplus, + PlanBatW: planA.BatteryW, + PlanEVW: planA.LoadpointW, + PlanGridW: planA.GridW, + } + s.checkInvariants(rec) + s.ticks = append(s.ticks, rec) + s.now = s.now.Add(s.dt) + return rec +} + +func (s *evSite) run(n int) []evSiteTick { + s.t.Helper() + out := make([]evSiteTick, 0, n) + for i := 0; i < n; i++ { + out = append(out, s.tick()) + } + return out +} + +func (s *evSite) leftoverW() float64 { + v := -(s.loadW + s.pvW) + if v < 0 { + return 0 + } + return v +} + +func (s *evSite) checkInvariants(rec evSiteTick) { + s.t.Helper() + ident := rec.LoadW + rec.PVW + rec.BatW + rec.EVW + if math.Abs(rec.GridW-ident) > 1 { + s.t.Fatalf("tick %d: grid identity %.1f != load+pv+bat+ev %.1f", rec.N, rec.GridW, ident) + } + if rec.GridW > s.fuseW+50 { + s.t.Fatalf("tick %d: grid %.0f W over fuse %.0f W", rec.N, rec.GridW, s.fuseW) + } + if s.surplusOnly && rec.EVW > 50 { + if rec.EVW > s.leftoverW()+50 { + s.t.Fatalf("tick %d: surplus-only EV %.0f W exceeds leftover PV after house %.0f W (grid=%.0f bat=%.0f)", + rec.N, rec.EVW, s.leftoverW(), rec.GridW, rec.BatW) + } + if rec.BatW < -50 { + house := rec.LoadW + rec.PVW + if house < 0 { + house = 0 + } + if -rec.BatW > house+50 { + s.t.Fatalf("tick %d: battery discharge %.0f W feeds surplus-only EV %.0f W (house residual %.0f W)", + rec.N, rec.BatW, rec.EVW, house) + } + } + } +} + +func (s *evSite) requireCombo(afterTicks int) evSiteTick { + s.t.Helper() + for _, rec := range s.ticks { + if rec.N < afterTicks { + continue + } + if rec.EVW > 1000 && rec.BatW > 500 && rec.GridW > 100 { + return rec + } + } + s.t.Fatalf("no tick after %d had EV charging from leftover PV while the home battery grid-charged; ticks=%s", + afterTicks, s.dumpTicks()) + return evSiteTick{} +} + +func (s *evSite) requireIdleEV(afterTicks int) { + s.t.Helper() + for _, rec := range s.ticks { + if rec.N < afterTicks { + continue + } + if rec.EVW > 50 { + s.t.Fatalf("tick %d: surplus-only EV imported without leftover PV: ev=%.0f grid=%.0f bat=%.0f pv=%.0f; ticks=%s", + rec.N, rec.EVW, rec.GridW, rec.BatW, rec.PVW, s.dumpTicks()) + } + } +} + +func (s *evSite) dumpTicks() string { + b := make([]byte, 0, 256) + for _, rec := range s.ticks { + b = append(b, []byte( + rec.At.Format("15:04:05")+" ev="+itoa(rec.EVW)+" bat="+itoa(rec.BatW)+" grid="+itoa(rec.GridW)+" surplus="+itoa(rec.SurplusW)+"\n", + )...) + } + return string(b) +} + +func itoa(w float64) string { + return jsonNumber(w) +} + +func jsonNumber(w float64) string { + b, _ := json.Marshal(math.Round(w)) + return string(b) +} + +func injectedChargePlan(start time.Time, slotMin int, batW, evW, loadW, pvW float64) mpc.Plan { + gridW := loadW + pvW + batW + evW + return mpc.Plan{ + GeneratedAtMs: start.UnixMilli(), + Mode: mpc.ModeArbitrage, + HorizonSlots: 1, + Actions: []mpc.Action{{ + SlotStartMs: start.UnixMilli(), + SlotLenMin: slotMin, + BatteryW: batW, + LoadpointW: evW, + GridW: gridW, + LoadW: loadW, + PVW: pvW, + }}, + } +} + +func surplusOnlyGarage() loadpoint.Config { + return loadpoint.Config{ + ID: evSiteLP, + DriverName: evSiteCharger, + MinChargeW: 1380, + MaxChargeW: 11040, + AllowedStepsW: []float64{0, 1380, 4140, 6900, 11040}, + PhaseSplitW: 3680, + SurplusOnly: true, + } +} diff --git a/go/internal/control/ev_site_test.go b/go/internal/control/ev_site_test.go new file mode 100644 index 00000000..2a9e0da1 --- /dev/null +++ b/go/internal/control/ev_site_test.go @@ -0,0 +1,202 @@ +package control + +import ( + "testing" + "time" + + "github.com/srcfl/ftw/go/internal/mpc" +) + +// Cheap-hour leftover PV while the home battery is buying from the grid. +// 6 kW leftover is above the 3Φ minimum so the surplus clamp can start. +const ( + evComboLoadW = 500 + evComboPVW = -8000 // leftover after house = 7500 W (holds 3Φ × 10 A) + evComboBatW = 10000 +) + +func evComboSlotStart() time.Time { + return time.Date(2026, 8, 18, 12, 0, 0, 0, time.UTC) +} + +func evComboSiteStart() time.Time { + return evComboSlotStart().Add(time.Second) +} + +func TestEVSiteSurplusOnlyTakesLeftoverPVWhileBatteryGridCharges(t *testing.T) { + // Injected plan: battery buys 10 kW. EV budget is 0 — the charger + // must start from the opportunistic surplus clamp, the path Easee + // sites without a vehicle SoC actually use. The old surplus reader + // treated battery charge as already-claimed PV and offered the car + // −grid+ev < 0 while Pixii imported, so the EV never moved. + start := evComboSiteStart() + site := newEVSite(t, evSiteConfig{ + Start: start, + Plan: injectedChargePlan(evComboSlotStart(), 15, evComboBatW, 0, evComboLoadW, evComboPVW), + LP: surplusOnlyGarage(), + LoadW: evComboLoadW, + PVW: evComboPVW, + }) + site.run(12) + got := site.requireCombo(4) + if got.EVW > site.leftoverW()+50 { + t.Errorf("EV %.0f W exceeded leftover %.0f W", got.EVW, site.leftoverW()) + } +} + +func TestEVSitePlannedSurplusEVChargesBesideBatteryGridCharge(t *testing.T) { + start := evComboSiteStart() + site := newEVSite(t, evSiteConfig{ + Start: start, + Plan: injectedChargePlan(evComboSlotStart(), 15, evComboBatW, 4140, evComboLoadW, evComboPVW), + LP: surplusOnlyGarage(), + LoadW: evComboLoadW, + PVW: evComboPVW, + }) + site.run(12) + site.requireCombo(4) +} + +func TestEVSiteOptimizeThenDispatchChargesEVFromPVBesideBatteryImport(t *testing.T) { + slot := evComboSlotStart() + slots := []mpc.Slot{ + { + StartMs: slot.UnixMilli(), LenMin: 60, + PriceOre: 20, SpotOre: 10, LoadW: evComboLoadW, PVW: evComboPVW, Confidence: 1, + }, + { + StartMs: slot.Add(time.Hour).UnixMilli(), LenMin: 60, + PriceOre: 300, SpotOre: 240, LoadW: 2500, PVW: 0, Confidence: 1, + }, + } + params := mpc.Params{ + Mode: mpc.ModeArbitrage, + SoCLevels: 11, + CapacityWh: 20000, + SoCMinPct: 10, + SoCMaxPct: 95, + InitialSoCPct: 20, + ActionLevels: 11, + MaxChargeW: 10000, + MaxDischargeW: 10000, + ChargeEfficiency: 0.95, + DischargeEfficiency: 0.95, + TerminalSoCPrice: 150, + Loadpoint: &mpc.LoadpointSpec{ + ID: evSiteLP, + CapacityWh: 40000, + Levels: 11, + InitialSoCPct: 20, + PluggedIn: true, + TargetSoCPct: 40, + TargetSlotIdx: 1, + MaxChargeW: 4140, + AllowedStepsW: []float64{0, 4140}, + ChargeEfficiency: 0.9, + SurplusOnly: true, + NoBatteryToEV: true, + }, + } + site := newEVSite(t, evSiteConfig{ + Start: evComboSiteStart(), + OptimizeSlots: slots, + OptimizeParams: params, + LP: surplusOnlyGarage(), + LoadW: evComboLoadW, + PVW: evComboPVW, + BatMaxCharge: 10000, + }) + if site.plan.Actions[0].BatteryW < 500 { + t.Fatalf("cheap slot should charge the home battery, got %+v", site.plan.Actions[0]) + } + site.run(12) + site.requireCombo(4) +} + +func TestEVSiteIdleSurplusOnlyEVDoesNotBlockNightGridCharge(t *testing.T) { + // #953: plugged idle surplus-only car, no PV, cheap night. + start := time.Date(2026, 8, 18, 2, 0, 1, 0, time.UTC) + slot := time.Date(2026, 8, 18, 2, 0, 0, 0, time.UTC) + site := newEVSite(t, evSiteConfig{ + Start: start, + Plan: injectedChargePlan(slot, 15, 5000, 0, 500, 0), + LP: surplusOnlyGarage(), + LoadW: 500, + PVW: 0, + }) + site.run(8) + site.requireIdleEV(2) + var charged bool + for _, rec := range site.ticks { + if rec.N >= 2 && rec.BatW > 500 && rec.GridW > 100 { + charged = true + break + } + } + if !charged { + t.Fatalf("home battery should grid-charge at night beside an idle surplus-only EV; ticks=%s", site.dumpTicks()) + } +} + +func TestEVSiteSurplusOnlyPausesWhenLeftoverCannotHold3Phase(t *testing.T) { + // 1.3 kW leftover is below the 3Φ minimum. The clamp must pause + // rather than import the gap. Battery may still buy from the grid. + start := evComboSiteStart() + const loadW, pvW = 500.0, -1800.0 // leftover 1300 W + site := newEVSite(t, evSiteConfig{ + Start: start, + Plan: injectedChargePlan(evComboSlotStart(), 15, 5000, 11000, loadW, pvW), + LP: surplusOnlyGarage(), + LoadW: loadW, + PVW: pvW, + }) + site.run(8) + site.requireIdleEV(4) +} + +func TestEVSiteScheduledEVMayImportOnCheapNight(t *testing.T) { + start := time.Date(2026, 8, 18, 2, 0, 1, 0, time.UTC) + slot := time.Date(2026, 8, 18, 2, 0, 0, 0, time.UTC) + lp := surplusOnlyGarage() + lp.SurplusOnly = false + site := newEVSite(t, evSiteConfig{ + Start: start, + Plan: injectedChargePlan(slot, 15, 4000, 4140, 500, 0), + LP: lp, + LoadW: 500, + PVW: 0, + }) + site.run(8) + var imported bool + for _, rec := range site.ticks { + if rec.N >= 2 && rec.EVW > 1000 && rec.GridW > 100 { + imported = true + break + } + } + if !imported { + t.Fatalf("scheduled (not surplus-only) EV should import on a cheap night; ticks=%s", site.dumpTicks()) + } +} + +func TestEVSiteBatteryDoesNotDischargeIntoSurplusOnlyEV(t *testing.T) { + start := evComboSiteStart() + site := newEVSite(t, evSiteConfig{ + Start: start, + Plan: injectedChargePlan(evComboSlotStart(), 15, -4000, 4000, 500, 0), + LP: surplusOnlyGarage(), + LoadW: 500, + PVW: 0, + BatEnergyWh: 16000, + BatMaxCharge: 10000, + }) + site.run(8) + for _, rec := range site.ticks { + if rec.EVW > 50 && rec.BatW < -50 { + t.Fatalf("tick %d: surplus-only EV %.0f W with battery discharge %.0f W", rec.N, rec.EVW, rec.BatW) + } + if rec.EVW > 50 { + t.Fatalf("tick %d: surplus-only EV charged without PV: %.0f W", rec.N, rec.EVW) + } + } +} diff --git a/go/internal/loadpoint/surplus_reserve.go b/go/internal/loadpoint/surplus_reserve.go index 9005dd72..787a10a1 100644 --- a/go/internal/loadpoint/surplus_reserve.go +++ b/go/internal/loadpoint/surplus_reserve.go @@ -26,6 +26,11 @@ package loadpoint // real site. const EVRampHeadroomW = 2000 +// GridChargeImportW is the live/plan grid band that means the site is +// deliberately importing, not soaking PV. Matches control's +// coverLoadChargeSlot / energy-path grid-charge skip. +const GridChargeImportW = 100.0 + // SurplusReserveW returns the aggregate PV headroom that must be // preserved for surplus_only loadpoints. For each surplus_only + // plugged_in LP it reserves min(MaxChargeW, CurrentPowerW + @@ -215,3 +220,46 @@ func SurplusPotentialW(states []State) float64 { } return sum } + +// PlannerTreatsLoadpointAsSurplusOnly is the SurplusOnly flag the MPC spec +// should carry. The bat-SoC unlock is a this-tick opportunistic clamp; +// putting it on the 48 h spec forbids night-time grid EV in a plan that +// was computed while the sun was still up. Battery→EV is already blocked +// by NoBatteryToEV. +func PlannerTreatsLoadpointAsSurplusOnly(operatorSurplusOnly, deferGridPlan bool) bool { + return operatorSurplusOnly || deferGridPlan +} + +// SurplusAvailableForEVW is the live PV leftover the surplus-only clamp +// may offer the charger this tick, in watts. +// +// Site identity: -gridW + batW + evW = -pvW - loadW (house leftover). +// +// The EV controller runs before battery dispatch on the same tick. If the +// home battery is soaking PV (charging while the site is not importing), +// counting that charge as EV-available would command the charger on +// before the battery has yielded and leak into import. Grid-funded +// battery charge is different: the battery is already importing, so the +// leftover PV is the car's to take without waiting for a yield. +func SurplusAvailableForEVW(gridW, batW, evW float64, surplusOnlyActive bool) float64 { + leftover := -gridW + batW + evW + if surplusOnlyActive && batW > 0 && gridW <= GridChargeImportW { + leftover = -gridW + evW + } + if leftover < 0 { + return 0 + } + return leftover +} + +// PlannedPVSoakW is the portion of a planned battery charge that is +// soaking leftover PV rather than buying from the grid. The near-term +// 3Φ gate subtracts this from forecast surplus so the EV does not wait +// for a 3Φ window the battery is about to eat. A grid-charge slot +// (PlannedGridW above the import band) does not consume that leftover. +func PlannedPVSoakW(batteryW, gridW float64) float64 { + if batteryW <= 0 || gridW > GridChargeImportW { + return 0 + } + return batteryW +} diff --git a/go/internal/loadpoint/surplus_reserve_test.go b/go/internal/loadpoint/surplus_reserve_test.go index d32a4f34..462ce1c9 100644 --- a/go/internal/loadpoint/surplus_reserve_test.go +++ b/go/internal/loadpoint/surplus_reserve_test.go @@ -225,6 +225,52 @@ func TestSurplusReserveWPluggedStoppedSoCUnknownBootstraps(t *testing.T) { } } +func TestSurplusAvailableForEVWHidesPVSoakButNotGridCharge(t *testing.T) { + // Identity leftover after house: -grid + bat + ev. + // PV-soak (battery charging, site not importing): hide the battery + // so the charger cannot claim watts the battery has not yielded yet. + if got := SurplusAvailableForEVW(0, 4000, 0, true); got != 0 { + t.Errorf("PV-soak: got %.0f, want 0 (battery charge is not yet EV-available)", got) + } + // Grid-funded battery charge: leftover PV is the car's. Without this + // the meter import zeros the surplus clamp and a surplus-only EV sits + // in the sun while Pixii buys. + if got := SurplusAvailableForEVW(1500, 5000, 4140, true); got != 7640 { + t.Errorf("grid-charge combo: got %.0f, want 7640 (-1500+5000+4140)", got) + } + if got := SurplusAvailableForEVW(-6500, 0, 0, true); got != 6500 { + t.Errorf("exporting idle: got %.0f, want 6500", got) + } + if got := SurplusAvailableForEVW(0, 4000, 0, false); got != 4000 { + t.Errorf("not surplus-only: got %.0f, want identity 4000", got) + } +} + +func TestPlannedPVSoakWIgnoresGridFundedCharge(t *testing.T) { + if got := PlannedPVSoakW(5000, 0); got != 5000 { + t.Errorf("PV-soak: got %.0f, want 5000", got) + } + if got := PlannedPVSoakW(5000, 1500); got != 0 { + t.Errorf("grid-funded: got %.0f, want 0", got) + } + if got := PlannedPVSoakW(-2000, 0); got != 0 { + t.Errorf("discharge: got %.0f, want 0", got) + } +} + +func TestPlannerTreatsLoadpointAsSurplusOnly(t *testing.T) { + if !PlannerTreatsLoadpointAsSurplusOnly(true, false) { + t.Fatal("operator surplus_only") + } + if !PlannerTreatsLoadpointAsSurplusOnly(false, true) { + t.Fatal("deadline past published prices") + } + if PlannerTreatsLoadpointAsSurplusOnly(false, false) { + t.Fatal("plain loadpoint") + } + // Bat-SoC unlock is this-tick only and must not appear here. +} + // Manual/schedule override: a force-charging (manual hold) surplus_only EV // must contribute NO reserve — the battery is meant to cover it, and a // non-zero reserve arms the dispatch no-discharge floor which flaps the diff --git a/go/internal/mpc/external_optimizer.go b/go/internal/mpc/external_optimizer.go index e909c13f..662e4f33 100644 --- a/go/internal/mpc/external_optimizer.go +++ b/go/internal/mpc/external_optimizer.go @@ -691,8 +691,8 @@ func ValidatePlan(slots []Slot, p Params, plan *Plan) error { if len(a.LoadpointPowerW) == 0 && lpIdx == 0 { powerW = a.LoadpointW } - if lp.SurplusOnly && powerW > 0 && a.GridW > 50 { - return fmt.Errorf("slot %d surplus-only loadpoint %s imports from grid", i, lp.ID) + if lp.SurplusOnly && surplusOnlyExceedsHousePV(powerW, slot.LoadW, effectivePVW) { + return fmt.Errorf("slot %d surplus-only loadpoint %s exceeds PV leftover after house load", i, lp.ID) } if powerW > 0 && a.BatteryW < 0 && a.GridW < -50 { return fmt.Errorf("slot %d loadpoint %s charges during battery-driven export", i, lp.ID) diff --git a/go/internal/mpc/external_optimizer_test.go b/go/internal/mpc/external_optimizer_test.go index 3448018a..4e9c4658 100644 --- a/go/internal/mpc/external_optimizer_test.go +++ b/go/internal/mpc/external_optimizer_test.go @@ -299,6 +299,34 @@ func TestValidatePlanAllowsGridChargeWithIdleSurplusOnlyEV(t *testing.T) { } } +func TestValidatePlanAllowsEVPVWithBatteryGridCharge(t *testing.T) { + slots := []Slot{{StartMs: 1, LenMin: 60, PriceOre: 20, SpotOre: 10, Confidence: 1, LoadW: 500, PVW: -6500}} + p := Params{ + Mode: ModeArbitrage, CapacityWh: 10000, + SoCMinPct: 10, SoCMaxPct: 95, InitialSoCPct: 20, + MaxChargeW: 5000, MaxDischargeW: 5000, + ChargeEfficiency: 0.95, DischargeEfficiency: 0.95, + Loadpoint: &LoadpointSpec{ + ID: "car", CapacityWh: 40000, Levels: 11, MinPct: 0, MaxPct: 100, + InitialSoCPct: 25, PluggedIn: true, MaxChargeW: 4140, + AllowedStepsW: []float64{0, 4140}, ChargeEfficiency: 1, + SurplusOnly: true, NoBatteryToEV: true, + }, + } + // leftover PV after house = 6000 W. EV 4140 + battery 5000 → + // grid = 500-6500+5000+4140 = 3140 import. Battery SoC: 20 + 47.5 = 67.5. + // EV SoC: 25 + 4140/40000*100 = 35.35. + plan := Plan{Mode: p.Mode, HorizonSlots: 1, CapacityWh: p.CapacityWh, InitialSoCPct: 20, + TotalCostOre: 62.8, Actions: []Action{{ + SlotStartMs: 1, SlotLenMin: 60, + BatteryW: 5000, GridW: 3140, SoCPct: 67.5, + LoadpointW: 4140, LoadpointSoCPct: 35.35, CostOre: 62.8, + }}} + if err := ValidatePlan(slots, p, &plan); err != nil { + t.Fatalf("ValidatePlan rejected leftover-PV EV beside battery grid-charge: %v", err) + } +} + func TestExternalOptimizerEndToEnd(t *testing.T) { python := os.Getenv("FTW_TEST_OPTIMIZER_PYTHON") if python == "" { diff --git a/go/internal/mpc/loadpoint_service_test.go b/go/internal/mpc/loadpoint_service_test.go index c4586d64..6dea563e 100644 --- a/go/internal/mpc/loadpoint_service_test.go +++ b/go/internal/mpc/loadpoint_service_test.go @@ -430,8 +430,64 @@ func TestSurplusOnlyEVCannotImportEvenWithDeadline(t *testing.T) { if len(plan.Actions) != 1 { t.Fatalf("got %d actions, want 1", len(plan.Actions)) } - if plan.Actions[0].LoadpointW > 50 && plan.Actions[0].GridW > 50 { - t.Errorf("surplus-only EV imported from grid: evW=%.0f gridW=%.0f", - plan.Actions[0].LoadpointW, plan.Actions[0].GridW) + a := plan.Actions[0] + if surplusOnlyExceedsHousePV(a.LoadpointW, slots[0].LoadW, slots[0].PVW) { + t.Errorf("surplus-only EV exceeded leftover PV: evW=%.0f leftover=%.0f gridW=%.0f", + a.LoadpointW, pvLeftoverAfterHouseW(slots[0].LoadW, slots[0].PVW), a.GridW) + } +} + +func TestArbitrageChargesSurplusOnlyEVFromPVWhileBatteryGridCharges(t *testing.T) { + // Cheap sun + empty battery + expensive evening. Surplus-only may + // take leftover PV after the house; the battery may still buy from + // the grid in the same slot. The old feasibility rule rejected any + // (evW>0 AND gridW>50) pair and forced "car sits / Pixii never buys". + slots := []Slot{ + {StartMs: 0, LenMin: 60, PriceOre: 20, SpotOre: 10, LoadW: 500, PVW: -6500, Confidence: 1}, + {StartMs: 3600_000, LenMin: 60, PriceOre: 300, SpotOre: 240, LoadW: 2500, PVW: 0, Confidence: 1}, + } + plan := Optimize(slots, Params{ + Mode: ModeArbitrage, + SoCLevels: 11, + CapacityWh: 20000, + SoCMinPct: 10, + SoCMaxPct: 95, + InitialSoCPct: 20, + ActionLevels: 11, + MaxChargeW: 10000, + MaxDischargeW: 10000, + ChargeEfficiency: 0.95, + DischargeEfficiency: 0.95, + TerminalSoCPrice: 150, + Loadpoint: &LoadpointSpec{ + ID: "garage", + CapacityWh: 40000, + Levels: 11, + InitialSoCPct: 20, + PluggedIn: true, + TargetSoCPct: 40, + TargetSlotIdx: 1, + MaxChargeW: 4140, + AllowedStepsW: []float64{0, 4140}, + ChargeEfficiency: 1.0, + SurplusOnly: true, + NoBatteryToEV: true, + }, + }) + if len(plan.Actions) != 2 { + t.Fatalf("got %d actions, want 2", len(plan.Actions)) + } + a := plan.Actions[0] + if a.LoadpointW < 1000 { + t.Errorf("cheap PV slot should charge the surplus-only EV from leftover PV, got %+v", a) + } + if a.BatteryW < 500 { + t.Errorf("cheap slot should still grid-charge the home battery, got %+v", a) + } + if a.GridW < 100 { + t.Errorf("battery charge past leftover PV must import: %+v", a) + } + if surplusOnlyExceedsHousePV(a.LoadpointW, slots[0].LoadW, slots[0].PVW) { + t.Errorf("EV %.0f W exceeded leftover %.0f W", a.LoadpointW, pvLeftoverAfterHouseW(slots[0].LoadW, slots[0].PVW)) } } diff --git a/go/internal/mpc/loadpoint_spec.go b/go/internal/mpc/loadpoint_spec.go index cd0e08fa..b54b5aa1 100644 --- a/go/internal/mpc/loadpoint_spec.go +++ b/go/internal/mpc/loadpoint_spec.go @@ -55,12 +55,11 @@ type LoadpointSpec struct { ChargeEfficiency float64 // SurplusOnly forbids EV actions that need grid import or home-battery - // discharge into the car. The loadpoint may use real site surplus only: - // PV already covering house load, or PV left after the battery's own - // planned charge. It must not treat battery discharge as synthetic PV - // surplus, even when the global BatteryCoversEV opt-in is enabled. - // The home battery itself may still grid-charge for house load or - // arbitrage — surplus-only is an EV policy, not a site import ban. + // discharge into the car. The loadpoint may take at most the PV leftover + // after house load. Site import caused by a simultaneous home-battery + // grid-charge does not count as the car importing — surplus-only is an + // EV policy, not a site import ban. Battery discharge still cannot be + // treated as synthetic PV surplus, even when BatteryCoversEV is on. SurplusOnly bool // NoBatteryToEV mirrors ctrl.State.BatteryCoversEV inverted: when @@ -86,6 +85,26 @@ func (l *LoadpointSpec) blocksBatteryToEV() bool { return l != nil && (l.NoBatteryToEV || l.SurplusOnly) } +// surplusOnlyEpsW matches the neighbouring DP / ValidatePlan float dither +// (modeTolW, battery-to-EV residual, export-vs-EV). +const surplusOnlyEpsW = 50 + +// pvLeftoverAfterHouseW is the watts of PV remaining after house load. +// PVW is site-signed (negative generation). +func pvLeftoverAfterHouseW(loadW, pvW float64) float64 { + leftover := -(loadW + pvW) + if leftover < 0 { + return 0 + } + return leftover +} + +// surplusOnlyExceedsHousePV reports whether evW would have to come from +// the grid or the home battery rather than from leftover PV. +func surplusOnlyExceedsHousePV(evW, loadW, pvW float64) bool { + return evW > pvLeftoverAfterHouseW(loadW, pvW)+surplusOnlyEpsW +} + // normalizedSteps returns a non-nil, 0-included, dedup'd + sorted // action set. Used internally by the DP. func (l *LoadpointSpec) normalizedSteps() []float64 { diff --git a/go/internal/mpc/loadpoint_spec_test.go b/go/internal/mpc/loadpoint_spec_test.go index 502570cd..2d6a42ef 100644 --- a/go/internal/mpc/loadpoint_spec_test.go +++ b/go/internal/mpc/loadpoint_spec_test.go @@ -2,6 +2,24 @@ package mpc import "testing" +func TestPVLeftoverAfterHouse(t *testing.T) { + if got := pvLeftoverAfterHouseW(500, -6500); got != 6000 { + t.Errorf("got %.0f, want 6000", got) + } + if got := pvLeftoverAfterHouseW(2000, -500); got != 0 { + t.Errorf("got %.0f, want 0 (house exceeds PV)", got) + } + if surplusOnlyExceedsHousePV(4140, 500, -6500) { + t.Fatal("4140 W EV fits in 6000 W leftover") + } + if !surplusOnlyExceedsHousePV(4140, 500, 0) { + t.Fatal("4140 W EV with no PV must exceed leftover") + } + if surplusOnlyExceedsHousePV(50, 500, 0) { + t.Fatal("idle/noise EV must not trip leftover") + } +} + func TestNormalizedStepsDefaults(t *testing.T) { cases := []struct { name string @@ -98,10 +116,10 @@ func TestOptimizePrefersCheapSlotsForEV(t *testing.T) { ID: "garage", CapacityWh: 60000, // 60 kWh Levels: 11, - InitialSoC: 0.2, + InitialSoC: 0.2, PluggedIn: true, - TargetSoC: 0.3, // need 10 % → 6 kWh - TargetSlotIdx: 3, // deadline at end of horizon + TargetSoC: 0.3, // need 10 % → 6 kWh + TargetSlotIdx: 3, // deadline at end of horizon MaxChargeW: 11000, AllowedStepsW: []float64{0, 11000}, ChargeEfficiency: 0.9, diff --git a/go/internal/mpc/mpc.go b/go/internal/mpc/mpc.go index 2317c801..b25c511b 100644 --- a/go/internal/mpc/mpc.go +++ b/go/internal/mpc/mpc.go @@ -712,29 +712,32 @@ func Optimize(slots []Slot, p Params) Plan { // GridW = load + PV + battery + EV. gridW := slot.LoadW + slot.PVW + battW + evW - // Surplus-only EV: forbid any non-zero EV - // action that turns the site into a net - // importer. evW = 0 is always feasible (the - // constraint short-circuits), so the DP - // degrades gracefully on low-PV days — the - // deadline shortfall penalty then makes the - // "miss target" outcome expensive but legal. - // 50 W epsilon absorbs floating-point dither - // from the discretized PV/load grid so the - // constraint isn't artificially tight against - // an action that's effectively zero net. - if evActive && lp.SurplusOnly && evW > 0 && gridW > 50 { + // Surplus-only EV: take at most leftover PV + // after house load. Site import caused by a + // simultaneous home-battery grid-charge is not + // the car importing — forbidding gridW > 0 + // whenever evW > 0 forced the DP to idle the + // car on every cheap slot the battery wanted + // to buy, which is how "EV takes the PV, Pixii + // never grid-charges" and the reverse + // "battery buys, car sits in the sun" both + // appear on the same site. evW = 0 is always + // feasible, so a no-PV day degrades to "miss + // the deadline" rather than an infeasible + // plan. 50 W epsilon matches the neighbouring + // EV feasibility rules. + if evActive && lp.SurplusOnly && surplusOnlyExceedsHousePV(evW, slot.LoadW, slot.PVW) { continue } // Surplus-only must not also ban home-battery - // grid charge. The EV-import rule above keeps - // the car off grid, and blocksBatteryToEV() + // grid charge. The leftover-PV rule above + // keeps the car off grid, and blocksBatteryToEV() // below already rejects battery→EV, so the // "launder cheap grid through the battery into // the car" path is closed without forbidding // Pixii/house-battery arbitrage while the car - // is plugged in. Active arbitrage with a + // is taking real PV. Active arbitrage with a // surplus-only EV is a real operator setup. // Don't simultaneously discharge the home battery diff --git a/optimizer/ftw_optimizer/model.py b/optimizer/ftw_optimizer/model.py index b1e0aaeb..83197f5d 100644 --- a/optimizer/ftw_optimizer/model.py +++ b/optimizer/ftw_optimizer/model.py @@ -917,8 +917,12 @@ def solve( zero_idx = steps.index(0.0) active = 1 - flex.selection[zero_idx, :] if bool(flex.spec.get("surplus_only", False)): - for sv in scenario_vars: - constraints.append(sv["import"] <= max_site_power * (1 - active)) + # Leftover PV after house load. Site import from a simultaneous + # home-battery grid-charge is not the car importing; forbidding + # import whenever the EV is active forced the solver to idle the + # car on every cheap slot the battery wanted to buy. + house_surplus = np.maximum(0.0, -base_pv - base_load) + constraints.append(flex.power <= house_surplus + 50.0) if bool(flex.spec.get("no_storage_to_load", False)) and storages: house_residual = np.maximum(0.0, base_load + base_pv) constraints.append(total_discharge <= house_residual + max_site_power * (1 - active)) diff --git a/optimizer/tests/test_model.py b/optimizer/tests/test_model.py index 03382b1d..80e4aed7 100644 --- a/optimizer/tests/test_model.py +++ b/optimizer/tests/test_model.py @@ -2087,6 +2087,41 @@ def test_surplus_only_ev_does_not_block_home_battery_grid_charge() -> None: assert actions[0]["grid_w"] > 100 +def test_surplus_only_ev_takes_pv_while_battery_grid_charges() -> None: + """Leftover PV may go to the car while the home battery buys from the grid. + + Forbidding site import whenever the EV was active idled the car on + every cheap slot the battery wanted to charge. + """ + + request = base_request() + request["slots"][0]["pv_w"] = -6500 + request["slots"][0]["max_import_w"] = 16000 + request["storages"][0]["max_charge_w"] = 10000 + request["flex_loads"] = [ + { + "id": "surplus-car", + "capacity_wh": 40000, + "initial_energy_wh": 8000, + "max_energy_wh": 40000, + "target_energy_wh": 16000, + "target_slot": 1, + "charge_efficiency": 1, + "allowed_steps_w": [0, 3000], + "surplus_only": True, + "no_storage_to_load": True, + } + ] + response = handle(request) + assert response["ok"], response + action = response["plan"]["actions"][0] + assert action["flex_power_w"]["surplus-car"] > 100 + assert action["battery_w"] > 100 + assert action["grid_w"] > 100 + leftover = max(0.0, 6500 - 500) + assert action["flex_power_w"]["surplus-car"] <= leftover + 50 + 1e-5 + + def test_surplus_only_ev_still_cannot_import() -> None: request = base_request() request["slots"] = [request["slots"][1]] # expensive slot only From cc7fa4a0b58e12913ca0fb96b187461d080c8007 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 18 Aug 2026 17:52:20 +0000 Subject: [PATCH 02/57] refactor(mpc): treat EV as site power, not a harness mapper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Give leftover PV, house residual and grid identity a home in loadpoint.GridW. DP, ValidatePlan, main.go and the joined site clock all read that contract. Plan→EMS mapping lives in SlotDirectiveFromMPC and SlotDirective.LoadpointDirective — the same adapters main.go uses. The site clock publishes via Service.InstallPlan and reads SlotDirectiveAt, so charger and battery cannot be given two mappings of one slot. Signed-off-by: Cursor Agent Co-authored-by: Fredrik Ahlgren --- go/cmd/ftw/control_state_test.go | 2 +- go/cmd/ftw/main.go | 73 +----- go/internal/control/ev_site_harness_test.go | 261 +++++++++----------- go/internal/control/ev_site_test.go | 25 +- go/internal/control/slot_directive.go | 24 ++ go/internal/loadpoint/site_power.go | 62 +++++ go/internal/loadpoint/site_power_test.go | 62 +++++ go/internal/mpc/external_optimizer.go | 10 +- go/internal/mpc/loadpoint_directive.go | 46 ++++ go/internal/mpc/loadpoint_directive_test.go | 50 ++++ go/internal/mpc/loadpoint_service_test.go | 54 +++- go/internal/mpc/loadpoint_spec.go | 41 +-- go/internal/mpc/loadpoint_spec_test.go | 18 -- go/internal/mpc/mpc.go | 50 ++-- go/internal/mpc/service.go | 22 ++ 15 files changed, 477 insertions(+), 323 deletions(-) create mode 100644 go/internal/control/slot_directive.go create mode 100644 go/internal/loadpoint/site_power.go create mode 100644 go/internal/loadpoint/site_power_test.go create mode 100644 go/internal/mpc/loadpoint_directive.go create mode 100644 go/internal/mpc/loadpoint_directive_test.go diff --git a/go/cmd/ftw/control_state_test.go b/go/cmd/ftw/control_state_test.go index 07f975ae..6c6aa6c9 100644 --- a/go/cmd/ftw/control_state_test.go +++ b/go/cmd/ftw/control_state_test.go @@ -37,7 +37,7 @@ func TestControlSlotDirectiveFromMPCPreservesDecisionIdentity(t *testing.T) { LoadpointEnergyWh: loadpoints, } - got := controlSlotDirectiveFromMPC(in) + got := control.SlotDirectiveFromMPC(in) if got.DecisionID != in.DecisionID || !got.SlotStart.Equal(in.SlotStart) || !got.SlotEnd.Equal(in.SlotEnd) { t.Fatalf("identity or timing changed across adapter: %+v", got) } diff --git a/go/cmd/ftw/main.go b/go/cmd/ftw/main.go index 3f734e0c..83a9dc4f 100644 --- a/go/cmd/ftw/main.go +++ b/go/cmd/ftw/main.go @@ -135,24 +135,6 @@ func decimalDigits(s string) bool { return s != "" } -// controlSlotDirectiveFromMPC keeps the import-cycle bridge explicit. The -// decision ID is report metadata; control does not use it for dispatch math. -func controlSlotDirectiveFromMPC(d mpc.SlotDirective) control.SlotDirective { - return control.SlotDirective{ - DecisionID: d.DecisionID, - SlotStart: d.SlotStart, - SlotEnd: d.SlotEnd, - BatteryEnergyWh: d.BatteryEnergyWh, - SoCTarget: d.SoCTarget, - Strategy: string(d.Strategy), - PVLimitW: d.PVLimitW, - PlannedGridW: d.GridW, - HasPlannedGridW: true, - LivePVSurplusSoCCap: d.LivePVSurplusSoCCap, - LoadpointEnergyWh: d.LoadpointEnergyWh, - } -} - // siteIdentityLoad is the machine's own identity, not a user's. // // Bound is set when nova.key has been adopted into a hardware-protected @@ -1529,7 +1511,9 @@ func main() { if !ok { return control.SlotDirective{}, false } - return controlSlotDirectiveFromMPC(d), true + // SlotDirectiveFromMPC lives in package control so tests + // and main share the plan→EMS field map. + return control.SlotDirectiveFromMPC(d), true } // Default to the energy-allocation path. The plan is a // scheduler (decides WHEN each strategy applies); the EMS is @@ -1608,11 +1592,7 @@ func main() { if !ok { return loadpoint.Directive{}, false } - return loadpoint.Directive{ - SlotStart: d.SlotStart, - SlotEnd: d.SlotEnd, - LoadpointEnergyWh: d.LoadpointEnergyWh, - }, true + return d.LoadpointDirective(), true } telAdapter := func(driver string) (loadpoint.EVSample, bool) { r := tel.Get(driver, telemetry.DerEV) @@ -1947,50 +1927,21 @@ func main() { // 3Φ minimum but day-peak is, we'd rather charge 1Φ now and // switch to 3Φ later than sit idle waiting. // - // "Surplus" here is what the EV can claim, not the raw PV - // excess. The MPC has already allocated battery_w out of PV; - // the EV gets only what's left after PV - Load - Battery. A - // borderline-PV day where MPC reserves 4.5 kW for battery - // charging while raw -PV - Load = 5 kW would otherwise pin - // the gate to 3Φ-only based on a peak the battery is going - // to consume — leaving the EV stuck at 0 W in 3Φ-only step - // land because real-time room is below 4140 W. + // "Surplus" here is leftover PV after house load, minus + // planned PV-soak battery charge. Grid-funded battery + // charge does not consume leftover the car can take. A + // borderline-PV day where the battery soaks 4.5 kW of a + // 5 kW leftover would otherwise pin the gate to 3Φ based + // on a peak the battery is about to eat. lpController.SetNearTermPeakSurplusW(func(window time.Duration) (float64, bool) { if mpcSvc == nil { return 0, false } plan := mpcSvc.Latest() - if plan == nil || len(plan.Actions) == 0 { + if plan == nil { return 0, false } - now := time.Now() - horizon := now.Add(window) - var peak float64 - any := false - for _, a := range plan.Actions { - slotEnd := time.UnixMilli(a.SlotStartMs).Add( - time.Duration(a.SlotLenMin) * time.Minute) - if slotEnd.Before(now) { - continue - } - if time.UnixMilli(a.SlotStartMs).After(horizon) { - break - } - // Net PV headroom for non-battery loads: positive when - // PV export exceeds load + planned PV-soak battery charge. - // Grid-funded battery charge does not consume leftover PV - // a surplus-only EV can take. - plannedChargeW := loadpoint.PlannedPVSoakW(a.BatteryW, a.GridW) - surplus := -a.PVW - a.LoadW - plannedChargeW - if !any || surplus > peak { - peak = surplus - any = true - } - } - if !any { - return 0, false - } - return peak, true + return mpc.PeakPlannedSurplusForEV(plan.Actions, time.Now(), window) }) lpController.SetSiteSurplusForEV(func() (float64, bool) { diff --git a/go/internal/control/ev_site_harness_test.go b/go/internal/control/ev_site_harness_test.go index d2538f82..67b23e4d 100644 --- a/go/internal/control/ev_site_harness_test.go +++ b/go/internal/control/ev_site_harness_test.go @@ -10,21 +10,30 @@ package control // SlotDirective and a pre-baked EVChargingW — no loadpoint controller // - go/test/e2e has Ferroamp / Sungrow batteries and no EV charger // -// This harness is the missing seam. One pinned clock runs: +// This is not a parallel mapper. The site clock holds an mpc.Service, +// publishes the plan with InstallPlan, and reads it the same way +// go/cmd/ftw/main.go does: // -// Optimize (optional) → map Action to SlotDirective + loadpoint.Directive -// → loadpoint.Controller.Tick → ComputeDispatch → site identity +// SlotDirectiveAt → control.SlotDirectiveFromMPC → ComputeDispatch +// SlotDirectiveAt → LoadpointDirective → loadpoint.Controller +// Latest + PeakPlannedSurplusForEV → 3Φ gate +// SurplusAvailableForEVW → surplus clamp // -// Tick order matches go/cmd/ftw/main.go: charger first, then battery -// dispatch, then the next meter sample sees both commands. Surplus-only -// leftover uses loadpoint.SurplusAvailableForEVW, the same helper main.go -// wires into SetSiteSurplusForEV. +// Tick order matches main.go: charger first, then battery dispatch, +// then the next meter sample sees both commands. +// +// SlotDirectiveAt ages GeneratedAtMs on the wall clock (MaxPlanAge), +// not the pinned site clock. Injected noon slots still stamp +// GeneratedAtMs with time.Now(). The 3Φ gate is the one legitimate +// test difference: it scans with the pinned clock so a 12:00 slot is +// "now", matching what main.go does with time.Now() on a live site. // // Run: go test -run 'TestEVSite' ./go/internal/control import ( "context" "encoding/json" + "fmt" "math" "testing" "time" @@ -98,7 +107,9 @@ func (s *evCmdSender) Send(_ context.Context, _ string, payload []byte) error { return nil } -type evSite struct { +// siteClock is the joined plant: pinned time, live flows, and the same +// mpc.Service cache main.go reads. +type siteClock struct { t *testing.T cfg evSiteConfig now time.Time @@ -110,20 +121,20 @@ type evSite struct { evEnergyWh float64 sessionWh float64 surplusOnly bool - plan mpc.Plan - store *telemetry.Store - st *State - mgr *loadpoint.Manager - lp *loadpoint.Controller - sender *evCmdSender - caps map[string]float64 - fuseW float64 + planner *mpc.Service + store *telemetry.Store + st *State + mgr *loadpoint.Manager + lp *loadpoint.Controller + sender *evCmdSender + caps map[string]float64 + fuseW float64 ticks []evSiteTick } -func newEVSite(t *testing.T, cfg evSiteConfig) *evSite { +func newSiteClock(t *testing.T, cfg evSiteConfig) *siteClock { t.Helper() if cfg.Start.IsZero() { cfg.Start = time.Date(2026, 8, 18, 12, 0, 1, 0, time.UTC) @@ -163,8 +174,10 @@ func newEVSite(t *testing.T, cfg evSiteConfig) *evSite { } plan := cfg.Plan + params := mpc.Params{Mode: mpc.ModeArbitrage} if len(cfg.OptimizeSlots) > 0 { - plan = mpc.Optimize(cfg.OptimizeSlots, cfg.OptimizeParams) + params = cfg.OptimizeParams + plan = mpc.Optimize(cfg.OptimizeSlots, params) if len(plan.Actions) == 0 { t.Fatalf("Optimize returned no actions") } @@ -172,8 +185,13 @@ func newEVSite(t *testing.T, cfg evSiteConfig) *evSite { if len(plan.Actions) == 0 { t.Fatal("ev site needs an injected plan or OptimizeSlots") } + // SlotDirectiveAt ages GeneratedAtMs on the wall clock. + plan.GeneratedAtMs = time.Now().UnixMilli() + + planner := &mpc.Service{Defaults: mpc.Params{Mode: params.Mode}} + planner.InstallPlan(plan, params, cfg.LP.ID) - s := &evSite{ + s := &siteClock{ t: t, cfg: cfg, now: cfg.Start, @@ -183,13 +201,13 @@ func newEVSite(t *testing.T, cfg evSiteConfig) *evSite { batEnergyWh: cfg.BatEnergyWh, evEnergyWh: cfg.EVEnergyWh, surplusOnly: cfg.LP.SurplusOnly, - plan: plan, + planner: planner, store: telemetry.NewStore(), sender: &evCmdSender{}, caps: map[string]float64{evSiteBattery: cfg.BatCapWh}, fuseW: cfg.FuseMaxW, } - s.gridW = s.loadW + s.pvW + s.batW + s.evW + s.gridW = loadpoint.GridW(s.loadW, s.pvW, s.batW, s.evW) st := NewState(0, 0, evSiteMeter) st.Mode = ModePlannerArbitrage @@ -203,8 +221,11 @@ func newEVSite(t *testing.T, cfg evSiteConfig) *evSite { } st.clock = func() time.Time { return s.now } st.SlotDirective = func(now time.Time) (SlotDirective, bool) { - d, _, ok := s.directives(now) - return d, ok + d, ok := s.planner.SlotDirectiveAt(now) + if !ok { + return SlotDirective{}, false + } + return SlotDirectiveFromMPC(d), true } s.st = st @@ -214,8 +235,11 @@ func newEVSite(t *testing.T, cfg evSiteConfig) *evSite { lp := loadpoint.NewController(mgr, func(now time.Time) (loadpoint.Directive, bool) { - _, d, ok := s.directives(now) - return d, ok + d, ok := s.planner.SlotDirectiveAt(now) + if !ok { + return loadpoint.Directive{}, false + } + return d.LoadpointDirective(), true }, func(driver string) (loadpoint.EVSample, bool) { if driver != cfg.LP.DriverName { @@ -233,62 +257,32 @@ func newEVSite(t *testing.T, cfg evSiteConfig) *evSite { lp.SetSiteSurplusForEV(func() (float64, bool) { return loadpoint.SurplusAvailableForEVW(s.gridW, s.batW, s.evW, lp.AnyLoadpointSurplusActive()), true }) - lp.SetNearTermPeakSurplusW(func(time.Duration) (float64, bool) { - leftover := -(s.loadW + s.pvW) - if leftover < 0 { - leftover = 0 + lp.SetNearTermPeakSurplusW(func(window time.Duration) (float64, bool) { + plan := s.planner.Latest() + if plan == nil { + return 0, false } - return leftover, true + return mpc.PeakPlannedSurplusForEV(plan.Actions, s.now, window) }) s.lp = lp s.publish() return s } -func (s *evSite) directives(now time.Time) (SlotDirective, loadpoint.Directive, bool) { +func (s *siteClock) plan() mpc.Plan { s.t.Helper() - nowMs := now.UnixMilli() - for _, a := range s.plan.Actions { - endMs := a.SlotStartMs + int64(a.SlotLenMin)*60*1000 - if nowMs < a.SlotStartMs || nowMs >= endMs { - continue - } - hours := float64(a.SlotLenMin) / 60.0 - lpWh := map[string]float64{} - if a.LoadpointW != 0 { - lpWh[s.cfg.LP.ID] = a.LoadpointW * hours - } - start := time.UnixMilli(a.SlotStartMs) - end := time.UnixMilli(endMs) - return SlotDirective{ - SlotStart: start, - SlotEnd: end, - BatteryEnergyWh: a.BatteryW * hours, - Strategy: "arbitrage", - PlannedGridW: a.GridW, - HasPlannedGridW: true, - LoadpointEnergyWh: lpWh, - }, loadpoint.Directive{ - SlotStart: start, - SlotEnd: end, - LoadpointEnergyWh: lpWh, - }, true + p := s.planner.Latest() + if p == nil { + s.t.Fatal("planner has no plan") } - return SlotDirective{}, loadpoint.Directive{}, false + return *p } -func (s *evSite) actionAt(now time.Time) (mpc.Action, bool) { - nowMs := now.UnixMilli() - for _, a := range s.plan.Actions { - endMs := a.SlotStartMs + int64(a.SlotLenMin)*60*1000 - if nowMs >= a.SlotStartMs && nowMs < endMs { - return a, true - } - } - return mpc.Action{}, false +func (s *siteClock) slotDirective() (mpc.SlotDirective, bool) { + return s.planner.SlotDirectiveAt(s.now) } -func (s *evSite) publish() { +func (s *siteClock) publish() { s.t.Helper() soc := s.batEnergyWh / s.cfg.BatCapWh if soc < 0 { @@ -297,29 +291,36 @@ func (s *evSite) publish() { if soc > 1 { soc = 1 } - // Kalman first-sample is exact; repeats settle after a step so - // ComputeDispatch sees approximately the physics, not a lag that - // would hide the combo under test. - for i := 0; i < 8; i++ { - s.store.Update(evSiteMeter, telemetry.DerMeter, s.gridW, nil, nil) - s.store.Update(evSiteBattery, telemetry.DerBattery, s.batW, &soc, nil) - s.store.Update(evSitePV, telemetry.DerPV, s.pvW, nil, nil) - s.store.Update(evSiteCharger, telemetry.DerEV, s.evW, nil, nil) - } + s.store.Update(evSiteMeter, telemetry.DerMeter, s.gridW, nil, nil) + s.store.Update(evSiteBattery, telemetry.DerBattery, s.batW, &soc, nil) + s.store.Update(evSitePV, telemetry.DerPV, s.pvW, nil, nil) + s.store.Update(evSiteCharger, telemetry.DerEV, s.evW, nil, nil) s.store.DriverHealthMut(evSiteMeter).RecordSuccess() s.store.DriverHealthMut(evSiteBattery).RecordSuccess() s.store.DriverHealthMut(evSitePV).RecordSuccess() s.store.DriverHealthMut(evSiteCharger).RecordSuccess() } -func (s *evSite) tick() evSiteTick { +func (s *siteClock) tick() evSiteTick { s.t.Helper() s.publish() + + d, ok := s.slotDirective() + if !ok { + s.t.Fatalf("tick %d: SlotDirectiveAt(%s) empty — GeneratedAtMs ages on the wall clock (MaxPlanAge), not the site clock", + len(s.ticks), s.now.Format(time.RFC3339)) + } + hours := d.SlotEnd.Sub(d.SlotStart).Hours() + planBatW, planEVW := 0.0, 0.0 + if hours > 0 { + planBatW = d.BatteryEnergyWh / hours + planEVW = d.LoadpointEnergyWh[s.cfg.LP.ID] / hours + } + surplus := loadpoint.SurplusAvailableForEVW(s.gridW, s.batW, s.evW, s.lp.AnyLoadpointSurplusActive()) - planA, _ := s.actionAt(s.now) s.sender.lastSet = false - s.lp.Tick(context.Background(), s.now) + s.lp.TickWithDispatch(context.Background(), s.now, true) evCmd := 0.0 if s.sender.lastSet { evCmd = s.sender.lastW @@ -338,34 +339,26 @@ func (s *evSite) tick() evSiteTick { } } - hours := s.dt.Hours() + dtH := s.dt.Hours() s.evW = evCmd if s.evW < 0 { s.evW = 0 } s.batW = batCmd - headroomWh := s.cfg.BatCapWh*0.95 - s.batEnergyWh - if s.batW > 0 && s.batW*hours > headroomWh && hours > 0 { - s.batW = headroomWh / hours - if s.batW < 0 { - s.batW = 0 - } - } - floorWh := s.cfg.BatCapWh * 0.10 - if s.batW < 0 && s.batEnergyWh+s.batW*hours/0.95 < floorWh && hours > 0 { - s.batW = -(s.batEnergyWh - floorWh) * 0.95 / hours - if s.batW > 0 { - s.batW = 0 - } - } - s.gridW = s.loadW + s.pvW + s.batW + s.evW + s.gridW = loadpoint.GridW(s.loadW, s.pvW, s.batW, s.evW) if s.batW >= 0 { - s.batEnergyWh += s.batW * hours * 0.95 + s.batEnergyWh += s.batW * dtH * 0.95 } else { - s.batEnergyWh += s.batW * hours / 0.95 + s.batEnergyWh += s.batW * dtH / 0.95 } - s.evEnergyWh += s.evW * hours * 0.90 - s.sessionWh += s.evW * hours + if s.batEnergyWh < 0 { + s.batEnergyWh = 0 + } + if s.batEnergyWh > s.cfg.BatCapWh { + s.batEnergyWh = s.cfg.BatCapWh + } + s.evEnergyWh += s.evW * dtH * 0.90 + s.sessionWh += s.evW * dtH rec := evSiteTick{ N: len(s.ticks), @@ -378,9 +371,9 @@ func (s *evSite) tick() evSiteTick { BatCmdW: batCmd, EVCmdW: evCmd, SurplusW: surplus, - PlanBatW: planA.BatteryW, - PlanEVW: planA.LoadpointW, - PlanGridW: planA.GridW, + PlanBatW: planBatW, + PlanEVW: planEVW, + PlanGridW: d.GridW, } s.checkInvariants(rec) s.ticks = append(s.ticks, rec) @@ -388,7 +381,7 @@ func (s *evSite) tick() evSiteTick { return rec } -func (s *evSite) run(n int) []evSiteTick { +func (s *siteClock) run(n int) []evSiteTick { s.t.Helper() out := make([]evSiteTick, 0, n) for i := 0; i < n; i++ { @@ -397,42 +390,32 @@ func (s *evSite) run(n int) []evSiteTick { return out } -func (s *evSite) leftoverW() float64 { - v := -(s.loadW + s.pvW) - if v < 0 { - return 0 - } - return v +func (s *siteClock) leftoverW() float64 { + return loadpoint.PVLeftoverAfterHouseW(s.loadW, s.pvW) } -func (s *evSite) checkInvariants(rec evSiteTick) { +func (s *siteClock) checkInvariants(rec evSiteTick) { s.t.Helper() - ident := rec.LoadW + rec.PVW + rec.BatW + rec.EVW + ident := loadpoint.GridW(rec.LoadW, rec.PVW, rec.BatW, rec.EVW) if math.Abs(rec.GridW-ident) > 1 { s.t.Fatalf("tick %d: grid identity %.1f != load+pv+bat+ev %.1f", rec.N, rec.GridW, ident) } - if rec.GridW > s.fuseW+50 { + if rec.GridW > s.fuseW+loadpoint.SitePowerEpsW { s.t.Fatalf("tick %d: grid %.0f W over fuse %.0f W", rec.N, rec.GridW, s.fuseW) } - if s.surplusOnly && rec.EVW > 50 { - if rec.EVW > s.leftoverW()+50 { + if s.surplusOnly && rec.EVW > loadpoint.SitePowerEpsW { + if loadpoint.SurplusOnlyExceedsHousePV(rec.EVW, rec.LoadW, rec.PVW) { s.t.Fatalf("tick %d: surplus-only EV %.0f W exceeds leftover PV after house %.0f W (grid=%.0f bat=%.0f)", rec.N, rec.EVW, s.leftoverW(), rec.GridW, rec.BatW) } - if rec.BatW < -50 { - house := rec.LoadW + rec.PVW - if house < 0 { - house = 0 - } - if -rec.BatW > house+50 { - s.t.Fatalf("tick %d: battery discharge %.0f W feeds surplus-only EV %.0f W (house residual %.0f W)", - rec.N, rec.BatW, rec.EVW, house) - } + if loadpoint.BatteryDischargeFeedsEV(rec.BatW, rec.EVW, rec.LoadW, rec.PVW) { + s.t.Fatalf("tick %d: battery discharge %.0f W feeds surplus-only EV %.0f W (house residual %.0f W)", + rec.N, rec.BatW, rec.EVW, loadpoint.HouseResidualW(rec.LoadW, rec.PVW)) } } } -func (s *evSite) requireCombo(afterTicks int) evSiteTick { +func (s *siteClock) requireCombo(afterTicks int) evSiteTick { s.t.Helper() for _, rec := range s.ticks { if rec.N < afterTicks { @@ -447,50 +430,38 @@ func (s *evSite) requireCombo(afterTicks int) evSiteTick { return evSiteTick{} } -func (s *evSite) requireIdleEV(afterTicks int) { +func (s *siteClock) requireIdleEV(afterTicks int) { s.t.Helper() for _, rec := range s.ticks { if rec.N < afterTicks { continue } - if rec.EVW > 50 { + if rec.EVW > loadpoint.SitePowerEpsW { s.t.Fatalf("tick %d: surplus-only EV imported without leftover PV: ev=%.0f grid=%.0f bat=%.0f pv=%.0f; ticks=%s", rec.N, rec.EVW, rec.GridW, rec.BatW, rec.PVW, s.dumpTicks()) } } } -func (s *evSite) dumpTicks() string { +func (s *siteClock) dumpTicks() string { b := make([]byte, 0, 256) for _, rec := range s.ticks { - b = append(b, []byte( - rec.At.Format("15:04:05")+" ev="+itoa(rec.EVW)+" bat="+itoa(rec.BatW)+" grid="+itoa(rec.GridW)+" surplus="+itoa(rec.SurplusW)+"\n", - )...) + b = append(b, []byte(fmt.Sprintf("%s ev=%.0f bat=%.0f grid=%.0f surplus=%.0f\n", + rec.At.Format("15:04:05"), rec.EVW, rec.BatW, rec.GridW, rec.SurplusW))...) } return string(b) } -func itoa(w float64) string { - return jsonNumber(w) -} - -func jsonNumber(w float64) string { - b, _ := json.Marshal(math.Round(w)) - return string(b) -} - func injectedChargePlan(start time.Time, slotMin int, batW, evW, loadW, pvW float64) mpc.Plan { - gridW := loadW + pvW + batW + evW return mpc.Plan{ - GeneratedAtMs: start.UnixMilli(), - Mode: mpc.ModeArbitrage, - HorizonSlots: 1, + Mode: mpc.ModeArbitrage, + HorizonSlots: 1, Actions: []mpc.Action{{ SlotStartMs: start.UnixMilli(), SlotLenMin: slotMin, BatteryW: batW, LoadpointW: evW, - GridW: gridW, + GridW: loadpoint.GridW(loadW, pvW, batW, evW), LoadW: loadW, PVW: pvW, }}, diff --git a/go/internal/control/ev_site_test.go b/go/internal/control/ev_site_test.go index 2a9e0da1..c0120af9 100644 --- a/go/internal/control/ev_site_test.go +++ b/go/internal/control/ev_site_test.go @@ -4,6 +4,7 @@ import ( "testing" "time" + "github.com/srcfl/ftw/go/internal/loadpoint" "github.com/srcfl/ftw/go/internal/mpc" ) @@ -30,7 +31,7 @@ func TestEVSiteSurplusOnlyTakesLeftoverPVWhileBatteryGridCharges(t *testing.T) { // treated battery charge as already-claimed PV and offered the car // −grid+ev < 0 while Pixii imported, so the EV never moved. start := evComboSiteStart() - site := newEVSite(t, evSiteConfig{ + site := newSiteClock(t, evSiteConfig{ Start: start, Plan: injectedChargePlan(evComboSlotStart(), 15, evComboBatW, 0, evComboLoadW, evComboPVW), LP: surplusOnlyGarage(), @@ -39,14 +40,14 @@ func TestEVSiteSurplusOnlyTakesLeftoverPVWhileBatteryGridCharges(t *testing.T) { }) site.run(12) got := site.requireCombo(4) - if got.EVW > site.leftoverW()+50 { + if got.EVW > site.leftoverW()+loadpoint.SitePowerEpsW { t.Errorf("EV %.0f W exceeded leftover %.0f W", got.EVW, site.leftoverW()) } } func TestEVSitePlannedSurplusEVChargesBesideBatteryGridCharge(t *testing.T) { start := evComboSiteStart() - site := newEVSite(t, evSiteConfig{ + site := newSiteClock(t, evSiteConfig{ Start: start, Plan: injectedChargePlan(evComboSlotStart(), 15, evComboBatW, 4140, evComboLoadW, evComboPVW), LP: surplusOnlyGarage(), @@ -97,7 +98,7 @@ func TestEVSiteOptimizeThenDispatchChargesEVFromPVBesideBatteryImport(t *testing NoBatteryToEV: true, }, } - site := newEVSite(t, evSiteConfig{ + site := newSiteClock(t, evSiteConfig{ Start: evComboSiteStart(), OptimizeSlots: slots, OptimizeParams: params, @@ -106,8 +107,8 @@ func TestEVSiteOptimizeThenDispatchChargesEVFromPVBesideBatteryImport(t *testing PVW: evComboPVW, BatMaxCharge: 10000, }) - if site.plan.Actions[0].BatteryW < 500 { - t.Fatalf("cheap slot should charge the home battery, got %+v", site.plan.Actions[0]) + if site.plan().Actions[0].BatteryW < 500 { + t.Fatalf("cheap slot should charge the home battery, got %+v", site.plan().Actions[0]) } site.run(12) site.requireCombo(4) @@ -117,7 +118,7 @@ func TestEVSiteIdleSurplusOnlyEVDoesNotBlockNightGridCharge(t *testing.T) { // #953: plugged idle surplus-only car, no PV, cheap night. start := time.Date(2026, 8, 18, 2, 0, 1, 0, time.UTC) slot := time.Date(2026, 8, 18, 2, 0, 0, 0, time.UTC) - site := newEVSite(t, evSiteConfig{ + site := newSiteClock(t, evSiteConfig{ Start: start, Plan: injectedChargePlan(slot, 15, 5000, 0, 500, 0), LP: surplusOnlyGarage(), @@ -143,7 +144,7 @@ func TestEVSiteSurplusOnlyPausesWhenLeftoverCannotHold3Phase(t *testing.T) { // rather than import the gap. Battery may still buy from the grid. start := evComboSiteStart() const loadW, pvW = 500.0, -1800.0 // leftover 1300 W - site := newEVSite(t, evSiteConfig{ + site := newSiteClock(t, evSiteConfig{ Start: start, Plan: injectedChargePlan(evComboSlotStart(), 15, 5000, 11000, loadW, pvW), LP: surplusOnlyGarage(), @@ -159,7 +160,7 @@ func TestEVSiteScheduledEVMayImportOnCheapNight(t *testing.T) { slot := time.Date(2026, 8, 18, 2, 0, 0, 0, time.UTC) lp := surplusOnlyGarage() lp.SurplusOnly = false - site := newEVSite(t, evSiteConfig{ + site := newSiteClock(t, evSiteConfig{ Start: start, Plan: injectedChargePlan(slot, 15, 4000, 4140, 500, 0), LP: lp, @@ -181,7 +182,7 @@ func TestEVSiteScheduledEVMayImportOnCheapNight(t *testing.T) { func TestEVSiteBatteryDoesNotDischargeIntoSurplusOnlyEV(t *testing.T) { start := evComboSiteStart() - site := newEVSite(t, evSiteConfig{ + site := newSiteClock(t, evSiteConfig{ Start: start, Plan: injectedChargePlan(evComboSlotStart(), 15, -4000, 4000, 500, 0), LP: surplusOnlyGarage(), @@ -192,10 +193,10 @@ func TestEVSiteBatteryDoesNotDischargeIntoSurplusOnlyEV(t *testing.T) { }) site.run(8) for _, rec := range site.ticks { - if rec.EVW > 50 && rec.BatW < -50 { + if rec.EVW > loadpoint.SitePowerEpsW && rec.BatW < -loadpoint.SitePowerEpsW { t.Fatalf("tick %d: surplus-only EV %.0f W with battery discharge %.0f W", rec.N, rec.EVW, rec.BatW) } - if rec.EVW > 50 { + if rec.EVW > loadpoint.SitePowerEpsW { t.Fatalf("tick %d: surplus-only EV charged without PV: %.0f W", rec.N, rec.EVW) } } diff --git a/go/internal/control/slot_directive.go b/go/internal/control/slot_directive.go new file mode 100644 index 00000000..a240bd96 --- /dev/null +++ b/go/internal/control/slot_directive.go @@ -0,0 +1,24 @@ +package control + +import ( + "github.com/srcfl/ftw/go/internal/mpc" +) + +// SlotDirectiveFromMPC is the plan→EMS bridge. main.go and the site +// clock must share it so EV energy budgets cannot drift from the +// battery slot the dispatcher executes. +func SlotDirectiveFromMPC(d mpc.SlotDirective) SlotDirective { + return SlotDirective{ + DecisionID: d.DecisionID, + SlotStart: d.SlotStart, + SlotEnd: d.SlotEnd, + BatteryEnergyWh: d.BatteryEnergyWh, + SoCTarget: d.SoCTarget, + Strategy: string(d.Strategy), + PVLimitW: d.PVLimitW, + PlannedGridW: d.GridW, + HasPlannedGridW: true, + LivePVSurplusSoCCap: d.LivePVSurplusSoCCap, + LoadpointEnergyWh: d.LoadpointEnergyWh, + } +} diff --git a/go/internal/loadpoint/site_power.go b/go/internal/loadpoint/site_power.go new file mode 100644 index 00000000..b6eda468 --- /dev/null +++ b/go/internal/loadpoint/site_power.go @@ -0,0 +1,62 @@ +package loadpoint + +// Site power identity (site-signed: PV generation is negative, battery +// charge and EV charge are positive, grid import is positive): +// +// gridW = loadW + pvW + batteryW + evW +// +// Leftover PV after the house and house residual after PV are the two +// sides of the same number. Surplus-only EV and "battery may not feed +// EV" are policies on this identity, not extra meters. +const SitePowerEpsW = 50 + +// GridW is the meter flow implied by house, PV, battery and EV. +func GridW(loadW, pvW, batteryW, evW float64) float64 { + return loadW + pvW + batteryW + evW +} + +// PVLeftoverAfterHouseW is PV remaining after house load. Zero when the +// house consumes the whole array (or more). +func PVLeftoverAfterHouseW(loadW, pvW float64) float64 { + leftover := -(loadW + pvW) + if leftover < 0 { + return 0 + } + return leftover +} + +// HouseResidualW is house demand still to be covered after PV. Zero when +// PV covers the house (or more). Battery discharge up to this amount can +// still be claimed as "house only"; anything beyond it feeds the EV or +// the grid. +func HouseResidualW(loadW, pvW float64) float64 { + residual := loadW + pvW + if residual < 0 { + return 0 + } + return residual +} + +// SurplusOnlyExceedsHousePV reports whether evW would have to come from +// the grid or the home battery rather than leftover PV. Site import from +// a simultaneous home-battery grid-charge is not the car importing. +func SurplusOnlyExceedsHousePV(evW, loadW, pvW float64) bool { + return evW > PVLeftoverAfterHouseW(loadW, pvW)+SitePowerEpsW +} + +// BatteryDischargeFeedsEV reports whether a simultaneous battery +// discharge and EV charge would, by conservation, put battery energy +// into the car. +func BatteryDischargeFeedsEV(batteryW, evW, loadW, pvW float64) bool { + if evW <= 0 || batteryW >= 0 { + return false + } + return -batteryW > HouseResidualW(loadW, pvW)+SitePowerEpsW +} + +// PlannedSurplusForEVW is the near-term 3Φ-gate quantity: leftover PV +// after house load, minus planned PV-soak battery charge. Grid-funded +// battery charge does not consume leftover the car can take. +func PlannedSurplusForEVW(loadW, pvW, batteryW, gridW float64) float64 { + return -pvW - loadW - PlannedPVSoakW(batteryW, gridW) +} diff --git a/go/internal/loadpoint/site_power_test.go b/go/internal/loadpoint/site_power_test.go new file mode 100644 index 00000000..40a7cc62 --- /dev/null +++ b/go/internal/loadpoint/site_power_test.go @@ -0,0 +1,62 @@ +package loadpoint + +import "testing" + +func TestGridWIncludesEV(t *testing.T) { + // 500 house, 8 kW PV, 10 kW battery charge, 4.14 kW EV → import. + if got := GridW(500, -8000, 10000, 4140); got != 6640 { + t.Errorf("GridW = %.0f, want 6640", got) + } +} + +func TestPVLeftoverAndHouseResidualAreComplements(t *testing.T) { + if got := PVLeftoverAfterHouseW(500, -8000); got != 7500 { + t.Errorf("leftover = %.0f, want 7500", got) + } + if got := HouseResidualW(500, -8000); got != 0 { + t.Errorf("residual with surplus = %.0f, want 0", got) + } + if got := PVLeftoverAfterHouseW(2000, -500); got != 0 { + t.Errorf("leftover when house wins = %.0f, want 0", got) + } + if got := HouseResidualW(2000, -500); got != 1500 { + t.Errorf("residual = %.0f, want 1500", got) + } +} + +func TestSurplusOnlyExceedsHousePV(t *testing.T) { + if SurplusOnlyExceedsHousePV(4140, 500, -8000) { + t.Fatal("4140 W fits in 7500 W leftover") + } + if !SurplusOnlyExceedsHousePV(4140, 500, 0) { + t.Fatal("4140 W with no PV must exceed leftover") + } + if SurplusOnlyExceedsHousePV(SitePowerEpsW, 500, 0) { + t.Fatal("idle/noise EV must not trip leftover") + } +} + +func TestBatteryDischargeFeedsEV(t *testing.T) { + if !BatteryDischargeFeedsEV(-4000, 4000, 500, 0) { + t.Fatal("4000 W discharge with 500 W house residual must count as feeding EV") + } + if BatteryDischargeFeedsEV(-400, 4000, 500, 0) { + t.Fatal("discharge within house residual is house cover, not EV feed") + } + if BatteryDischargeFeedsEV(4000, 4140, 500, -8000) { + t.Fatal("battery charge cannot feed the EV") + } + if BatteryDischargeFeedsEV(-4000, 0, 500, 0) { + t.Fatal("idle EV cannot be fed") + } +} + +func TestPlannedSurplusForEVWSkipsGridFundedCharge(t *testing.T) { + // leftover 7500, battery soaking 2000 of it. + if got := PlannedSurplusForEVW(500, -8000, 2000, 0); got != 5500 { + t.Errorf("PV-soak: got %.0f, want 5500", got) + } + if got := PlannedSurplusForEVW(500, -8000, 10000, 2500); got != 7500 { + t.Errorf("grid-funded: got %.0f, want 7500 (soak does not apply)", got) + } +} diff --git a/go/internal/mpc/external_optimizer.go b/go/internal/mpc/external_optimizer.go index 662e4f33..502aaf23 100644 --- a/go/internal/mpc/external_optimizer.go +++ b/go/internal/mpc/external_optimizer.go @@ -11,6 +11,7 @@ import ( "time" "github.com/google/uuid" + "github.com/srcfl/ftw/go/internal/loadpoint" "github.com/srcfl/ftw/go/internal/optimizercontract" ) @@ -697,14 +698,11 @@ func ValidatePlan(slots []Slot, p Params, plan *Plan) error { if powerW > 0 && a.BatteryW < 0 && a.GridW < -50 { return fmt.Errorf("slot %d loadpoint %s charges during battery-driven export", i, lp.ID) } - if lp.blocksBatteryToEV() && powerW > 0 && a.BatteryW < 0 { - houseResidualW := math.Max(0, slot.LoadW+effectivePVW) - if -a.BatteryW > houseResidualW+50 { - return fmt.Errorf("slot %d battery discharge feeds loadpoint %s", i, lp.ID) - } + if lp.blocksBatteryToEV() && loadpoint.BatteryDischargeFeedsEV(a.BatteryW, powerW, slot.LoadW, effectivePVW) { + return fmt.Errorf("slot %d battery discharge feeds loadpoint %s", i, lp.ID) } } - wantGridW := slot.LoadW + effectivePVW + a.BatteryW + totalLoadpointW + wantGridW := loadpoint.GridW(slot.LoadW, effectivePVW, a.BatteryW, totalLoadpointW) if math.Abs(a.GridW-wantGridW) > 2 { return fmt.Errorf("slot %d grid balance %.3f, want %.3f", i, a.GridW, wantGridW) } diff --git a/go/internal/mpc/loadpoint_directive.go b/go/internal/mpc/loadpoint_directive.go new file mode 100644 index 00000000..ed28f1bb --- /dev/null +++ b/go/internal/mpc/loadpoint_directive.go @@ -0,0 +1,46 @@ +package mpc + +import ( + "time" + + "github.com/srcfl/ftw/go/internal/loadpoint" +) + +// LoadpointDirective is the charger slice of this slot. main.go and the +// site-clock tests both send this to loadpoint.Controller so EV energy +// budgets cannot drift from the plan the battery dispatch sees. +func (d SlotDirective) LoadpointDirective() loadpoint.Directive { + return loadpoint.Directive{ + SlotStart: d.SlotStart, + SlotEnd: d.SlotEnd, + LoadpointEnergyWh: d.LoadpointEnergyWh, + } +} + +// PeakPlannedSurplusForEV is the near-term 3Φ-gate scan: peak leftover +// PV after house load, minus planned PV-soak battery charge, over +// slots that overlap [now, now+window]. Grid-funded battery charge +// does not consume leftover the car can take. +func PeakPlannedSurplusForEV(actions []Action, now time.Time, window time.Duration) (float64, bool) { + if len(actions) == 0 { + return 0, false + } + horizon := now.Add(window) + var peak float64 + any := false + for _, a := range actions { + slotEnd := time.UnixMilli(a.SlotStartMs).Add(time.Duration(a.SlotLenMin) * time.Minute) + if slotEnd.Before(now) { + continue + } + if time.UnixMilli(a.SlotStartMs).After(horizon) { + break + } + surplus := loadpoint.PlannedSurplusForEVW(a.LoadW, a.PVW, a.BatteryW, a.GridW) + if !any || surplus > peak { + peak = surplus + any = true + } + } + return peak, any +} diff --git a/go/internal/mpc/loadpoint_directive_test.go b/go/internal/mpc/loadpoint_directive_test.go new file mode 100644 index 00000000..41130196 --- /dev/null +++ b/go/internal/mpc/loadpoint_directive_test.go @@ -0,0 +1,50 @@ +package mpc + +import ( + "testing" + "time" + + "github.com/srcfl/ftw/go/internal/loadpoint" +) + +func TestSlotDirectiveLoadpointDirectiveCarriesEVBudget(t *testing.T) { + start := time.Date(2026, 8, 18, 12, 0, 0, 0, time.UTC) + d := SlotDirective{ + SlotStart: start, + SlotEnd: start.Add(15 * time.Minute), + BatteryEnergyWh: 2500, + LoadpointEnergyWh: map[string]float64{"garage": 1035}, + } + got := d.LoadpointDirective() + if !got.SlotStart.Equal(d.SlotStart) || !got.SlotEnd.Equal(d.SlotEnd) { + t.Fatalf("slot window changed: %+v", got) + } + if got.LoadpointEnergyWh["garage"] != 1035 { + t.Fatalf("EV budget dropped: %+v", got.LoadpointEnergyWh) + } +} + +func TestPeakPlannedSurplusForEVSkipsGridFundedCharge(t *testing.T) { + start := time.Date(2026, 8, 18, 12, 0, 0, 0, time.UTC) + actions := []Action{ + { + SlotStartMs: start.UnixMilli(), SlotLenMin: 15, + LoadW: 500, PVW: -8000, BatteryW: 10000, GridW: 2500, + }, + { + SlotStartMs: start.Add(15 * time.Minute).UnixMilli(), SlotLenMin: 15, + LoadW: 500, PVW: 0, BatteryW: 0, GridW: 500, + }, + } + peak, ok := PeakPlannedSurplusForEV(actions, start.Add(time.Second), 30*time.Minute) + if !ok { + t.Fatal("expected a peak") + } + want := loadpoint.PlannedSurplusForEVW(500, -8000, 10000, 2500) + if peak != want { + t.Errorf("peak = %.0f, want %.0f", peak, want) + } + if peak != 7500 { + t.Errorf("grid-funded slot must still offer leftover PV %.0f, got %.0f", 7500.0, peak) + } +} diff --git a/go/internal/mpc/loadpoint_service_test.go b/go/internal/mpc/loadpoint_service_test.go index 6dea563e..57df8f9f 100644 --- a/go/internal/mpc/loadpoint_service_test.go +++ b/go/internal/mpc/loadpoint_service_test.go @@ -3,6 +3,8 @@ package mpc import ( "testing" "time" + + "github.com/srcfl/ftw/go/internal/loadpoint" ) // TestSlotDirectiveCarriesLoadpointEnergyWh asserts that when the DP @@ -68,12 +70,8 @@ func TestSlotDirectiveCarriesLoadpointEnergyWh(t *testing.T) { t.Fatalf("DP never scheduled EV charging; actions: %+v", plan.Actions) } - svc := &Service{ - Zone: "SE3", - Defaults: Params{Mode: ModeCheapCharge}, - last: &plan, - lastLoadpointID: "garage", - } + svc := &Service{Zone: "SE3", Defaults: Params{Mode: ModeCheapCharge}} + svc.InstallPlan(plan, p, "garage") // Query inside the charged slot. queryAt := time.UnixMilli(plan.Actions[chargedSlotIdx].SlotStartMs).Add(1 * time.Minute) d, ok := svc.SlotDirectiveAt(queryAt) @@ -285,11 +283,7 @@ func TestSurplusOnlyForbidsBatteryFeedingEVEvenWhenCoverEVEnabled(t *testing.T) plan := Optimize(slots, mkParams(true)) for i, a := range plan.Actions { - houseResidualW := slots[i].LoadW + slots[i].PVW - if houseResidualW < 0 { - houseResidualW = 0 - } - if a.LoadpointW > 100 && a.BatteryW < -(houseResidualW+50) { + if loadpoint.BatteryDischargeFeedsEV(a.BatteryW, a.LoadpointW, slots[i].LoadW, slots[i].PVW) { t.Errorf("slot %d: surplus_only used battery as EV surplus — battW=%.0f loadpointW=%.0f gridW=%.0f", i, a.BatteryW, a.LoadpointW, a.GridW) } @@ -433,7 +427,7 @@ func TestSurplusOnlyEVCannotImportEvenWithDeadline(t *testing.T) { a := plan.Actions[0] if surplusOnlyExceedsHousePV(a.LoadpointW, slots[0].LoadW, slots[0].PVW) { t.Errorf("surplus-only EV exceeded leftover PV: evW=%.0f leftover=%.0f gridW=%.0f", - a.LoadpointW, pvLeftoverAfterHouseW(slots[0].LoadW, slots[0].PVW), a.GridW) + a.LoadpointW, loadpoint.PVLeftoverAfterHouseW(slots[0].LoadW, slots[0].PVW), a.GridW) } } @@ -488,6 +482,40 @@ func TestArbitrageChargesSurplusOnlyEVFromPVWhileBatteryGridCharges(t *testing.T t.Errorf("battery charge past leftover PV must import: %+v", a) } if surplusOnlyExceedsHousePV(a.LoadpointW, slots[0].LoadW, slots[0].PVW) { - t.Errorf("EV %.0f W exceeded leftover %.0f W", a.LoadpointW, pvLeftoverAfterHouseW(slots[0].LoadW, slots[0].PVW)) + t.Errorf("EV %.0f W exceeded leftover %.0f W", a.LoadpointW, loadpoint.PVLeftoverAfterHouseW(slots[0].LoadW, slots[0].PVW)) + } +} + +func TestInstallPlanPublishesLoadpointEnergyToSlotDirectiveAt(t *testing.T) { + now := time.Now().UTC().Truncate(time.Second) + plan := Plan{ + GeneratedAtMs: now.UnixMilli(), + Mode: ModeArbitrage, + Actions: []Action{{ + SlotStartMs: now.UnixMilli(), + SlotLenMin: 15, + BatteryW: 10000, + LoadpointW: 4140, + LoadW: 500, + PVW: -8000, + GridW: loadpoint.GridW(500, -8000, 10000, 4140), + }}, + } + svc := &Service{} + svc.InstallPlan(plan, Params{Mode: ModeArbitrage}, "garage") + d, ok := svc.SlotDirectiveAt(now.Add(time.Second)) + if !ok { + t.Fatal("fresh InstallPlan must be visible to SlotDirectiveAt") + } + wantEV := 4140.0 * 15 / 60 + if d.LoadpointEnergyWh["garage"] != wantEV { + t.Fatalf("EV budget = %+v, want garage=%.0f Wh", d.LoadpointEnergyWh, wantEV) + } + if d.Strategy != ModeArbitrage { + t.Fatalf("Strategy = %q, want %q", d.Strategy, ModeArbitrage) + } + lp := d.LoadpointDirective() + if lp.LoadpointEnergyWh["garage"] != wantEV { + t.Fatalf("LoadpointDirective dropped EV budget: %+v", lp.LoadpointEnergyWh) } } diff --git a/go/internal/mpc/loadpoint_spec.go b/go/internal/mpc/loadpoint_spec.go index b54b5aa1..cf3b89e2 100644 --- a/go/internal/mpc/loadpoint_spec.go +++ b/go/internal/mpc/loadpoint_spec.go @@ -1,5 +1,7 @@ package mpc +import "github.com/srcfl/ftw/go/internal/loadpoint" + // LoadpointSpec tells the DP how to extend its state space with an EV // loadpoint. Set `Params.Loadpoint` to a non-nil spec to have the // optimizer treat charging the EV as a decision variable alongside @@ -64,20 +66,10 @@ type LoadpointSpec struct { // NoBatteryToEV mirrors ctrl.State.BatteryCoversEV inverted: when // true (operator's default), the home battery's discharge MUST NOT - // end up at the EV. The DP feasibility check enforces this by - // rejecting any (battW, evW) combination where battery discharge - // exceeds the PV-residual house demand — i.e. where some of the - // battery's energy must, by conservation, have flowed into the EV - // or out to grid (and the existing battery-export-vs-EV rule - // already covers the export case). The runtime dispatch in - // control/dispatch.go has the canonical clamp using identical - // accounting (search "CANONICAL \"battery may not feed EV\""); the - // DP rule here stops the planner from emitting infeasible - // allocations that dispatch then has to censor, removing the - // plan↔reality divergence operators were seeing on - // planner_arbitrage slots. A future refactor should extract the - // shared houseResidualW + feasibility predicate into a helper so - // the two sites can't drift. + // end up at the EV. Enforced by loadpoint.BatteryDischargeFeedsEV + // in the DP and in ValidatePlan; the runtime clamp in + // control/dispatch.go (search CANONICAL "battery may not feed EV") + // is the same conservation check. NoBatteryToEV bool } @@ -85,24 +77,11 @@ func (l *LoadpointSpec) blocksBatteryToEV() bool { return l != nil && (l.NoBatteryToEV || l.SurplusOnly) } -// surplusOnlyEpsW matches the neighbouring DP / ValidatePlan float dither -// (modeTolW, battery-to-EV residual, export-vs-EV). -const surplusOnlyEpsW = 50 - -// pvLeftoverAfterHouseW is the watts of PV remaining after house load. -// PVW is site-signed (negative generation). -func pvLeftoverAfterHouseW(loadW, pvW float64) float64 { - leftover := -(loadW + pvW) - if leftover < 0 { - return 0 - } - return leftover -} - -// surplusOnlyExceedsHousePV reports whether evW would have to come from -// the grid or the home battery rather than from leftover PV. +// surplusOnlyExceedsHousePV is the planner name for the site-power +// leftover check. Surplus-only is an EV policy: leftover PV after the +// house, not a ban on site import while the car is charging. func surplusOnlyExceedsHousePV(evW, loadW, pvW float64) bool { - return evW > pvLeftoverAfterHouseW(loadW, pvW)+surplusOnlyEpsW + return loadpoint.SurplusOnlyExceedsHousePV(evW, loadW, pvW) } // normalizedSteps returns a non-nil, 0-included, dedup'd + sorted diff --git a/go/internal/mpc/loadpoint_spec_test.go b/go/internal/mpc/loadpoint_spec_test.go index 2d6a42ef..742026e4 100644 --- a/go/internal/mpc/loadpoint_spec_test.go +++ b/go/internal/mpc/loadpoint_spec_test.go @@ -2,24 +2,6 @@ package mpc import "testing" -func TestPVLeftoverAfterHouse(t *testing.T) { - if got := pvLeftoverAfterHouseW(500, -6500); got != 6000 { - t.Errorf("got %.0f, want 6000", got) - } - if got := pvLeftoverAfterHouseW(2000, -500); got != 0 { - t.Errorf("got %.0f, want 0 (house exceeds PV)", got) - } - if surplusOnlyExceedsHousePV(4140, 500, -6500) { - t.Fatal("4140 W EV fits in 6000 W leftover") - } - if !surplusOnlyExceedsHousePV(4140, 500, 0) { - t.Fatal("4140 W EV with no PV must exceed leftover") - } - if surplusOnlyExceedsHousePV(50, 500, 0) { - t.Fatal("idle/noise EV must not trip leftover") - } -} - func TestNormalizedStepsDefaults(t *testing.T) { cases := []struct { name string diff --git a/go/internal/mpc/mpc.go b/go/internal/mpc/mpc.go index b25c511b..4117edec 100644 --- a/go/internal/mpc/mpc.go +++ b/go/internal/mpc/mpc.go @@ -17,9 +17,10 @@ // battery > 0 → charging (load on site) // battery < 0 → discharging (source on site) // -// Power balance per slot (from the grid meter's point of view): +// Power balance per slot (from the grid meter's point of view). EV +// charge is a site load; the identity lives in loadpoint.GridW: // -// grid_w = load_w + pv_w + battery_w +// grid_w = load_w + pv_w + battery_w + ev_w // // Battery efficiency: the `battery_w` we command is measured at the AC // terminals (site-facing). Due to conversion losses, only a fraction @@ -41,6 +42,7 @@ import ( "time" "github.com/srcfl/ftw/go/internal/gridcost" + "github.com/srcfl/ftw/go/internal/loadpoint" ) // Mode selects how aggressively the planner uses the battery. @@ -709,8 +711,7 @@ func Optimize(slots []Slot, p Params) Plan { } } // EV appears as a site load (+ site-signed). - // GridW = load + PV + battery + EV. - gridW := slot.LoadW + slot.PVW + battW + evW + gridW := loadpoint.GridW(slot.LoadW, slot.PVW, battW, evW) // Surplus-only EV: take at most leftover PV // after house load. Site import caused by a @@ -724,8 +725,7 @@ func Optimize(slots []Slot, p Params) Plan { // appear on the same site. evW = 0 is always // feasible, so a no-PV day degrades to "miss // the deadline" rather than an infeasible - // plan. 50 W epsilon matches the neighbouring - // EV feasibility rules. + // plan. Epsilon is loadpoint.SitePowerEpsW. if evActive && lp.SurplusOnly && surplusOnlyExceedsHousePV(evW, slot.LoadW, slot.PVW) { continue } @@ -759,41 +759,19 @@ func Optimize(slots []Slot, p Params) Plan { } // Battery-to-EV block: operator has BatteryCoversEV=false - // (the default), or this loadpoint is surplus-only. In - // both cases, the home battery's energy may cover house - // load but must not become synthetic EV surplus. - // Reject any allocation where the battery's - // discharge exceeds the PV-residual house demand - // — i.e. where, by conservation, some of the - // battery's energy must have flowed into the EV. - // houseResidualW = max(0, load - pv_gen) is how - // much house demand is left after PV has covered - // what it can; the battery can supply up to that - // much and still be claimed as "house only". - // Anything beyond it must go to EV (illegal here) - // or grid (covered by the rule above). - // Matches the canonical runtime safety clamp in - // control/dispatch.go (search "CANONICAL - // \"battery may not feed EV\"") — keep them - // aligned. 50 W epsilon mirrors the surrounding - // constraints. TODO(refactor): the - // houseResidualW math + feasibility predicate - // is duplicated; extract a shared helper. - if evActive && lp.blocksBatteryToEV() && evW > 0 && battW < 0 { - houseResidualW := slot.LoadW + slot.PVW // PVW is negative - if houseResidualW < 0 { - houseResidualW = 0 - } - if (-battW) > houseResidualW+50 { - continue - } + // (the default), or this loadpoint is surplus-only. Same + // conservation check as ValidatePlan and the runtime + // clamp in control/dispatch.go (search CANONICAL + // "battery may not feed EV"): loadpoint.BatteryDischargeFeedsEV. + if evActive && lp.blocksBatteryToEV() && loadpoint.BatteryDischargeFeedsEV(battW, evW, slot.LoadW, slot.PVW) { + continue } // Mode-based feasibility. Baseline includes // EV so the mode check asks "is the extra // battery action pulling the grid further // into import/export than baseline?". - baseGridW := slot.LoadW + slot.PVW + evW + baseGridW := loadpoint.GridW(slot.LoadW, slot.PVW, 0, evW) if !modeAllows(p.Mode, baseGridW, gridW, battW) { continue } @@ -1048,7 +1026,7 @@ func Optimize(slots []Slot, p Params) Plan { evSoc2 = lp.SoCMax } } - gridW := slot.LoadW + slot.PVW + actW + evW + gridW := loadpoint.GridW(slot.LoadW, slot.PVW, actW, evW) gridKWh := gridW * dtH / 1000.0 // Report the ACTUAL expected cost using the raw (un-blended) // prices so the UI summary reflects "what we'd actually pay diff --git a/go/internal/mpc/service.go b/go/internal/mpc/service.go index 90edce24..2e3e1e2c 100644 --- a/go/internal/mpc/service.go +++ b/go/internal/mpc/service.go @@ -372,6 +372,28 @@ func (s *Service) Latest() *Plan { return s.last } +// InstallPlan puts a plan in the cache SlotDirectiveAt and Latest read. +// Optimize and RestoreDiagnostic already write that cache after a +// successful solve. Tests that inject a known Action use the same seam +// so the charger and battery cannot be given two different mappings of +// one slot. +// +// GeneratedAtMs is aged against the wall clock (MaxPlanAge), not the +// slot clock passed to SlotDirectiveAt. A simulated site clock must +// still stamp GeneratedAtMs with time.Now(). +func (s *Service) InstallPlan(plan Plan, params Params, loadpointID string) { + if s == nil { + return + } + s.mu.Lock() + defer s.mu.Unlock() + copied := plan + s.last = &copied + s.lastParams = params + s.lastLoadpointID = loadpointID + s.lastReplanAt = time.Now() +} + // MaxPlanAge is the staleness cutoff. Once a plan's `generated_at_ms` // is older than this, we consider it stale and the control loop falls // back to self_consumption. Picked to be ~2× the replan interval so a From 7e27199928c36e30d5341cf4dccc079995a4be25 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Wed, 19 Aug 2026 14:17:47 +0200 Subject: [PATCH 03/57] fix(mpc): speak 0-1 SoC after si-core-units rebase The surplus-only EV tests still used SoCMinPct / InitialSoCPct. Core stores fractions. SlotDirectiveFromMPC uses SoCTarget and LivePVSurplusSoCCap. --- go/internal/control/ev_site_test.go | 10 +++++----- go/internal/control/slot_directive.go | 10 +++++----- go/internal/mpc/external_optimizer_test.go | 16 ++++++++-------- go/internal/mpc/loadpoint_service_test.go | 10 +++++----- 4 files changed, 23 insertions(+), 23 deletions(-) diff --git a/go/internal/control/ev_site_test.go b/go/internal/control/ev_site_test.go index c0120af9..4f1cf2d6 100644 --- a/go/internal/control/ev_site_test.go +++ b/go/internal/control/ev_site_test.go @@ -74,9 +74,9 @@ func TestEVSiteOptimizeThenDispatchChargesEVFromPVBesideBatteryImport(t *testing Mode: mpc.ModeArbitrage, SoCLevels: 11, CapacityWh: 20000, - SoCMinPct: 10, - SoCMaxPct: 95, - InitialSoCPct: 20, + SoCMin: 0.10, + SoCMax: 0.95, + InitialSoC: 0.20, ActionLevels: 11, MaxChargeW: 10000, MaxDischargeW: 10000, @@ -87,9 +87,9 @@ func TestEVSiteOptimizeThenDispatchChargesEVFromPVBesideBatteryImport(t *testing ID: evSiteLP, CapacityWh: 40000, Levels: 11, - InitialSoCPct: 20, + InitialSoC: 0.20, PluggedIn: true, - TargetSoCPct: 40, + TargetSoC: 0.40, TargetSlotIdx: 1, MaxChargeW: 4140, AllowedStepsW: []float64{0, 4140}, diff --git a/go/internal/control/slot_directive.go b/go/internal/control/slot_directive.go index a240bd96..17a80c21 100644 --- a/go/internal/control/slot_directive.go +++ b/go/internal/control/slot_directive.go @@ -9,16 +9,16 @@ import ( // battery slot the dispatcher executes. func SlotDirectiveFromMPC(d mpc.SlotDirective) SlotDirective { return SlotDirective{ - DecisionID: d.DecisionID, - SlotStart: d.SlotStart, - SlotEnd: d.SlotEnd, - BatteryEnergyWh: d.BatteryEnergyWh, + DecisionID: d.DecisionID, + SlotStart: d.SlotStart, + SlotEnd: d.SlotEnd, + BatteryEnergyWh: d.BatteryEnergyWh, SoCTarget: d.SoCTarget, Strategy: string(d.Strategy), PVLimitW: d.PVLimitW, PlannedGridW: d.GridW, HasPlannedGridW: true, LivePVSurplusSoCCap: d.LivePVSurplusSoCCap, - LoadpointEnergyWh: d.LoadpointEnergyWh, + LoadpointEnergyWh: d.LoadpointEnergyWh, } } diff --git a/go/internal/mpc/external_optimizer_test.go b/go/internal/mpc/external_optimizer_test.go index 4e9c4658..d0b36ab9 100644 --- a/go/internal/mpc/external_optimizer_test.go +++ b/go/internal/mpc/external_optimizer_test.go @@ -303,24 +303,24 @@ func TestValidatePlanAllowsEVPVWithBatteryGridCharge(t *testing.T) { slots := []Slot{{StartMs: 1, LenMin: 60, PriceOre: 20, SpotOre: 10, Confidence: 1, LoadW: 500, PVW: -6500}} p := Params{ Mode: ModeArbitrage, CapacityWh: 10000, - SoCMinPct: 10, SoCMaxPct: 95, InitialSoCPct: 20, + SoCMin: 0.10, SoCMax: 0.95, InitialSoC: 0.20, MaxChargeW: 5000, MaxDischargeW: 5000, ChargeEfficiency: 0.95, DischargeEfficiency: 0.95, Loadpoint: &LoadpointSpec{ - ID: "car", CapacityWh: 40000, Levels: 11, MinPct: 0, MaxPct: 100, - InitialSoCPct: 25, PluggedIn: true, MaxChargeW: 4140, + ID: "car", CapacityWh: 40000, Levels: 11, SoCMin: 0, SoCMax: 1, + InitialSoC: 0.25, PluggedIn: true, MaxChargeW: 4140, AllowedStepsW: []float64{0, 4140}, ChargeEfficiency: 1, SurplusOnly: true, NoBatteryToEV: true, }, } // leftover PV after house = 6000 W. EV 4140 + battery 5000 → - // grid = 500-6500+5000+4140 = 3140 import. Battery SoC: 20 + 47.5 = 67.5. - // EV SoC: 25 + 4140/40000*100 = 35.35. - plan := Plan{Mode: p.Mode, HorizonSlots: 1, CapacityWh: p.CapacityWh, InitialSoCPct: 20, + // grid = 500-6500+5000+4140 = 3140 import. Battery SoC: 0.20 + 0.475 = 0.675. + // EV SoC: 0.25 + 4140/40000 = 0.3535. + plan := Plan{Mode: p.Mode, HorizonSlots: 1, CapacityWh: p.CapacityWh, InitialSoC: 0.20, TotalCostOre: 62.8, Actions: []Action{{ SlotStartMs: 1, SlotLenMin: 60, - BatteryW: 5000, GridW: 3140, SoCPct: 67.5, - LoadpointW: 4140, LoadpointSoCPct: 35.35, CostOre: 62.8, + BatteryW: 5000, GridW: 3140, SoC: 0.675, + LoadpointW: 4140, LoadpointSoC: 0.3535, CostOre: 62.8, }}} if err := ValidatePlan(slots, p, &plan); err != nil { t.Fatalf("ValidatePlan rejected leftover-PV EV beside battery grid-charge: %v", err) diff --git a/go/internal/mpc/loadpoint_service_test.go b/go/internal/mpc/loadpoint_service_test.go index 57df8f9f..1ca419f0 100644 --- a/go/internal/mpc/loadpoint_service_test.go +++ b/go/internal/mpc/loadpoint_service_test.go @@ -444,9 +444,9 @@ func TestArbitrageChargesSurplusOnlyEVFromPVWhileBatteryGridCharges(t *testing.T Mode: ModeArbitrage, SoCLevels: 11, CapacityWh: 20000, - SoCMinPct: 10, - SoCMaxPct: 95, - InitialSoCPct: 20, + SoCMin: 0.10, + SoCMax: 0.95, + InitialSoC: 0.20, ActionLevels: 11, MaxChargeW: 10000, MaxDischargeW: 10000, @@ -457,9 +457,9 @@ func TestArbitrageChargesSurplusOnlyEVFromPVWhileBatteryGridCharges(t *testing.T ID: "garage", CapacityWh: 40000, Levels: 11, - InitialSoCPct: 20, + InitialSoC: 0.20, PluggedIn: true, - TargetSoCPct: 40, + TargetSoC: 0.40, TargetSlotIdx: 1, MaxChargeW: 4140, AllowedStepsW: []float64{0, 4140}, From 9a244951c5c5151b04078381a64ed8febd5b8db2 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 19 Aug 2026 15:48:49 +0000 Subject: [PATCH 04/57] fix(loadpoint): live surplus hides PV-soak even when EV made the meter import MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Meter import is not grid-funded battery charge. Soak plus EV can import together while the battery is still taking leftover PV. Live surplus and the 3Φ gate now treat grid minus EV as the battery's grid, the same identity PlannedPVSoakW already used on a soak-only tick. ValidatePlan rejects leftover breach. Python leftover-assert matches the new policy. Ship a new optimizer image with this core. Signed-off-by: Cursor Agent Co-authored-by: Fredrik Ahlgren --- .../surplus-only-ev-pv-beside-battery-grid.md | 2 ++ go/internal/control/ev_site_test.go | 25 +++++++++++++++++ go/internal/loadpoint/controller.go | 21 +++++++-------- go/internal/loadpoint/site_power.go | 5 ++-- go/internal/loadpoint/site_power_test.go | 5 ++++ go/internal/loadpoint/surplus_reserve.go | 25 ++++++++--------- go/internal/loadpoint/surplus_reserve_test.go | 15 +++++++---- go/internal/mpc/external_optimizer_test.go | 27 +++++++++++++++++++ go/internal/mpc/loadpoint_directive.go | 6 ++--- go/internal/mpc/loadpoint_directive_test.go | 16 +++++++++++ go/internal/mpc/loadpoint_spec.go | 9 ++++--- optimizer/ftw_optimizer/model.py | 3 +++ optimizer/tests/test_model.py | 6 +++-- 13 files changed, 125 insertions(+), 40 deletions(-) diff --git a/.changeset/surplus-only-ev-pv-beside-battery-grid.md b/.changeset/surplus-only-ev-pv-beside-battery-grid.md index 480bdeac..ab709578 100644 --- a/.changeset/surplus-only-ev-pv-beside-battery-grid.md +++ b/.changeset/surplus-only-ev-pv-beside-battery-grid.md @@ -3,3 +3,5 @@ --- A surplus-only EV can take leftover PV while the home battery buys from the grid. Surplus-only is an EV policy, not a site-wide import ban: the car still cannot import, and the home battery still cannot feed the car. + +Ship a new optimizer image with this core. HiGHS still plans under the leftover constraint; an old optimizer image will keep idling the car on cheap sun and never produce the combo ValidatePlan now accepts. diff --git a/go/internal/control/ev_site_test.go b/go/internal/control/ev_site_test.go index 4f1cf2d6..353a0cb6 100644 --- a/go/internal/control/ev_site_test.go +++ b/go/internal/control/ev_site_test.go @@ -180,6 +180,31 @@ func TestEVSiteScheduledEVMayImportOnCheapNight(t *testing.T) { } } +func TestEVSiteSurplusOnlyDoesNotClaimPVSoakWhenTogetherTheyWouldImport(t *testing.T) { + // Battery plan 4 kW of 7.5 kW leftover — soak, not Pixii buying. + // The old live reader treated any meter import as grid-charge and + // offered the full leftover, so a 3Φ snap held while soak+EV imported. + start := evComboSiteStart() + site := newSiteClock(t, evSiteConfig{ + Start: start, + Plan: injectedChargePlan(evComboSlotStart(), 15, 4000, 0, evComboLoadW, evComboPVW), + LP: surplusOnlyGarage(), + LoadW: evComboLoadW, + PVW: evComboPVW, + }) + site.run(12) + const soakHeadroom = 3500.0 // leftover 7500 − soak 4000 + for _, rec := range site.ticks { + if rec.N < 4 { + continue + } + if rec.EVW > soakHeadroom+loadpoint.SitePowerEpsW { + t.Fatalf("tick %d: surplus-only EV %.0f W claimed PV-soak (headroom %.0f); ticks=%s", + rec.N, rec.EVW, soakHeadroom, site.dumpTicks()) + } + } +} + func TestEVSiteBatteryDoesNotDischargeIntoSurplusOnlyEV(t *testing.T) { start := evComboSiteStart() site := newSiteClock(t, evSiteConfig{ diff --git a/go/internal/loadpoint/controller.go b/go/internal/loadpoint/controller.go index cfa3319a..3ede525a 100644 --- a/go/internal/loadpoint/controller.go +++ b/go/internal/loadpoint/controller.go @@ -76,13 +76,11 @@ type Controller struct { fusePhaseCapA map[string]float64 // siteSurplusForEVW returns the live PV surplus that this loadpoint - // could legally claim under surplus_only — i.e. *what's left of PV - // after house load*, regardless of what the home battery is - // currently absorbing. The arithmetic lives in main.go because - // it depends on per-site telemetry layout (pv driver, load driver, - // battery drivers, site-meter driver). Returns (_, false) when any - // of the inputs are stale; the controller then pauses rather than - // guess, which is the conservative default for "never import". + // could legally claim under surplus_only: leftover PV after house + // load, minus home-battery PV-soak. Grid-funded battery charge is + // not soak. Wired from main.go via SurplusAvailableForEVW. + // Returns (_, false) when any of the inputs are stale; the + // controller then pauses rather than guess. siteSurplusForEVW func() (float64, bool) // site is the grid-boundary fuse. Its values are passed through @@ -649,11 +647,10 @@ func (c *Controller) SetPerPhaseMeterAmps(f func() (l1, l2, l3 float64, ok bool) } // SetSiteSurplusForEV wires a per-tick "PV surplus available to the -// EV" reader for the surplus_only clamp. The function returns total -// W the EV could safely claim without forcing site import — typically -// `(-pvW - houseLoadW)` since that's PV-minus-load regardless of how -// the home battery is currently splitting it. Called once at startup -// from main.go. Pass nil to disable, in which case surplus_only is +// EV" reader for the surplus_only clamp. The function returns watts +// the EV may claim this tick: leftover after house load, minus +// PV-soak (SurplusAvailableForEVW). Called once at startup from +// main.go. Pass nil to disable, in which case surplus_only is // enforced only by the MPC plan (no live clamp). func (c *Controller) SetSiteSurplusForEV(f func() (float64, bool)) { if c == nil { diff --git a/go/internal/loadpoint/site_power.go b/go/internal/loadpoint/site_power.go index b6eda468..cb4f0091 100644 --- a/go/internal/loadpoint/site_power.go +++ b/go/internal/loadpoint/site_power.go @@ -55,8 +55,9 @@ func BatteryDischargeFeedsEV(batteryW, evW, loadW, pvW float64) bool { } // PlannedSurplusForEVW is the near-term 3Φ-gate quantity: leftover PV -// after house load, minus planned PV-soak battery charge. Grid-funded -// battery charge does not consume leftover the car can take. +// after house load, minus planned PV-soak battery charge. gridW is the +// grid flow attributed to house+battery (planned GridW minus EV). +// Grid-funded battery charge does not consume leftover the car can take. func PlannedSurplusForEVW(loadW, pvW, batteryW, gridW float64) float64 { return -pvW - loadW - PlannedPVSoakW(batteryW, gridW) } diff --git a/go/internal/loadpoint/site_power_test.go b/go/internal/loadpoint/site_power_test.go index 40a7cc62..c98495dd 100644 --- a/go/internal/loadpoint/site_power_test.go +++ b/go/internal/loadpoint/site_power_test.go @@ -59,4 +59,9 @@ func TestPlannedSurplusForEVWSkipsGridFundedCharge(t *testing.T) { if got := PlannedSurplusForEVW(500, -8000, 10000, 2500); got != 7500 { t.Errorf("grid-funded: got %.0f, want 7500 (soak does not apply)", got) } + // Soak + EV that together import: pass grid minus EV so soak is + // still detected (meter import 640 is the leak, not battery buying). + if got := PlannedSurplusForEVW(500, -8000, 4000, 640-6900); got != 3500 { + t.Errorf("soak+EV: got %.0f, want 3500", got) + } } diff --git a/go/internal/loadpoint/surplus_reserve.go b/go/internal/loadpoint/surplus_reserve.go index 787a10a1..20da3d9e 100644 --- a/go/internal/loadpoint/surplus_reserve.go +++ b/go/internal/loadpoint/surplus_reserve.go @@ -236,15 +236,16 @@ func PlannerTreatsLoadpointAsSurplusOnly(operatorSurplusOnly, deferGridPlan bool // Site identity: -gridW + batW + evW = -pvW - loadW (house leftover). // // The EV controller runs before battery dispatch on the same tick. If the -// home battery is soaking PV (charging while the site is not importing), -// counting that charge as EV-available would command the charger on -// before the battery has yielded and leak into import. Grid-funded -// battery charge is different: the battery is already importing, so the -// leftover PV is the car's to take without waiting for a yield. +// home battery is soaking PV, counting that charge as EV-available would +// command the charger on before the battery has yielded and leak into +// import. Meter import is not enough to call the charge grid-funded: +// soak plus EV can import together while the battery is still taking +// leftover PV. Import beyond the car (gridW − evW) is the battery +// buying; that leftover is the car's without waiting for a yield. func SurplusAvailableForEVW(gridW, batW, evW float64, surplusOnlyActive bool) float64 { leftover := -gridW + batW + evW - if surplusOnlyActive && batW > 0 && gridW <= GridChargeImportW { - leftover = -gridW + evW + if surplusOnlyActive { + leftover -= PlannedPVSoakW(batW, gridW-evW) } if leftover < 0 { return 0 @@ -252,11 +253,11 @@ func SurplusAvailableForEVW(gridW, batW, evW float64, surplusOnlyActive bool) fl return leftover } -// PlannedPVSoakW is the portion of a planned battery charge that is -// soaking leftover PV rather than buying from the grid. The near-term -// 3Φ gate subtracts this from forecast surplus so the EV does not wait -// for a 3Φ window the battery is about to eat. A grid-charge slot -// (PlannedGridW above the import band) does not consume that leftover. +// PlannedPVSoakW is the portion of a battery charge that is soaking +// leftover PV rather than buying from the grid. gridW is the grid +// flow attributed to house+battery (live meter minus EV, or planned +// GridW minus LoadpointW). A reading above the import band means the +// battery is buying, so soak is zero and leftover PV stays with the car. func PlannedPVSoakW(batteryW, gridW float64) float64 { if batteryW <= 0 || gridW > GridChargeImportW { return 0 diff --git a/go/internal/loadpoint/surplus_reserve_test.go b/go/internal/loadpoint/surplus_reserve_test.go index 462ce1c9..dd4cf0b9 100644 --- a/go/internal/loadpoint/surplus_reserve_test.go +++ b/go/internal/loadpoint/surplus_reserve_test.go @@ -232,11 +232,16 @@ func TestSurplusAvailableForEVWHidesPVSoakButNotGridCharge(t *testing.T) { if got := SurplusAvailableForEVW(0, 4000, 0, true); got != 0 { t.Errorf("PV-soak: got %.0f, want 0 (battery charge is not yet EV-available)", got) } - // Grid-funded battery charge: leftover PV is the car's. Without this - // the meter import zeros the surplus clamp and a surplus-only EV sits - // in the sun while Pixii buys. - if got := SurplusAvailableForEVW(1500, 5000, 4140, true); got != 7640 { - t.Errorf("grid-charge combo: got %.0f, want 7640 (-1500+5000+4140)", got) + // Soak + EV that together import: leftover 7640, battery 5000 < leftover + // so this is still soak. Meter import is the leak, not Pixii buying. + // Offering 7640 would keep the 4140 W setpoint that caused the import. + if got := SurplusAvailableForEVW(1500, 5000, 4140, true); got != 2640 { + t.Errorf("soak+EV import: got %.0f, want 2640 (leftover minus soak)", got) + } + // Grid-funded battery charge: leftover 7500, battery 10 kW, EV 4140, + // grid 6640. Import beyond the car is the battery buying. + if got := SurplusAvailableForEVW(6640, 10000, 4140, true); got != 7500 { + t.Errorf("grid-charge combo: got %.0f, want 7500", got) } if got := SurplusAvailableForEVW(-6500, 0, 0, true); got != 6500 { t.Errorf("exporting idle: got %.0f, want 6500", got) diff --git a/go/internal/mpc/external_optimizer_test.go b/go/internal/mpc/external_optimizer_test.go index d0b36ab9..2267c583 100644 --- a/go/internal/mpc/external_optimizer_test.go +++ b/go/internal/mpc/external_optimizer_test.go @@ -327,6 +327,33 @@ func TestValidatePlanAllowsEVPVWithBatteryGridCharge(t *testing.T) { } } +func TestValidatePlanRejectsSurplusOnlyEVAboveLeftoverPV(t *testing.T) { + slots := []Slot{{StartMs: 1, LenMin: 60, PriceOre: 20, SpotOre: 10, Confidence: 1, LoadW: 500, PVW: -6500}} + p := Params{ + Mode: ModeArbitrage, CapacityWh: 10000, + SoCMin: 0.10, SoCMax: 0.95, InitialSoC: 0.20, + MaxChargeW: 5000, MaxDischargeW: 5000, + ChargeEfficiency: 0.95, DischargeEfficiency: 0.95, + Loadpoint: &LoadpointSpec{ + ID: "car", CapacityWh: 40000, Levels: 11, SoCMin: 0, SoCMax: 1, + InitialSoC: 0.25, PluggedIn: true, MaxChargeW: 11000, + AllowedStepsW: []float64{0, 7000}, ChargeEfficiency: 1, + SurplusOnly: true, NoBatteryToEV: true, + }, + } + // leftover after house = 6000 W. EV 7000 exceeds it even though + // the home battery is the one importing. + plan := Plan{Mode: p.Mode, HorizonSlots: 1, CapacityWh: p.CapacityWh, InitialSoC: 0.20, + TotalCostOre: 120, Actions: []Action{{ + SlotStartMs: 1, SlotLenMin: 60, + BatteryW: 5000, GridW: 6000, SoC: 0.675, + LoadpointW: 7000, LoadpointSoC: 0.425, CostOre: 120, + }}} + if err := ValidatePlan(slots, p, &plan); err == nil { + t.Fatal("ValidatePlan accepted surplus-only EV above leftover PV") + } +} + func TestExternalOptimizerEndToEnd(t *testing.T) { python := os.Getenv("FTW_TEST_OPTIMIZER_PYTHON") if python == "" { diff --git a/go/internal/mpc/loadpoint_directive.go b/go/internal/mpc/loadpoint_directive.go index ed28f1bb..a55d048f 100644 --- a/go/internal/mpc/loadpoint_directive.go +++ b/go/internal/mpc/loadpoint_directive.go @@ -19,8 +19,8 @@ func (d SlotDirective) LoadpointDirective() loadpoint.Directive { // PeakPlannedSurplusForEV is the near-term 3Φ-gate scan: peak leftover // PV after house load, minus planned PV-soak battery charge, over -// slots that overlap [now, now+window]. Grid-funded battery charge -// does not consume leftover the car can take. +// slots that overlap [now, now+window]. Soak uses GridW minus EV so +// soak+EV import is not treated as the battery buying. func PeakPlannedSurplusForEV(actions []Action, now time.Time, window time.Duration) (float64, bool) { if len(actions) == 0 { return 0, false @@ -36,7 +36,7 @@ func PeakPlannedSurplusForEV(actions []Action, now time.Time, window time.Durati if time.UnixMilli(a.SlotStartMs).After(horizon) { break } - surplus := loadpoint.PlannedSurplusForEVW(a.LoadW, a.PVW, a.BatteryW, a.GridW) + surplus := loadpoint.PlannedSurplusForEVW(a.LoadW, a.PVW, a.BatteryW, a.GridW-a.LoadpointW) if !any || surplus > peak { peak = surplus any = true diff --git a/go/internal/mpc/loadpoint_directive_test.go b/go/internal/mpc/loadpoint_directive_test.go index 41130196..aa1677c5 100644 --- a/go/internal/mpc/loadpoint_directive_test.go +++ b/go/internal/mpc/loadpoint_directive_test.go @@ -48,3 +48,19 @@ func TestPeakPlannedSurplusForEVSkipsGridFundedCharge(t *testing.T) { t.Errorf("grid-funded slot must still offer leftover PV %.0f, got %.0f", 7500.0, peak) } } + +func TestPeakPlannedSurplusForEVHidesSoakWhenEVMakesMeterImport(t *testing.T) { + start := time.Date(2026, 8, 18, 12, 0, 0, 0, time.UTC) + actions := []Action{{ + SlotStartMs: start.UnixMilli(), SlotLenMin: 15, + LoadW: 500, PVW: -8000, BatteryW: 4000, LoadpointW: 6900, + GridW: loadpoint.GridW(500, -8000, 4000, 6900), + }} + peak, ok := PeakPlannedSurplusForEV(actions, start.Add(time.Second), 30*time.Minute) + if !ok { + t.Fatal("expected a peak") + } + if peak != 3500 { + t.Errorf("soak+EV peak = %.0f, want 3500 (leftover minus soak, not full leftover)", peak) + } +} diff --git a/go/internal/mpc/loadpoint_spec.go b/go/internal/mpc/loadpoint_spec.go index cf3b89e2..0526fd2a 100644 --- a/go/internal/mpc/loadpoint_spec.go +++ b/go/internal/mpc/loadpoint_spec.go @@ -39,11 +39,11 @@ type LoadpointSpec struct { // Plan-start conditions. InitialSoC float64 // EV SoC at the first slot - PluggedIn bool // when false, Optimize treats the loadpoint as absent + PluggedIn bool // when false, Optimize treats the loadpoint as absent // User intent. A zero target means no deadline — charge // opportunistically based on price/PV surplus only. - TargetSoC float64 + TargetSoC float64 TargetSlotIdx int // zero-based slot by whose end the target must be met; ignored when target is zero // Electrical constraints. AllowedStepsW MUST include 0 (off) and @@ -67,9 +67,10 @@ type LoadpointSpec struct { // NoBatteryToEV mirrors ctrl.State.BatteryCoversEV inverted: when // true (operator's default), the home battery's discharge MUST NOT // end up at the EV. Enforced by loadpoint.BatteryDischargeFeedsEV - // in the DP and in ValidatePlan; the runtime clamp in + // in the DP and in ValidatePlan. The runtime clamp in // control/dispatch.go (search CANONICAL "battery may not feed EV") - // is the same conservation check. + // is the same conservation rule, not this helper — dispatch.go is + // owned by a separate PV-export PR. NoBatteryToEV bool } diff --git a/optimizer/ftw_optimizer/model.py b/optimizer/ftw_optimizer/model.py index 83197f5d..f566ce8b 100644 --- a/optimizer/ftw_optimizer/model.py +++ b/optimizer/ftw_optimizer/model.py @@ -922,6 +922,9 @@ def solve( # import whenever the EV is active forced the solver to idle the # car on every cheap slot the battery wanted to buy. house_surplus = np.maximum(0.0, -base_pv - base_load) + # Base forecast leftover. Robust low-PV scenarios are not a + # tighter leftover here; Core ValidatePlan rejects a plan + # that exceeds the slot's actual leftover. constraints.append(flex.power <= house_surplus + 50.0) if bool(flex.spec.get("no_storage_to_load", False)) and storages: house_residual = np.maximum(0.0, base_load + base_pv) diff --git a/optimizer/tests/test_model.py b/optimizer/tests/test_model.py index 80e4aed7..de00889d 100644 --- a/optimizer/tests/test_model.py +++ b/optimizer/tests/test_model.py @@ -2125,6 +2125,7 @@ def test_surplus_only_ev_takes_pv_while_battery_grid_charges() -> None: def test_surplus_only_ev_still_cannot_import() -> None: request = base_request() request["slots"] = [request["slots"][1]] # expensive slot only + request["slots"][0]["pv_w"] = -3000 # leftover 500 W, below the 2 kW step request["storages"][0]["initial_energy_wh"] = 2000 request["flex_loads"] = [ { @@ -2142,8 +2143,9 @@ def test_surplus_only_ev_still_cannot_import() -> None: response = handle(request) assert response["ok"], response action = response["plan"]["actions"][0] - if action["flex_power_w"]["surplus-car"] > 1e-5: - assert action["grid_w"] <= 50 + 1e-5 + leftover = max(0.0, 3000 - 2500) + assert action["flex_power_w"]["surplus-car"] <= leftover + 50 + 1e-5 + assert action["flex_power_w"]["surplus-car"] <= 1e-5 def test_ev_charge_never_coincides_with_battery_export() -> None: From cd07ddcfb0dc4abe831361f404570171de51e451 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Sun, 6 Sep 2026 08:00:59 +0200 Subject: [PATCH 05/57] feat(ev): the Manual tab follows the charger after Charge now MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Field report 2026-09-05 22:00 (#1002): Charge now on an Easee over the cloud driver, and the Manual tab said "Charging at 16 A until the car is full" a tenth of a second later. That line was written at click time and never refreshed; the Easee cloud takes 5–15 s to act, the status table showed 0 W, and the operator removed the charger to charge by hand. Everything needed to say what the charger did with the order was already on the wire in /api/ev/status; nothing read it on this path, and the box kept no record of when the hold was installed. The box now keeps that record. ManualHold carries StartedAt, the manager records when its current order was first given (commanded_since_ms), and GET /api/loadpoints carries `manual`: state (sent, accepted, charging, not_drawing, stalled, limited), started_at_ms, since_ms, requested and commanded watts and amps, and the charger's reported limit and reason. ManualStatusFrom is pure and tested tick by tick. The Manual tab is redrawn on every poll from that account, the plan strip says the same sentence while a manual charge runs, and a refused Start reads as a failure with the server's reason instead of the success sentence. Co-Authored-By: Claude Fable 5.1 --- .changeset/manual-charge-feedback.md | 7 + go/internal/api/api_loadpoint_manual.go | 52 +++++- go/internal/api/api_loadpoint_manual_test.go | 95 ++++++++++ go/internal/loadpoint/controller.go | 8 + go/internal/loadpoint/loadpoint.go | 22 ++- go/internal/loadpoint/manual_status.go | 172 +++++++++++++++++++ go/internal/loadpoint/manual_status_test.go | 141 +++++++++++++++ web/app.js | 136 +++++++++++++-- web/ev-manual-feedback.test.mjs | 54 ++++++ 9 files changed, 668 insertions(+), 19 deletions(-) create mode 100644 .changeset/manual-charge-feedback.md create mode 100644 go/internal/loadpoint/manual_status.go create mode 100644 go/internal/loadpoint/manual_status_test.go create mode 100644 web/ev-manual-feedback.test.mjs diff --git a/.changeset/manual-charge-feedback.md b/.changeset/manual-charge-feedback.md new file mode 100644 index 00000000..50b7d44c --- /dev/null +++ b/.changeset/manual-charge-feedback.md @@ -0,0 +1,7 @@ +--- +"ftw": patch +--- + +EV modal, Manual tab: after Charge now the line under the button follows the charger instead of repeating the request. It says that the amps were sent and the box is waiting for the charger to confirm, that the charger has taken the limit and the car has not started drawing, that the car is charging, that the charger offers the current but the car is not drawing it (with the charger's own reason, such as "EV not accepting current"), that the command stalled, or that the main fuse limits the charge right now — each with the time elapsed. The plan strip above the tabs says the same while a manual charge runs, so the charger's reason is no longer hidden behind the manual sentence. A refused Start (403, 404, 409) now reads as a failure with the server's reason instead of "Charging at 16 A". + +`GET /api/loadpoints` carries this as `manual` per loadpoint: `state` (`sent`, `accepted`, `charging`, `not_drawing`, `stalled`, `limited`), `started_at_ms`, `since_ms`, requested and commanded watts and amps, the charger's reported limit and reason. `POST …/manual_hold` answers with `started_at_ms`, and an Update of the amps keeps the first press as the start. `commanded_since_ms` says when the box's current order was first given. diff --git a/go/internal/api/api_loadpoint_manual.go b/go/internal/api/api_loadpoint_manual.go index a063300f..b7e92b27 100644 --- a/go/internal/api/api_loadpoint_manual.go +++ b/go/internal/api/api_loadpoint_manual.go @@ -1,12 +1,14 @@ package api import ( + "encoding/json" "fmt" "math" "net/http" "time" "github.com/srcfl/ftw/go/internal/loadpoint" + "github.com/srcfl/ftw/go/internal/telemetry" ) // Manual-hold diagnostics endpoint. Lets an operator pin a loadpoint @@ -57,6 +59,10 @@ type manualHoldResponse struct { SitePhases int `json:"site_phases,omitempty"` ExpiresAtMs int64 `json:"expires_at_ms,omitempty"` ReleaseAtSoCPct float64 `json:"release_at_soc_pct,omitempty"` + // StartedAtMs is when the operator installed the hold; an Update of + // the amps keeps it. The live account of what the charger did with the + // hold is `manual` on GET /api/loadpoints. + StartedAtMs int64 `json:"started_at_ms,omitempty"` } // maxManualHoldS bounds the hold duration so a forgotten hold can't @@ -157,6 +163,13 @@ func (s *Server) handleLoadpointManualHold(w http.ResponseWriter, r *http.Reques Persistent: persistent, ReleaseAtSoC: req.ReleaseAtSoCPct / 100, } + // An Update of the amps keeps the hold's start, so the manual tab keeps + // counting from the first press; a fresh hold starts now. + now := time.Now() + hold.StartedAt = now + if prev, ok := s.deps.LoadpointCtrl.GetManualHold(id, now); ok && !prev.StartedAt.IsZero() { + hold.StartedAt = prev.StartedAt + } s.deps.LoadpointCtrl.SetManualHold(id, hold) writeJSON(w, 200, manualHoldResponseFrom(hold, true)) } @@ -240,6 +253,7 @@ func (s *Server) decorateLoadpointsWithManual(states []loadpoint.State) { } now := time.Now() + chargers := s.chargerReadings() for i := range states { phases := fusePhases switch phaseModeByID[states[i].ID] { @@ -251,13 +265,46 @@ func (s *Server) decorateLoadpointsWithManual(states []loadpoint.State) { states[i].Phases = phases states[i].VoltageV = voltage if s.deps.LoadpointCtrl != nil { - if h, ok := s.deps.LoadpointCtrl.GetManualHold(states[i].ID, now); ok { + h, ok := s.deps.LoadpointCtrl.GetManualHold(states[i].ID, now) + if ok { states[i].ManualActive = true states[i].ManualChargeW = h.PowerW states[i].ManualReleaseSoC = h.ReleaseAtSoC } + states[i].Manual = loadpoint.ManualStatusFrom(h, ok, states[i], chargers[states[i].DriverName], now) + } + } +} + +// chargerReadings collects, per EV driver, what the charger last reported +// about the current it allows and why it delivers none. The field names +// follow the EV driver contract (easee_cloud.lua and its siblings): max_a, +// charging, reason_no_current_label, command_stalled. A driver that emits +// none of them still yields a reading, so the manual status knows the +// charger is there but cannot confirm a limit. +func (s *Server) chargerReadings() map[string]loadpoint.ChargerReading { + out := map[string]loadpoint.ChargerReading{} + if s.deps.Tel == nil { + return out + } + for _, rd := range s.deps.Tel.ReadingsByType(telemetry.DerEV) { + var d struct { + MaxA *float64 `json:"max_a"` + Charging bool `json:"charging"` + Reason string `json:"reason_no_current_label"` + CommandStalled bool `json:"command_stalled"` + } + if len(rd.Data) > 0 { + _ = json.Unmarshal(rd.Data, &d) } + r := loadpoint.ChargerReading{Known: true, Charging: d.Charging, Reason: d.Reason, Stalled: d.CommandStalled} + if d.MaxA != nil { + r.LimitA = *d.MaxA + r.LimitKnown = true + } + out[rd.Driver] = r } + return out } func manualHoldResponseFrom(h loadpoint.ManualHold, active bool) manualHoldResponse { @@ -276,5 +323,8 @@ func manualHoldResponseFrom(h loadpoint.ManualHold, active bool) manualHoldRespo resp.ExpiresAtMs = h.ExpiresAt.UnixMilli() } resp.ReleaseAtSoCPct = h.ReleaseAtSoC * 100 + if !h.StartedAt.IsZero() { + resp.StartedAtMs = h.StartedAt.UnixMilli() + } return resp } diff --git a/go/internal/api/api_loadpoint_manual_test.go b/go/internal/api/api_loadpoint_manual_test.go index 409fefc1..3d267c1a 100644 --- a/go/internal/api/api_loadpoint_manual_test.go +++ b/go/internal/api/api_loadpoint_manual_test.go @@ -9,6 +9,7 @@ import ( "time" "github.com/srcfl/ftw/go/internal/loadpoint" + "github.com/srcfl/ftw/go/internal/telemetry" ) // Manual-hold endpoint tests. Validation, route wiring, and the full @@ -230,3 +231,97 @@ func TestManualHoldRefusesReleaseTargetAlreadyMet(t *testing.T) { t.Errorf("no-target hold should be installed without a SoC release, got active=%v %+v", active, h) } } + +// After Charge now the loadpoint carries a live account of the hold: what +// was ordered, since when, and what the charger did with it (#1002). The +// manual tab renders this instead of a sentence written at click time. +func TestLoadpointsCarryManualStatus(t *testing.T) { + mgr := loadpoint.NewManager() + mgr.Load([]loadpoint.Config{{ID: "garage", DriverName: "easee", MinChargeW: 1380, MaxChargeW: 11000}}) + ctrl := loadpoint.NewController(mgr, nil, nil, nil) + tel := telemetry.NewStore() + srv := New(&Deps{Loadpoints: mgr, LoadpointCtrl: ctrl, Tel: tel}) + + post := func(body string) { + t.Helper() + req := httptest.NewRequest(http.MethodPost, "/api/loadpoints/garage/manual_hold", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + srv.Handler().ServeHTTP(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("POST status = %d: %s", rr.Code, rr.Body.String()) + } + var resp manualHoldResponse + if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil { + t.Fatal(err) + } + if resp.StartedAtMs == 0 { + t.Error("POST response must carry started_at_ms") + } + } + manual := func() loadpoint.ManualStatus { + t.Helper() + rr := httptest.NewRecorder() + srv.Handler().ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/api/loadpoints", nil)) + if rr.Code != http.StatusOK { + t.Fatalf("GET /api/loadpoints = %d: %s", rr.Code, rr.Body.String()) + } + var got struct { + Loadpoints []loadpoint.State `json:"loadpoints"` + } + if err := json.Unmarshal(rr.Body.Bytes(), &got); err != nil { + t.Fatal(err) + } + if len(got.Loadpoints) != 1 { + t.Fatalf("loadpoints = %d, want 1", len(got.Loadpoints)) + } + return got.Loadpoints[0].Manual + } + + if m := manual(); m.Active { + t.Fatalf("no hold yet, got %+v", m) + } + + // 6 A on three phases at 230 V; the charger has not answered yet. + post(`{"power_w":4140,"hold_s":0}`) + m := manual() + if !m.Active || m.State != loadpoint.ManualSent || m.RequestedA != 6 || m.StartedAtMs == 0 { + t.Fatalf("after Charge now: %+v", m) + } + first := m.StartedAtMs + + // The Easee echoes the limit: accepted, waiting for the car. + tel.Update("easee", telemetry.DerEV, 0, nil, json.RawMessage(`{"max_a":6,"charging":false,"reason_no_current_label":"car not drawing current"}`)) + m = manual() + if m.State != loadpoint.ManualAccepted || !m.ChargerLimitKnown || m.ChargerLimitA != 6 || m.ChargerReason != "car not drawing current" { + t.Fatalf("after the charger took the limit: %+v", m) + } + + // An Update of the amps keeps the first press as the start. + post(`{"power_w":11040,"hold_s":0}`) + if m = manual(); m.StartedAtMs != first || m.RequestedA != 16 { + t.Fatalf("after Update: %+v (first start %d)", m, first) + } + + // The charger says the command stalled. + tel.Update("easee", telemetry.DerEV, 0, nil, json.RawMessage(`{"max_a":16,"charging":false,"reason_no_current_label":"EV not accepting current","command_stalled":true}`)) + if m = manual(); m.State != loadpoint.ManualStalled || m.ChargerReason != "EV not accepting current" { + t.Fatalf("after a stall: %+v", m) + } + + // Power flows. + tel.Update("easee", telemetry.DerEV, 10800, nil, json.RawMessage(`{"max_a":16,"charging":true}`)) + if m = manual(); m.State != loadpoint.ManualCharging { + t.Fatalf("while charging: %+v", m) + } + + // Stop clears the account. + rr := httptest.NewRecorder() + srv.Handler().ServeHTTP(rr, httptest.NewRequest(http.MethodDelete, "/api/loadpoints/garage/manual_hold", nil)) + if rr.Code != http.StatusOK { + t.Fatalf("DELETE = %d", rr.Code) + } + if m = manual(); m.Active || m.State != "" { + t.Fatalf("after Stop: %+v", m) + } +} diff --git a/go/internal/loadpoint/controller.go b/go/internal/loadpoint/controller.go index f123912c..945cb5b7 100644 --- a/go/internal/loadpoint/controller.go +++ b/go/internal/loadpoint/controller.go @@ -408,6 +408,11 @@ type ManualHold struct { // Persisted with the hold, so a restart mid-boost keeps the // release target. ReleaseAtSoC float64 + + // StartedAt is when the operator installed the hold. The API keeps it + // across an Update of the amps, so the manual tab can say how long the + // charge has been asked for. SetManualHold fills a zero value. + StartedAt time.Time } // Directive is the loadpoint-relevant slice of mpc.SlotDirective. @@ -1314,6 +1319,9 @@ func (c *Controller) SetManualHold(id string, h ManualHold) { delete(c.holds, id) cleared = true } else { + if h.StartedAt.IsZero() { + h.StartedAt = time.Now() + } c.holds[id] = h } saver := c.manualHoldSaver diff --git a/go/internal/loadpoint/loadpoint.go b/go/internal/loadpoint/loadpoint.go index c89212df..fb43740f 100644 --- a/go/internal/loadpoint/loadpoint.go +++ b/go/internal/loadpoint/loadpoint.go @@ -168,6 +168,10 @@ type State struct { ManualActive bool `json:"manual_active"` ManualChargeW float64 `json:"manual_charge_w,omitempty"` ManualReleaseSoC float64 `json:"manual_release_soc,omitempty"` + // Manual is the live account of the hold: what was ordered, since when, + // and what the charger did with it. Populated by the API layer from the + // controller and the charger's reading; see ManualStatusFrom. + Manual ManualStatus `json:"manual"` // BatteryBoost is the explicit, bounded home-battery-to-EV permission // for this loadpoint. Populated by the API layer from Controller state. @@ -192,6 +196,9 @@ type State struct { // "the box is pausing on purpose". CommandedW float64 `json:"commanded_w"` CommandedKnown bool `json:"commanded_known"` + // CommandedSinceMs is when the current order was first given; it moves + // when CommandedW or CommandedReason changes. Zero until the first tick. + CommandedSinceMs int64 `json:"commanded_since_ms,omitempty"` // CommandedReason names the dispatch branch that decided CommandedW: // "plan", "no_plan_budget", "pv_surplus", "pv_surplus_pause", @@ -389,6 +396,9 @@ type loadpointRuntime struct { commandedW float64 commandedKnown bool commandedReason string + // commandedSince is when the current (commandedW, commandedReason) pair + // was first ordered. The manual status counts elapsed time from it. + commandedSince time.Time // The interruption hysteresis state. chargingSteadySince anchors the // current continuous above-floor run; steadyRunArmed latches once that @@ -433,11 +443,16 @@ func (m *Manager) SetCommanded(id string, w float64, reason string) { m.mu.Lock() defer m.mu.Unlock() if lp, ok := m.byID[id]; ok { + changed := !lp.commandedKnown || lp.commandedW != w || + (reason != "" && reason != lp.commandedReason) lp.commandedW = w lp.commandedKnown = true if reason != "" { lp.commandedReason = reason } + if changed { + lp.commandedSince = time.Now() + } } } @@ -476,6 +491,7 @@ func (m *Manager) Load(cfgs []Config) { lp.commandedW = existing.commandedW lp.commandedReason = existing.commandedReason lp.commandedKnown = existing.commandedKnown + lp.commandedSince = existing.commandedSince lp.chargingSteadySince = existing.chargingSteadySince lp.stoppedSince = existing.stoppedSince lp.steadyRunArmed = existing.steadyRunArmed @@ -984,7 +1000,7 @@ func (lp *loadpointRuntime) snapshot() State { steps := make([]float64, len(lp.AllowedStepsW)) copy(steps, lp.AllowedStepsW) sort.Float64s(steps) - return State{ + st := State{ ID: lp.ID, DriverName: lp.DriverName, PluggedIn: lp.pluggedIn, @@ -1005,6 +1021,10 @@ func (lp *loadpointRuntime) snapshot() State { CommandedReason: lp.commandedReason, CommandedKnown: lp.commandedKnown, } + if !lp.commandedSince.IsZero() { + st.CommandedSinceMs = lp.commandedSince.UnixMilli() + } + return st } // SetScheduleSaver wires the persistence callback. Pass nil to disable. diff --git a/go/internal/loadpoint/manual_status.go b/go/internal/loadpoint/manual_status.go new file mode 100644 index 00000000..5b7c24a5 --- /dev/null +++ b/go/internal/loadpoint/manual_status.go @@ -0,0 +1,172 @@ +package loadpoint + +import ( + "math" + "time" +) + +// ManualStatus is the live account of an operator hold ("Charge now"): +// what was asked, what the box ordered after its clamps, since when, and +// what the charger did with it. The manual tab reads it every poll, so the +// operator never has to guess from a 0 W readout whether the button worked. +// Field report 2026-09-05 (#1002): Charge now at 22:00, the tab said +// "Charging at 16 A" within a tenth of a second, the Easee cloud takes +// 5–15 s to act, nothing on screen moved, and the operator removed the +// charger to charge by hand. +type ManualStatus struct { + Active bool `json:"active"` + // State is one of ManualSent, ManualAccepted, ManualCharging, + // ManualNotDrawing, ManualStalled or ManualLimited. Empty when inactive. + State string `json:"state,omitempty"` + // StartedAtMs is when the operator installed the hold. + StartedAtMs int64 `json:"started_at_ms,omitempty"` + // SinceMs is when the current order took effect: the hold's start, or + // the last change of the ordered watts (an Update on the amp slider, a + // fuse clamp coming or going). Elapsed time in the UI counts from here. + SinceMs int64 `json:"since_ms,omitempty"` + // RequestedW is what the operator asked for; CommandedW is what the box + // ordered after every clamp. They differ while the main fuse limits. + RequestedW float64 `json:"requested_w,omitempty"` + CommandedW float64 `json:"commanded_w,omitempty"` + RequestedA float64 `json:"requested_a,omitempty"` + CommandedA float64 `json:"commanded_a,omitempty"` + // ChargerLimitA is the current limit the charger itself reports, when + // its driver exposes one (Easee: max_a). ChargerLimitKnown separates a + // reading of zero from no reading at all. + ChargerLimitA float64 `json:"charger_limit_a,omitempty"` + ChargerLimitKnown bool `json:"charger_limit_known,omitempty"` + // ChargerReason is the charger's own explanation for delivering no + // current, in its words (reason_no_current_label). Empty when it has none. + ChargerReason string `json:"charger_reason,omitempty"` + // LimitReason names the clamp behind ManualLimited: "fuse_limit", + // "fuse_cooldown" or "site_meter_stale". + LimitReason string `json:"limit_reason,omitempty"` +} + +const ( + // ManualSent: the hold is installed; the charger has not yet reflected + // the ordered limit. + ManualSent = "sent" + // ManualAccepted: the charger reports the ordered limit; the car has not + // started drawing yet. + ManualAccepted = "accepted" + // ManualCharging: power is flowing. + ManualCharging = "charging" + // ManualNotDrawing: the charger reports the ordered limit, and the car + // still draws nothing after the grace period. + ManualNotDrawing = "not_drawing" + // ManualStalled: the charger says the command stalled, or never + // confirmed it within the timeout. + ManualStalled = "stalled" + // ManualLimited: a clamp the hold cannot override (main fuse, stale site + // meter) holds the order below what was asked. + ManualLimited = "limited" +) + +// ChargerReading is what the charger's driver last reported, as far as the +// manual status needs it. Known is false when there is no reading. +type ChargerReading struct { + Known bool + LimitA float64 + LimitKnown bool + Charging bool + Reason string + Stalled bool +} + +const ( + // manualAcceptGrace is how long a charger that has taken the limit may + // sit at 0 W before the box calls it "not drawing". An Easee contactor + // plus the car's ramp takes 5–15 s; a phase flip up to 90 s. + manualAcceptGrace = 2 * time.Minute + // manualConfirmTimeout is how long the box waits for the charger to + // reflect the ordered limit at all before calling the command stalled. + manualConfirmTimeout = 3 * time.Minute + // manualChargingFloorW is the draw above which the hold counts as + // charging. Same floor the plan strip uses. + manualChargingFloorW = 100.0 +) + +// holdClampReason reports whether a commanded reason is one of the clamps +// that override an operator hold. Any other reason belongs to a tick from +// before the hold was installed and says nothing about it. +func holdClampReason(reason string) bool { + switch reason { + case "fuse_limit", "fuse_cooldown", "site_meter_stale": + return true + } + return false +} + +// ManualStatusFrom derives the operator-facing status of a hold from the +// controller's hold, the loadpoint snapshot (with Phases and VoltageV set) +// and the charger's reading. Pure, so a tick-by-tick test can drive it; the +// API layer calls it on every poll. +func ManualStatusFrom(h ManualHold, held bool, st State, ch ChargerReading, now time.Time) ManualStatus { + if !held { + return ManualStatus{} + } + perA := float64(st.Phases) * st.VoltageV + toA := func(w float64) float64 { + if perA <= 0 || w <= 0 { + return 0 + } + return math.Round(w/perA*10) / 10 + } + m := ManualStatus{ + Active: true, + RequestedW: h.PowerW, + RequestedA: toA(h.PowerW), + ChargerLimitA: ch.LimitA, + ChargerLimitKnown: ch.Known && ch.LimitKnown, + ChargerReason: ch.Reason, + } + // The ordered value is the box's last command only once a tick has run + // the hold branch; before that the snapshot still carries the previous + // automatic order, which says nothing about this hold. + clamp := st.CommandedKnown && holdClampReason(st.CommandedReason) + ordered := h.PowerW + if st.CommandedKnown && (st.CommandedReason == "manual_hold" || clamp) { + ordered = st.CommandedW + } + m.CommandedW = ordered + m.CommandedA = toA(ordered) + + since := h.StartedAt + if !h.StartedAt.IsZero() { + m.StartedAtMs = h.StartedAt.UnixMilli() + } + if st.CommandedSinceMs > 0 && (st.CommandedReason == "manual_hold" || clamp) { + if t := time.UnixMilli(st.CommandedSinceMs); t.After(since) { + since = t + } + } + var elapsed time.Duration + if !since.IsZero() { + m.SinceMs = since.UnixMilli() + elapsed = now.Sub(since) + } + + limitMatches := m.ChargerLimitKnown && m.CommandedA > 0 && math.Abs(ch.LimitA-m.CommandedA) < 1 + switch { + case st.CurrentPowerW >= manualChargingFloorW || (ch.Known && ch.Charging): + m.State = ManualCharging + if clamp { + m.LimitReason = st.CommandedReason + } + case ch.Known && ch.Stalled: + m.State = ManualStalled + case clamp: + m.State = ManualLimited + m.LimitReason = st.CommandedReason + case limitMatches && elapsed >= manualAcceptGrace: + m.State = ManualNotDrawing + case limitMatches: + m.State = ManualAccepted + case elapsed >= manualConfirmTimeout: + m.State = ManualStalled + default: + m.State = ManualSent + } + return m +} diff --git a/go/internal/loadpoint/manual_status_test.go b/go/internal/loadpoint/manual_status_test.go new file mode 100644 index 00000000..963cd8c5 --- /dev/null +++ b/go/internal/loadpoint/manual_status_test.go @@ -0,0 +1,141 @@ +package loadpoint + +import ( + "testing" + "time" +) + +// The manual status follows one Charge now press on an Easee tick by tick: +// sent, taken by the charger, charging — or not drawing, stalled, limited. +// Every branch the manual tab renders is pinned here. +func TestManualStatusFrom(t *testing.T) { + t0 := time.Date(2026, 9, 5, 22, 0, 0, 0, time.UTC) + hold := ManualHold{PowerW: 11040, Persistent: true, StartedAt: t0} // 16 A × 3 × 230 V + base := State{Phases: 3, VoltageV: 230} + ordered := func(w float64, reason string, at time.Time) State { + st := base + st.CommandedW = w + st.CommandedKnown = true + st.CommandedReason = reason + st.CommandedSinceMs = at.UnixMilli() + return st + } + easee := func(limitA float64, charging bool, reason string, stalled bool) ChargerReading { + return ChargerReading{Known: true, LimitA: limitA, LimitKnown: true, Charging: charging, Reason: reason, Stalled: stalled} + } + + for _, tc := range []struct { + name string + st State + ch ChargerReading + now time.Time + wantState string + wantCmdA float64 + wantLimit string + }{ + { + name: "no reading yet, previous automatic order still in the snapshot", + st: ordered(0, "no_plan_budget", t0.Add(-time.Hour)), ch: ChargerReading{}, + now: t0.Add(2 * time.Second), wantState: ManualSent, wantCmdA: 16, + }, + { + name: "charger still shows the old limit", + st: ordered(11040, "manual_hold", t0.Add(2*time.Second)), ch: easee(6, false, "car not drawing current", false), + now: t0.Add(10 * time.Second), wantState: ManualSent, wantCmdA: 16, + }, + { + name: "charger took the limit, car has not started", + st: ordered(11040, "manual_hold", t0.Add(2*time.Second)), ch: easee(16, false, "car not drawing current", false), + now: t0.Add(20 * time.Second), wantState: ManualAccepted, wantCmdA: 16, + }, + { + name: "power flows", + st: func() State { + st := ordered(11040, "manual_hold", t0.Add(2*time.Second)) + st.CurrentPowerW = 10800 + return st + }(), ch: easee(16, true, "", false), + now: t0.Add(30 * time.Second), wantState: ManualCharging, wantCmdA: 16, + }, + { + name: "charger took the limit, car still not drawing after the grace period", + st: ordered(11040, "manual_hold", t0.Add(2*time.Second)), ch: easee(16, false, "EV not accepting current", false), + now: t0.Add(3 * time.Minute), wantState: ManualNotDrawing, wantCmdA: 16, + }, + { + name: "driver reports the command stalled", + st: ordered(11040, "manual_hold", t0.Add(2*time.Second)), ch: easee(16, false, "EV not accepting current", true), + now: t0.Add(45 * time.Second), wantState: ManualStalled, wantCmdA: 16, + }, + { + name: "charger never reflected the limit", + st: ordered(11040, "manual_hold", t0.Add(2*time.Second)), ch: easee(6, false, "", false), + now: t0.Add(4 * time.Minute), wantState: ManualStalled, wantCmdA: 16, + }, + { + name: "main fuse clamps the hold", + st: ordered(6900, "fuse_limit", t0.Add(40*time.Second)), ch: easee(10, false, "", false), + now: t0.Add(50 * time.Second), wantState: ManualLimited, wantCmdA: 10, wantLimit: "fuse_limit", + }, + { + name: "fuse cooldown pauses the hold", + st: ordered(0, "fuse_cooldown", t0.Add(40*time.Second)), ch: easee(0, false, "", false), + now: t0.Add(50 * time.Second), wantState: ManualLimited, wantCmdA: 0, wantLimit: "fuse_cooldown", + }, + { + name: "a charger without a limit reading is only ever sent or charging", + st: ordered(11040, "manual_hold", t0.Add(2*time.Second)), ch: ChargerReading{Known: true}, + now: t0.Add(time.Minute), wantState: ManualSent, wantCmdA: 16, + }, + } { + t.Run(tc.name, func(t *testing.T) { + got := ManualStatusFrom(hold, true, tc.st, tc.ch, tc.now) + if !got.Active { + t.Fatal("status must be active while a hold is held") + } + if got.State != tc.wantState { + t.Errorf("state = %q, want %q (%+v)", got.State, tc.wantState, got) + } + if got.CommandedA != tc.wantCmdA { + t.Errorf("commanded_a = %v, want %v", got.CommandedA, tc.wantCmdA) + } + if got.LimitReason != tc.wantLimit { + t.Errorf("limit_reason = %q, want %q", got.LimitReason, tc.wantLimit) + } + if got.RequestedA != 16 || got.RequestedW != 11040 { + t.Errorf("requested = %v A / %v W, want 16 A / 11040 W", got.RequestedA, got.RequestedW) + } + if got.StartedAtMs != t0.UnixMilli() { + t.Errorf("started_at_ms = %d, want %d", got.StartedAtMs, t0.UnixMilli()) + } + if tc.ch.Reason != "" && got.ChargerReason != tc.ch.Reason { + t.Errorf("charger_reason = %q, want %q", got.ChargerReason, tc.ch.Reason) + } + }) + } +} + +func TestManualStatusFrom_SinceFollowsTheLatestOrder(t *testing.T) { + t0 := time.Date(2026, 9, 5, 22, 0, 0, 0, time.UTC) + hold := ManualHold{PowerW: 11040, Persistent: true, StartedAt: t0} + st := State{Phases: 3, VoltageV: 230, CommandedW: 11040, CommandedKnown: true, CommandedReason: "manual_hold", + CommandedSinceMs: t0.Add(30 * time.Second).UnixMilli()} + got := ManualStatusFrom(hold, true, st, ChargerReading{}, t0.Add(time.Minute)) + if got.SinceMs != t0.Add(30*time.Second).UnixMilli() { + t.Errorf("since_ms = %d, want the order's time", got.SinceMs) + } + // An automatic order from before the hold does not move "since". + st.CommandedReason = "no_plan_budget" + st.CommandedSinceMs = t0.Add(-time.Hour).UnixMilli() + got = ManualStatusFrom(hold, true, st, ChargerReading{}, t0.Add(time.Minute)) + if got.SinceMs != t0.UnixMilli() { + t.Errorf("since_ms = %d, want the hold's start", got.SinceMs) + } +} + +func TestManualStatusFrom_InactiveIsEmpty(t *testing.T) { + got := ManualStatusFrom(ManualHold{}, false, State{}, ChargerReading{}, time.Now()) + if got != (ManualStatus{}) { + t.Errorf("inactive status must be the zero value, got %+v", got) + } +} diff --git a/web/app.js b/web/app.js index f02f9153..bdc21efc 100644 --- a/web/app.js +++ b/web/app.js @@ -2647,6 +2647,56 @@ return String(d.getHours()).padStart(2, "0") + ":" + String(d.getMinutes()).padStart(2, "0"); } + function evFmtElapsed(ms) { + var s = Math.max(0, Math.round(ms / 1000)); + if (s < 90) return s + " s"; + var m = Math.round(s / 60); + if (m < 90) return m + " min"; + return Math.round(m / 60) + " h"; + } + + // manualStatusText is the one sentence the Manual tab and the plan strip + // show while an operator hold runs. It follows the charger, not the + // request: sent, taken by the charger, charging, not drawing, stalled or + // limited by the fuse, each with the time elapsed. The box derives the + // state (loadpoint.manual); this only puts words on it. Returns null + // when no hold is active. + function manualStatusText(lp, d) { + var m = lp && lp.manual; + if (!lp || !lp.manual_active || !m || !m.active) return null; + if (lp.manual_release_soc > 0) { + return "Charging now → stops at " + Math.round(lp.manual_release_soc * 100) + " %, then back to the plan (fuse still limits)."; + } + var reqA = m.requested_a > 0 ? Math.round(m.requested_a) + " A" : formatW(m.requested_w || lp.manual_charge_w || 0); + var cmdA = m.commanded_a > 0 ? Math.round(m.commanded_a) + " A" : formatW(m.commanded_w || 0); + var since = m.since_ms > 0 ? evFmtElapsed(Date.now() - m.since_ms) : ""; + var sinceP = since ? " (" + since + ")" : ""; + var reason = m.charger_reason ? " Charger reports: " + m.charger_reason + "." : ""; + switch (m.state) { + case "charging": + return "Charging at " + formatW(lp.current_power_w || 0) + " — " + reqA + " requested. Runs until the car is full, Stop or unplug."; + case "sent": + return "Sent " + reqA + " to the charger. Waiting for it to confirm…" + sinceP; + case "accepted": + return "Charger set to " + cmdA + ". Waiting for the car to start drawing…" + sinceP + reason; + case "not_drawing": + return "Charger offers " + cmdA + " but the car is not drawing" + sinceP + "." + + (reason || " It may be full, or held by its own charge limit or schedule."); + case "stalled": + return "Nothing is charging" + (since ? " after " + since : "") + ": the charger has not acted on " + reqA + "." + + (reason || " Check the car's own charge limit or schedule, then the charger's app."); + case "limited": + if (m.limit_reason === "fuse_cooldown") { + return "Paused: main-fuse protection — " + reqA + " requested; charging resumes on its own." + sinceP; + } + if (m.limit_reason === "site_meter_stale") { + return "Paused for safety: site-meter data is stale — " + reqA + " requested; charging resumes when telemetry recovers."; + } + return "Main fuse limits this charge to " + cmdA + " right now (" + reqA + " requested)." + sinceP; + } + return "Manual charge at " + reqA + " — until the car is full, Stop or unplug."; + } + // renderEvPlanStatus answers the question the status table can't: // "why isn't it charging right now, and when will it?" Field // experience: a car plugged in against a schedule sits at 0 W until @@ -2664,11 +2714,16 @@ var charging = (lp.current_power_w || 0) >= 100; var hasSchedule = lp.schedule && lp.schedule.soc > 0; if (lp.manual_active) { + // The same sentence as the Manual tab, so the charger's own reason is + // never hidden behind "manual charge is running". text = lp.manual_release_soc > 0 ? "Charging now at " + formatW(lp.manual_charge_w || 0) + " → returns to plan at " + Math.round(lp.manual_release_soc * 100) + " %." - : "Manual charge at " + formatW(lp.manual_charge_w || 0) + - " — plan and PV logic are off until the car is full, Stop or unplug."; + : (manualStatusText(lp, d) || ("Manual charge at " + formatW(lp.manual_charge_w || 0) + + " — plan and PV logic are off until the car is full, Stop or unplug.")); + if (lp.manual && (lp.manual.state === "not_drawing" || lp.manual.state === "stalled")) { + tone = "var(--text)"; + } } else if (charging) { text = winActive ? "Charging on plan until " + evFmtClock(lp.plan_next_end_ms) + "." + kwPlanned @@ -2901,6 +2956,9 @@ evTabsEl = null; evTabsLpId = null; } + if (matched && evTabsEl && typeof evTabsEl.update === "function") { + evTabsEl.update(matched, d); + } // Boost from the home battery: one control, below the tabs, for // whichever mode the loadpoint is in. Mounted once per loadpoint // (the reserve slider keeps its value); update() redraws state @@ -3244,11 +3302,8 @@ status.style.color = "var(--text-dim)"; status.style.marginTop = "0.35rem"; status.style.minHeight = "1em"; - status.textContent = active - ? (lp && lp.manual_release_soc > 0 - ? "Charging now → stops at " + Math.round(lp.manual_release_soc * 100) + " %, then back to the plan (fuse still limits)." - : "Charging at the slider's amps until the car is full, Stop or unplug (fuse still limits).") - : "Charges at the slider's amps until the car is full, Stop or unplug. The plan takes over again after that."; + var idleText = "Charges at the slider's amps until the car is full, Stop or unplug. The plan takes over again after that."; + status.textContent = manualStatusText(lp, null) || idleText; box.appendChild(status); // Start / Stop buttons. @@ -3286,10 +3341,44 @@ btnRow.appendChild(stopBtn); box.appendChild(btnRow); + // Live state. The section is mounted once per loadpoint; update() runs + // on every poll and rewrites the status line from lp.manual, so what the + // operator reads follows the charger instead of the click. A message + // written by a click holds the line until the box shows the new state. + var lastLp = lp; + var lastD = null; + var busy = false; + var holdLineUntil = 0; + function renderStatus() { + var on = !!(lastLp && lastLp.manual_active); + if (!busy) { + stopBtn.disabled = !on; + stopBtn.style.opacity = on ? "1" : "0.5"; + startBtn.textContent = on ? "Update" : "Charge now"; + } + if (busy || Date.now() < holdLineUntil) return; + status.textContent = manualStatusText(lastLp, lastD) || idleText; + } + function update(nextLp, d) { + if (nextLp) lastLp = nextLp; + if (d) lastD = d; + renderStatus(); + } + + // failOn turns an HTTP error into a rejection carrying the server's + // reason, so a refused Start (403, 404, 409) never reads as success. + function failOn(r) { + if (r.ok) return r; + return r.json().catch(function () { return {}; }).then(function (j) { + throw new Error((j && j.error) || ("HTTP " + r.status)); + }); + } + startBtn.addEventListener("click", function () { + busy = true; startBtn.disabled = true; - status.textContent = "Starting…"; var a = parseInt(slider.value, 10) || minA; + status.textContent = "Sending " + a + " A to the charger…"; // CONTROL write — strict (FIX-B): persistent manual hold (hold_s:0) // with no SoC release — see the note above the slider. apiFetch("/api/loadpoints/" + encodeURIComponent(lp.id) + "/manual_hold", { @@ -3300,30 +3389,39 @@ hold_s: 0, phase_mode: phases === 1 ? "1p" : "3p", }), - }).then(function () { - status.textContent = "Charging at " + a + " A until the car is full, Stop or unplug."; + }).then(failOn).then(function () { + busy = false; + status.textContent = "Sent " + a + " A to the charger. Waiting for it to confirm…"; + holdLineUntil = Date.now() + 6000; manualNeedsRebuild = true; // reflect active state on next poll - }).catch(function () { + }).catch(function (e) { + busy = false; startBtn.disabled = false; - status.textContent = "Start failed — try again."; + status.textContent = "Start failed: " + ((e && e.message) || "try again") + "."; + holdLineUntil = Date.now() + 15000; }); }); stopBtn.addEventListener("click", function () { + busy = true; stopBtn.disabled = true; status.textContent = "Stopping…"; apiFetch("/api/loadpoints/" + encodeURIComponent(lp.id) + "/manual_hold", { method: "DELETE", - }).then(function () { + }).then(failOn).then(function () { + busy = false; status.textContent = "Released — back to automatic charging."; + holdLineUntil = Date.now() + 6000; manualNeedsRebuild = true; - }).catch(function () { + }).catch(function (e) { + busy = false; stopBtn.disabled = false; - status.textContent = "Stop failed — try again."; + status.textContent = "Stop failed: " + ((e && e.message) || "try again") + "."; + holdLineUntil = Date.now() + 15000; }); }); - return box; + return { el: box, update: update }; } // sliderHeader builds a "LABEL ............ value" row (mono uppercase @@ -4037,7 +4135,8 @@ pvPanel.appendChild(buildPVModeSection(lp)); var manualPanel = document.createElement("div"); - manualPanel.appendChild(buildManualChargeSection(lp)); + var manual = buildManualChargeSection(lp); + manualPanel.appendChild(manual.el); var schedPanel = document.createElement("div"); schedPanel.appendChild(buildScheduleSection(lp, hasPV)); @@ -4087,6 +4186,9 @@ container.appendChild(manualPanel); container.appendChild(schedPanel); + // Polls redraw the live parts of the panels without remounting them. + container.update = function (nextLp, d) { manual.update(nextLp, d); }; + selectTab(evActiveTab); return container; } diff --git a/web/ev-manual-feedback.test.mjs b/web/ev-manual-feedback.test.mjs new file mode 100644 index 00000000..d0bb6e80 --- /dev/null +++ b/web/ev-manual-feedback.test.mjs @@ -0,0 +1,54 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import test from 'node:test'; + +const source = readFileSync(new URL('./app.js', import.meta.url), 'utf8'); +const manual = source.slice( + source.indexOf('function buildManualChargeSection'), + source.indexOf('function sliderHeader'), +); +const statusText = source.slice( + source.indexOf('function manualStatusText'), + source.indexOf('function renderEvPlanStatus'), +); + +// After Charge now the operator must always know what is happening (#1002). +// Field report 2026-09-05: the tab said "Charging at 16 A" a tenth of a +// second after the click, the Easee cloud takes 5–15 s to act, nothing on +// screen moved, and the operator removed the charger to charge by hand. + +test('the status line follows the charger through every state', () => { + for (const state of ['sent', 'accepted', 'charging', 'not_drawing', 'stalled', 'limited']) { + assert.match(statusText, new RegExp(`case "${state}":`)); + } + assert.match(statusText, /Waiting for it to confirm/); + assert.match(statusText, /Waiting for the car to start drawing/); + assert.match(statusText, /but the car is not drawing/); + assert.match(statusText, /the charger has not acted on/); + assert.match(statusText, /Main fuse limits this charge/); + // The charger's own words are part of the sentence. + assert.match(statusText, /Charger reports: " \+ m\.charger_reason/); + // Elapsed time comes from the box's since_ms, not from click time. + assert.match(statusText, /m\.since_ms/); +}); + +test('the manual tab is redrawn on every poll', () => { + assert.match(manual, /return \{ el: box, update: update \};/); + assert.match(source, /container\.update = function \(nextLp, d\) \{ manual\.update\(nextLp, d\); \};/); + assert.match(source, /evTabsEl\.update\(matched, d\);/); +}); + +test("a refused Start reads as a failure with the server's reason", () => { + assert.match(manual, /if \(r\.ok\) return r;/); + assert.match(manual, /\.then\(failOn\)/); + assert.match(manual, /"Start failed: " \+ \(\(e && e\.message\) \|\| "try again"\)/); + assert.doesNotMatch(manual, /"Charging at " \+ a \+ " A until the car is full/); +}); + +test('the plan strip uses the same sentence while a manual charge runs', () => { + const strip = source.slice( + source.indexOf('function renderEvPlanStatus'), + source.indexOf('// EV modal sub-elements held across refreshes'), + ); + assert.match(strip, /manualStatusText\(lp, d\)/); +}); From aea554ec3debb8efbf01cacdfe521d03dc945d74 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Sun, 6 Sep 2026 09:01:14 +0200 Subject: [PATCH 06/57] fix(ev): apply controls directly and show charger feedback --- .changeset/ev-feedback-and-direct-controls.md | 7 + go/internal/api/api_loadpoint_manual.go | 16 +- go/internal/api/api_loadpoint_manual_test.go | 33 ++ go/internal/loadpoint/loadpoint.go | 2 + go/internal/loadpoint/manual_status.go | 35 +- go/internal/loadpoint/manual_status_test.go | 19 + web/app.js | 331 ++++++++++++------ web/ev-charge-now.test.mjs | 9 +- web/ev-manual-feedback.test.mjs | 32 +- web/ev-plan-status.test.mjs | 6 +- web/ev-plug-in-view.test.mjs | 6 +- web/ev-schedule-autosave.test.mjs | 2 +- web/loadpoint-battery-boost.test.mjs | 2 +- web/settings/tabs/loadpoints.js | 154 ++++++-- web/settings/tabs/loadpoints.test.mjs | 8 +- 15 files changed, 504 insertions(+), 158 deletions(-) create mode 100644 .changeset/ev-feedback-and-direct-controls.md diff --git a/.changeset/ev-feedback-and-direct-controls.md b/.changeset/ev-feedback-and-direct-controls.md new file mode 100644 index 00000000..d4202954 --- /dev/null +++ b/.changeset/ev-feedback-and-direct-controls.md @@ -0,0 +1,7 @@ +--- +"ftw": patch +--- + +Add a charger without inventing an ID or pressing Save again. Charger settings apply on change, with errors and a retry beside the form. OCPP setup stays separate from cloud chargers. + +Charging feedback separates the FTW request, the charger's reported limit and measured power. Old charger readings cannot claim current charging. Manual current changes apply on release; Return to plan names the action that ends a manual hold. Charge level and schedule writes run in order, and failed requests stay visible. diff --git a/go/internal/api/api_loadpoint_manual.go b/go/internal/api/api_loadpoint_manual.go index b7e92b27..f974ce06 100644 --- a/go/internal/api/api_loadpoint_manual.go +++ b/go/internal/api/api_loadpoint_manual.go @@ -264,6 +264,16 @@ func (s *Server) decorateLoadpointsWithManual(states []loadpoint.State) { } states[i].Phases = phases states[i].VoltageV = voltage + reading := chargers[states[i].DriverName] + status := &loadpoint.ChargerStatus{Known: reading.Known, Available: reading.Known && !reading.Unavailable, Reason: reading.Reason} + if !reading.UpdatedAt.IsZero() { + status.UpdatedAtMs = reading.UpdatedAt.UnixMilli() + } + if reading.LimitKnown { + limit := reading.LimitA + status.LimitA = &limit + } + states[i].Charger = status if s.deps.LoadpointCtrl != nil { h, ok := s.deps.LoadpointCtrl.GetManualHold(states[i].ID, now) if ok { @@ -293,11 +303,15 @@ func (s *Server) chargerReadings() map[string]loadpoint.ChargerReading { Charging bool `json:"charging"` Reason string `json:"reason_no_current_label"` CommandStalled bool `json:"command_stalled"` + Online *bool `json:"is_online"` } if len(rd.Data) > 0 { _ = json.Unmarshal(rd.Data, &d) } - r := loadpoint.ChargerReading{Known: true, Charging: d.Charging, Reason: d.Reason, Stalled: d.CommandStalled} + r := loadpoint.ChargerReading{Known: true, UpdatedAt: rd.UpdatedAt, Unavailable: d.Online != nil && !*d.Online, Charging: d.Charging, Reason: d.Reason, Stalled: d.CommandStalled} + if health := s.deps.Tel.DriverHealth(rd.Driver); health != nil && !health.TelemetryLive() { + r.Unavailable = true + } if d.MaxA != nil { r.LimitA = *d.MaxA r.LimitKnown = true diff --git a/go/internal/api/api_loadpoint_manual_test.go b/go/internal/api/api_loadpoint_manual_test.go index 3d267c1a..5fd23a10 100644 --- a/go/internal/api/api_loadpoint_manual_test.go +++ b/go/internal/api/api_loadpoint_manual_test.go @@ -325,3 +325,36 @@ func TestLoadpointsCarryManualStatus(t *testing.T) { t.Fatalf("after Stop: %+v", m) } } + +func TestLoadpointsReportChargerFreshnessWithoutManualHold(t *testing.T) { + mgr := loadpoint.NewManager() + mgr.Load([]loadpoint.Config{{ID: "garage", DriverName: "easee"}}) + tel := telemetry.NewStore() + srv := New(&Deps{Loadpoints: mgr, Tel: tel}) + read := func() *loadpoint.ChargerStatus { + t.Helper() + rr := httptest.NewRecorder() + srv.Handler().ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/api/loadpoints", nil)) + var body struct { + Loadpoints []loadpoint.State `json:"loadpoints"` + } + if err := json.Unmarshal(rr.Body.Bytes(), &body); err != nil { + t.Fatal(err) + } + if len(body.Loadpoints) != 1 || body.Loadpoints[0].Charger == nil { + t.Fatalf("missing charger status: %s", rr.Body.String()) + } + return body.Loadpoints[0].Charger + } + if got := read(); got.Known || got.Available { + t.Fatalf("no reading must stay unknown: %+v", got) + } + tel.Update("easee", telemetry.DerEV, 10800, nil, json.RawMessage(`{"is_online":false,"charging":true,"max_a":16,"reason_no_current_label":"offline"}`)) + if got := read(); !got.Known || got.Available || got.UpdatedAtMs == 0 || got.Reason != "offline" { + t.Fatalf("offline cached power became current: %+v", got) + } + tel.Update("easee", telemetry.DerEV, 10800, nil, json.RawMessage(`{"is_online":true,"charging":true,"max_a":16}`)) + if got := read(); !got.Available || got.LimitA == nil || *got.LimitA != 16 { + t.Fatalf("fresh report did not recover: %+v", got) + } +} diff --git a/go/internal/loadpoint/loadpoint.go b/go/internal/loadpoint/loadpoint.go index fb43740f..51f763e0 100644 --- a/go/internal/loadpoint/loadpoint.go +++ b/go/internal/loadpoint/loadpoint.go @@ -172,6 +172,8 @@ type State struct { // and what the charger did with it. Populated by the API layer from the // controller and the charger's reading; see ManualStatusFrom. Manual ManualStatus `json:"manual"` + // Charger is the driver reading used for feedback in every charging mode. + Charger *ChargerStatus `json:"charger,omitempty"` // BatteryBoost is the explicit, bounded home-battery-to-EV permission // for this loadpoint. Populated by the API layer from Controller state. diff --git a/go/internal/loadpoint/manual_status.go b/go/internal/loadpoint/manual_status.go index 5b7c24a5..a66cfe51 100644 --- a/go/internal/loadpoint/manual_status.go +++ b/go/internal/loadpoint/manual_status.go @@ -14,7 +14,8 @@ import ( // 5–15 s to act, nothing on screen moved, and the operator removed the // charger to charge by hand. type ManualStatus struct { - Active bool `json:"active"` + Active bool `json:"active"` + ChargerUpdatedAtMs int64 `json:"charger_updated_at_ms,omitempty"` // State is one of ManualSent, ManualAccepted, ManualCharging, // ManualNotDrawing, ManualStalled or ManualLimited. Empty when inactive. State string `json:"state,omitempty"` @@ -60,18 +61,31 @@ const ( ManualStalled = "stalled" // ManualLimited: a clamp the hold cannot override (main fuse, stale site // meter) holds the order below what was asked. - ManualLimited = "limited" + ManualLimited = "limited" + ManualUnavailable = "unavailable" ) +// ChargerStatus separates a current report from a cached reading. +// Power and connection state cannot be treated as current when Available is false. +type ChargerStatus struct { + Known bool `json:"known"` + Available bool `json:"available"` + UpdatedAtMs int64 `json:"updated_at_ms,omitempty"` + Reason string `json:"reason,omitempty"` + LimitA *float64 `json:"limit_a,omitempty"` +} + // ChargerReading is what the charger's driver last reported, as far as the // manual status needs it. Known is false when there is no reading. type ChargerReading struct { - Known bool - LimitA float64 - LimitKnown bool - Charging bool - Reason string - Stalled bool + Known bool + UpdatedAt time.Time + Unavailable bool + LimitA float64 + LimitKnown bool + Charging bool + Reason string + Stalled bool } const ( @@ -121,6 +135,9 @@ func ManualStatusFrom(h ManualHold, held bool, st State, ch ChargerReading, now ChargerLimitKnown: ch.Known && ch.LimitKnown, ChargerReason: ch.Reason, } + if !ch.UpdatedAt.IsZero() { + m.ChargerUpdatedAtMs = ch.UpdatedAt.UnixMilli() + } // The ordered value is the box's last command only once a tick has run // the hold branch; before that the snapshot still carries the previous // automatic order, which says nothing about this hold. @@ -149,6 +166,8 @@ func ManualStatusFrom(h ManualHold, held bool, st State, ch ChargerReading, now limitMatches := m.ChargerLimitKnown && m.CommandedA > 0 && math.Abs(ch.LimitA-m.CommandedA) < 1 switch { + case ch.Unavailable: + m.State = ManualUnavailable case st.CurrentPowerW >= manualChargingFloorW || (ch.Known && ch.Charging): m.State = ManualCharging if clamp { diff --git a/go/internal/loadpoint/manual_status_test.go b/go/internal/loadpoint/manual_status_test.go index 963cd8c5..af33f6fa 100644 --- a/go/internal/loadpoint/manual_status_test.go +++ b/go/internal/loadpoint/manual_status_test.go @@ -139,3 +139,22 @@ func TestManualStatusFrom_InactiveIsEmpty(t *testing.T) { t.Errorf("inactive status must be the zero value, got %+v", got) } } + +func TestManualStatusUnavailableDoesNotReuseChargingPower(t *testing.T) { + now := time.Now() + hold := ManualHold{PowerW: 11040, Persistent: true, StartedAt: now.Add(-time.Minute)} + st := State{Phases: 3, VoltageV: 230, CurrentPowerW: 10800} + reading := ChargerReading{Known: true, Unavailable: true, Charging: true, UpdatedAt: now.Add(-5 * time.Minute)} + got := ManualStatusFrom(hold, true, st, reading, now) + if got.State != ManualUnavailable { + t.Fatalf("old power became current charging: %+v", got) + } + if got.ChargerUpdatedAtMs != reading.UpdatedAt.UnixMilli() { + t.Fatalf("missing age: %+v", got) + } + reading.Unavailable = false + reading.UpdatedAt = now + if got := ManualStatusFrom(hold, true, st, reading, now); got.State != ManualCharging { + t.Fatalf("did not recover: %+v", got) + } +} diff --git a/web/app.js b/web/app.js index bdc21efc..6d3274f6 100644 --- a/web/app.js +++ b/web/app.js @@ -65,6 +65,16 @@ return request.then(decode).finally(function () { clearTimeout(timer); }); } + function evWrite(path, options) { + var controller = new AbortController(); + var timer = setTimeout(function () { controller.abort(); }, 30000); + return apiFetch(path, Object.assign({}, options, { signal: controller.signal })) + .catch(function (e) { + if (e && e.name === "AbortError") throw new Error("FTW has not confirmed the request. Check its current state before trying again"); + throw e; + }).finally(function () { clearTimeout(timer); }); + } + // ---- Chart data ---- var chartHistory = { grid: [], @@ -2664,21 +2674,25 @@ function manualStatusText(lp, d) { var m = lp && lp.manual; if (!lp || !lp.manual_active || !m || !m.active) return null; - if (lp.manual_release_soc > 0) { - return "Charging now → stops at " + Math.round(lp.manual_release_soc * 100) + " %, then back to the plan (fuse still limits)."; - } var reqA = m.requested_a > 0 ? Math.round(m.requested_a) + " A" : formatW(m.requested_w || lp.manual_charge_w || 0); var cmdA = m.commanded_a > 0 ? Math.round(m.commanded_a) + " A" : formatW(m.commanded_w || 0); var since = m.since_ms > 0 ? evFmtElapsed(Date.now() - m.since_ms) : ""; var sinceP = since ? " (" + since + ")" : ""; var reason = m.charger_reason ? " Charger reports: " + m.charger_reason + "." : ""; + var end = lp.manual_release_soc > 0 + ? " Returns to the plan at the estimated " + Math.round(lp.manual_release_soc * 100) + " % target." + : " Continues until the car stops drawing, you return to the plan, or unplug."; switch (m.state) { + case "unavailable": + return "Charger status is out of date. FTW cannot confirm whether the car is charging."; case "charging": - return "Charging at " + formatW(lp.current_power_w || 0) + " — " + reqA + " requested. Runs until the car is full, Stop or unplug."; + return ((lp.current_power_w || 0) >= 100 + ? "Charging at " + formatW(lp.current_power_w) + " — " + reqA + " requested." + : "The charger reports charging. Waiting for a power reading.") + end; case "sent": - return "Sent " + reqA + " to the charger. Waiting for it to confirm…" + sinceP; + return "FTW received " + reqA + ". Waiting for the charger…" + sinceP + end; case "accepted": - return "Charger set to " + cmdA + ". Waiting for the car to start drawing…" + sinceP + reason; + return "Charger reports a " + cmdA + " limit. Waiting for the car to start drawing…" + sinceP + reason + end; case "not_drawing": return "Charger offers " + cmdA + " but the car is not drawing" + sinceP + "." + (reason || " It may be full, or held by its own charge limit or schedule."); @@ -2694,7 +2708,7 @@ } return "Main fuse limits this charge to " + cmdA + " right now (" + reqA + " requested)." + sinceP; } - return "Manual charge at " + reqA + " — until the car is full, Stop or unplug."; + return "FTW received " + reqA + ". Waiting for charger status." + end; } // renderEvPlanStatus answers the question the status table can't: @@ -2713,14 +2727,15 @@ var winActive = lp.plan_next_start_ms > 0 && lp.plan_next_start_ms <= Date.now() && Date.now() < lp.plan_next_end_ms; var charging = (lp.current_power_w || 0) >= 100; var hasSchedule = lp.schedule && lp.schedule.soc > 0; - if (lp.manual_active) { + if (lp.charger && !lp.charger.available) { + text = lp.charger.known + ? "Charger status is out of date. FTW cannot confirm whether the car is charging." + : "Waiting for the charger's first status report."; + tone = "var(--text)"; + } else if (lp.manual_active) { // The same sentence as the Manual tab, so the charger's own reason is // never hidden behind "manual charge is running". - text = lp.manual_release_soc > 0 - ? "Charging now at " + formatW(lp.manual_charge_w || 0) + " → returns to plan at " + - Math.round(lp.manual_release_soc * 100) + " %." - : (manualStatusText(lp, d) || ("Manual charge at " + formatW(lp.manual_charge_w || 0) + - " — plan and PV logic are off until the car is full, Stop or unplug.")); + text = manualStatusText(lp, d) || "Manual charge requested. Waiting for charger status."; if (lp.manual && (lp.manual.state === "not_drawing" || lp.manual.state === "stalled")) { tone = "var(--text)"; } @@ -2732,11 +2747,9 @@ text += " Rate is limited by the main fuse right now."; } } else if (lp.commanded_known && lp.commanded_w > 0) { - text = "Charger offers " + formatW(lp.commanded_w) + - " but the car isn't drawing — it may be full or at its own charge limit."; - if (d && d.reason_no_current_label) { - text += " Charger reports: " + d.reason_no_current_label + "."; - } + text = "FTW requests " + formatW(lp.commanded_w) + ". Waiting for the car to draw power."; + var chargerReason = lp.charger && lp.charger.reason || d && d.reason_no_current_label; + if (chargerReason) text += " Charger reports: " + chargerReason + "."; tone = "var(--text)"; } else if (lp.commanded_known && !lp.commanded_w && (lp.commanded_reason === "fuse_cooldown" || lp.commanded_reason === "fuse_limit")) { @@ -2748,7 +2761,7 @@ } else if (lp.commanded_known && !lp.commanded_w && lp.commanded_reason === "site_meter_stale") { text = "Paused for safety: site-meter data is stale — charging resumes when telemetry recovers."; tone = "var(--text)"; - } else if (lp.grid_deferred) { + } else if (lp.grid_deferred && hasSchedule) { // Richer than the pv_surplus_pause reason it usually co-occurs // with: it also says when normal planning resumes. text = "Waiting for tomorrow's electricity prices — until they arrive (~13:00) the car charges from PV surplus only."; @@ -2763,7 +2776,7 @@ } else if (lp.surplus_only) { text = "PV surplus only — charges when solar exceeds house load."; } else if (!hasSchedule) { - text = "Nothing will start charging: set a schedule, turn on PV only, or press Start."; + text = "No charging plan yet. Choose Scheduled to set a ready time, or Manual to charge now."; tone = "var(--text)"; } else { text = "No charge window in the current plan — the target may already be reached."; @@ -2794,9 +2807,10 @@ var evTabsLpId = null; var evBoostEl = null; // { el, update } from buildEvBoostView var evBoostLpId = null; + var evBoostDetails = null; var schedNeedsRebuild = false; var manualNeedsRebuild = false; - var evActiveTab = "pv"; // "pv" | "manual" | "scheduled" + var evActiveTab = "manual"; // "pv" | "manual" | "scheduled" // Detect whether the site has any PV driver configured. Used to hide // the "surplus charge from PV" option on PV-less sites where the @@ -2812,7 +2826,12 @@ return false; } + var evReading = false; + var evLastLp = null; function refreshEvModal() { + if (evReading) return; + evReading = true; + var requestedDriver = evModalDriver; // Pass driver query if known so the backend can scope the response // to the clicked planet (multi-EV setups). Falls back to whatever // the backend returns when no driver filter is honored. @@ -2823,9 +2842,13 @@ // to "no PV" until the dashboard's own fetchStatus lands once. Promise.all([ // Local API reads. - apiFetch(url).then(function (r) { return r.ok ? r.json() : null; }).catch(function () { return null; }), - apiFetch("/api/loadpoints").then(function (r) { return r.ok ? r.json() : null; }).catch(function () { return null; }), + boundedApiRead(url, function (r) { if (!r.ok) throw new Error("Charger status unavailable"); return r.json(); }), + boundedApiRead("/api/loadpoints", function (r) { if (!r.ok) throw new Error("Charging settings unavailable"); return r.json(); }), ]).then(function (results) { + if (requestedDriver !== evModalDriver || evModal.classList.contains("hidden")) return; + var problem = evModalBody.querySelector('.ev-read-problem'); + if (problem) problem.remove(); + if (statusTableEl) statusTableEl.hidden = false; var d = results[0]; var lps = results[1]; var status = lastStatusPayload; @@ -2857,7 +2880,12 @@ // the modal still has a header before the schedule editor. var freshStatus; if (carConnected) { - freshStatus = renderEvStatusTable(d); + freshStatus = document.createElement("details"); + freshStatus.open = !!(statusTableEl && statusTableEl.open); + var detailsLabel = document.createElement("summary"); + detailsLabel.textContent = "Charger details"; + freshStatus.appendChild(detailsLabel); + freshStatus.appendChild(renderEvStatusTable(d)); } else { freshStatus = document.createElement("p"); freshStatus.style.color = "var(--text-dim)"; @@ -2894,15 +2922,36 @@ // schedule is persistent loadpoint state, not driver state, and // operators routinely want to set tomorrow morning's target // before plugging in tonight). - if (!matched) { + if (!matched && !evModalDriver) { for (var j = 0; j < lps.loadpoints.length; j++) { if (lps.loadpoints[j].plugged_in) { matched = lps.loadpoints[j]; break; } } } - if (!matched) { + if (!matched && !evModalDriver) { matched = lps.loadpoints[0]; } } + evLastLp = matched; + if (matched && matched.charger && !matched.charger.available) { + var oldReport = document.createElement("p"); + oldReport.textContent = matched.charger.updated_at_ms + ? "Last charger report: " + evFmtClock(matched.charger.updated_at_ms) + " (out of date)." + : "Waiting for the charger's first status report."; + evModalBody.replaceChild(oldReport, statusTableEl); + statusTableEl = oldReport; + } + var setupNote = evModalBody.querySelector(".ev-setup-note"); + if (!matched && lps) { + if (!setupNote) { + setupNote = document.createElement("p"); + setupNote.className = "ev-setup-note"; + setupNote.setAttribute("role", "status"); + evModalBody.insertBefore(setupNote, statusTableEl.nextSibling); + } + setupNote.textContent = "FTW can read this charger, but charging control is not set up. Open Settings → Chargers and add the charger."; + } else if (setupNote) { + setupNote.remove(); + } // Plan view: what the box will do with this car (one sentence + // the planned windows on a 24 h track) and the car's charge level, // which the plan is built from. Mounted once per loadpoint so the @@ -2914,11 +2963,11 @@ } evPlanEl = buildEvPlanView(matched, d); evPlanLpId = matched.id; - evModalBody.insertBefore(evPlanEl.el, statusTableEl.nextSibling); + evModalBody.insertBefore(evPlanEl.el, statusTableEl); } else { evPlanEl.update(matched, d); if (evPlanEl.el.parentNode !== evModalBody) { - evModalBody.insertBefore(evPlanEl.el, statusTableEl.nextSibling); + evModalBody.insertBefore(evPlanEl.el, statusTableEl); } } } else if (evPlanEl) { @@ -2969,24 +3018,45 @@ if (evBoostEl && evBoostEl.el.parentNode === evModalBody) { evModalBody.removeChild(evBoostEl.el); } + if (evBoostDetails && evBoostDetails.parentNode === evModalBody) evBoostDetails.remove(); evBoostEl = buildEvBoostView(matched, status); evBoostLpId = matched.id; + evBoostDetails = document.createElement("details"); + var boostSummary = document.createElement("summary"); + boostSummary.textContent = "Use the home battery"; + evBoostDetails.appendChild(boostSummary); + evBoostDetails.appendChild(evBoostEl.el); } else { evBoostEl.update(matched, status); } - if (evModalBody.lastChild !== evBoostEl.el) { - evModalBody.appendChild(evBoostEl.el); + if (evModalBody.lastChild !== evBoostDetails) { + evModalBody.appendChild(evBoostDetails); } } else if (evBoostEl) { - if (evBoostEl.el.parentNode === evModalBody) { - evModalBody.removeChild(evBoostEl.el); - } + if (evBoostDetails && evBoostDetails.parentNode === evModalBody) evBoostDetails.remove(); evBoostEl = null; evBoostLpId = null; } }).catch(function () { - setEvModalMessage("Failed to load EV status"); - }); + if (requestedDriver !== evModalDriver || evModal.classList.contains("hidden")) return; + var problem = evModalBody.querySelector('.ev-read-problem'); + if (!problem) { + problem = document.createElement("p"); + problem.className = "ev-read-problem"; + problem.setAttribute("role", "alert"); + evModalBody.prepend(problem); + } + problem.textContent = "Waiting for current charger status. FTW cannot confirm whether the car is charging. Trying again…"; + if (statusTableEl) statusTableEl.hidden = true; + if (evLastLp) { + var stale = Object.assign({}, evLastLp, { + charger: { known: true, available: false }, + manual: Object.assign({}, evLastLp.manual, { state: "unavailable" }), + }); + if (evTabsEl && evTabsEl.update) evTabsEl.update(stale, null); + if (evPlanEl) evPlanEl.update(stale, null); + } + }).finally(function () { evReading = false; }); } // Stop reasons the controller reports for a battery boost lease, in @@ -3157,7 +3227,7 @@ startBtn.disabled = true; msg.textContent = "Starting boost…"; // CONTROL write — strict (FIX-B): time-boxed battery boost lease. - apiFetch("/api/loadpoints/" + encodeURIComponent(lp.id) + "/battery_boost", { + evWrite("/api/loadpoints/" + encodeURIComponent(lp.id) + "/battery_boost", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ @@ -3175,7 +3245,7 @@ stopBtn.addEventListener("click", function () { stopBtn.disabled = true; msg.textContent = "Stopping boost…"; - apiFetch("/api/loadpoints/" + encodeURIComponent(lp.id) + "/battery_boost", { method: "DELETE" }) + evWrite("/api/loadpoints/" + encodeURIComponent(lp.id) + "/battery_boost", { method: "DELETE" }) .then(function (res) { stopBtn.disabled = false; if (res.ok) { msg.textContent = ""; refreshEvModal(); } @@ -3276,6 +3346,7 @@ slider.max = String(maxA); slider.step = "1"; slider.value = String(curA); + slider.setAttribute("aria-label", "Charging current"); slider.style.flex = "1"; slider.style.accentColor = "var(--accent-e)"; @@ -3302,8 +3373,10 @@ status.style.color = "var(--text-dim)"; status.style.marginTop = "0.35rem"; status.style.minHeight = "1em"; - var idleText = "Charges at the slider's amps until the car is full, Stop or unplug. The plan takes over again after that."; - status.textContent = manualStatusText(lp, null) || idleText; + status.setAttribute("role", "status"); + status.setAttribute("aria-live", "polite"); + var idleText = "Requests this current now. Return to plan restores your schedule and solar settings."; + status.textContent = active ? "Changes apply when you release the slider." : idleText; box.appendChild(status); // Start / Stop buttons. @@ -3314,7 +3387,8 @@ var startBtn = document.createElement("button"); startBtn.type = "button"; - startBtn.textContent = active ? "Update" : "Charge now"; + startBtn.textContent = "Charge now"; + startBtn.hidden = active; startBtn.style.flex = "1"; startBtn.style.padding = "0.4rem 0.6rem"; startBtn.style.border = "none"; @@ -3326,7 +3400,7 @@ var stopBtn = document.createElement("button"); stopBtn.type = "button"; - stopBtn.textContent = "Stop"; + stopBtn.textContent = "Return to plan"; stopBtn.style.flex = "1"; stopBtn.style.padding = "0.4rem 0.6rem"; stopBtn.style.border = "1px solid var(--line)"; @@ -3354,10 +3428,11 @@ if (!busy) { stopBtn.disabled = !on; stopBtn.style.opacity = on ? "1" : "0.5"; - startBtn.textContent = on ? "Update" : "Charge now"; + startBtn.hidden = on; + startBtn.disabled = false; } if (busy || Date.now() < holdLineUntil) return; - status.textContent = manualStatusText(lastLp, lastD) || idleText; + status.textContent = on ? "Changes apply when you release the slider." : idleText; } function update(nextLp, d) { if (nextLp) lastLp = nextLp; @@ -3374,14 +3449,17 @@ }); } - startBtn.addEventListener("click", function () { + function requestCharge() { + if (busy) return; busy = true; + slider.disabled = true; + stopBtn.disabled = true; startBtn.disabled = true; var a = parseInt(slider.value, 10) || minA; - status.textContent = "Sending " + a + " A to the charger…"; + status.textContent = "Asking FTW for " + a + " A…"; // CONTROL write — strict (FIX-B): persistent manual hold (hold_s:0) // with no SoC release — see the note above the slider. - apiFetch("/api/loadpoints/" + encodeURIComponent(lp.id) + "/manual_hold", { + evWrite("/api/loadpoints/" + encodeURIComponent(lp.id) + "/manual_hold", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ @@ -3391,33 +3469,46 @@ }), }).then(failOn).then(function () { busy = false; - status.textContent = "Sent " + a + " A to the charger. Waiting for it to confirm…"; + slider.disabled = false; + status.textContent = "FTW received " + a + " A. Waiting for the charger…"; holdLineUntil = Date.now() + 6000; - manualNeedsRebuild = true; // reflect active state on next poll + refreshEvModal(); // read the hold and charger response without rebuilding focused inputs }).catch(function (e) { busy = false; + slider.disabled = false; startBtn.disabled = false; - status.textContent = "Start failed: " + ((e && e.message) || "try again") + "."; - holdLineUntil = Date.now() + 15000; + stopBtn.disabled = !lastLp.manual_active; + status.textContent = "Request not confirmed: " + ((e && e.message) || "try again") + "."; + holdLineUntil = Infinity; }); + } + startBtn.addEventListener("click", requestCharge); + slider.addEventListener("change", function () { + if (lastLp.manual_active) requestCharge(); }); stopBtn.addEventListener("click", function () { busy = true; stopBtn.disabled = true; - status.textContent = "Stopping…"; - apiFetch("/api/loadpoints/" + encodeURIComponent(lp.id) + "/manual_hold", { + slider.disabled = true; + startBtn.disabled = true; + status.textContent = "Returning to the plan…"; + evWrite("/api/loadpoints/" + encodeURIComponent(lp.id) + "/manual_hold", { method: "DELETE", }).then(failOn).then(function () { busy = false; - status.textContent = "Released — back to automatic charging."; + slider.disabled = false; + startBtn.disabled = false; + status.textContent = "Manual charge ended. The plan decides when to charge."; holdLineUntil = Date.now() + 6000; - manualNeedsRebuild = true; + refreshEvModal(); }).catch(function (e) { busy = false; + slider.disabled = false; + startBtn.disabled = false; stopBtn.disabled = false; status.textContent = "Stop failed: " + ((e && e.message) || "try again") + "."; - holdLineUntil = Date.now() + 15000; + holdLineUntil = Infinity; }); }); @@ -3538,12 +3629,13 @@ var dragging = false; var lastInputAt = 0; var noteTimer = null; + var socFailed = false; slider.addEventListener("pointerdown", function () { dragging = true; }); ["pointerup", "pointercancel"].forEach(function (ev) { slider.addEventListener(ev, function () { dragging = false; }); }); slider.addEventListener("input", function () { lastInputAt = Date.now(); }); - function operatorHolds() { return dragging || (Date.now() - lastInputAt) < 1500; } + function operatorHolds() { return dragging || socSaving || socPending !== null || (Date.now() - lastInputAt) < 1500; } function sourceNote(lpNow) { var src = (lpNow && lpNow.soc_source) || ""; @@ -3552,28 +3644,44 @@ return "Estimated from energy delivered. Drag to the real value and the plan follows."; } - // Write on release. The handler replans before answering, so the - // refetch right after shows the plan built from the new value. - slider.addEventListener("change", function () { - var v = parseInt(slider.value, 10); - if (!isFinite(v) || v < 0 || v > 100) return; - if (noteTimer) { clearTimeout(noteTimer); noteTimer = null; } - note.textContent = "Replanning from " + v + " %…"; - // CONTROL write — strict (FIX-B): corrects the SoC estimate and replans. - apiFetch("/api/loadpoints/" + encodeURIComponent(lp.id) + "/soc", { + var socPending = null; + var socSaving = false; + var socRevision = 0; + function sendSoc() { + if (socSaving || socPending === null) return; + var v = socPending; + var revision = socRevision; + socPending = null; + socSaving = true; + evWrite("/api/loadpoints/" + encodeURIComponent(lp.id) + "/soc", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ soc: v / 100 }), }).then(function (r) { return r.json().then(function (j) { return { ok: r.ok, body: j }; }); }) .then(function (res) { - if (res.ok && res.body && res.body.ok) { - note.textContent = "Plan updated from " + v + " %."; - noteTimer = setTimeout(function () { note.textContent = sourceNote(lastLp); noteTimer = null; }, 4000); - refreshEvModal(); - } else { - note.textContent = (res.body && res.body.error) || "Could not set the charge level."; - } - }).catch(function (e) { note.textContent = "Could not set the charge level: " + e.message; }); + if (!(res.ok && res.body && res.body.ok)) throw new Error((res.body && res.body.error) || "FTW refused the change."); + if (revision !== socRevision) return; + note.textContent = "Charge level saved: " + v + " %." + + (!(lastLp.schedule && lastLp.schedule.soc > 0) && !lastLp.manual_active && !lastLp.surplus_only + ? " Choose Scheduled or Manual to start charging." : " Reading the updated plan…"); + noteTimer = setTimeout(function () { noteTimer = null; if (!socFailed) note.textContent = sourceNote(lastLp); }, 6000); + refreshEvModal(); + }).catch(function (e) { + if (revision === socRevision) { socFailed = true; note.textContent = "Charge level not confirmed: " + e.message; } + }).finally(function () { + socSaving = false; + if (socPending !== null) sendSoc(); + }); + } + slider.addEventListener("change", function () { + var v = parseInt(slider.value, 10); + if (!isFinite(v) || v < 0 || v > 100) return; + if (noteTimer) { clearTimeout(noteTimer); noteTimer = null; } + socRevision++; + socFailed = false; + socPending = v; + note.textContent = "Sending charge level: " + v + " %…"; + sendSoc(); }); var lastLp = lp; @@ -3583,6 +3691,7 @@ ticks.textContent = ""; var now = Date.now(); var windows = (lpNow && Array.isArray(lpNow.plan_windows)) ? lpNow.plan_windows : []; + planWrap.hidden = windows.length === 0 || !!lpNow.manual_active; var shown = 0; var shownWh = 0; windows.forEach(function (w) { @@ -3610,8 +3719,8 @@ } if (lpNow && lpNow.manual_active) { caption.textContent = shown > 0 - ? "Manual charge is running. The plan below resumes after it." - : "Manual charge is running. Nothing else is planned in the next 24 h."; + ? "Manual charge is selected. The plan below resumes when you return to it." + : "Manual charge is selected. Nothing else is planned in the next 24 h."; } else if (shown > 0) { var first = windows[0]; caption.textContent = "Charges " + evFmtClock(first.start_ms) + "–" + evFmtClock(first.end_ms) + @@ -3642,7 +3751,7 @@ slider.value = String(cur); hdr.value.textContent = cur + "%"; } - if (!noteTimer && !operatorHolds()) note.textContent = sourceNote(lpNow); + if (!noteTimer && !socFailed && !operatorHolds()) note.textContent = sourceNote(lpNow); } update(lp, d); @@ -3704,7 +3813,7 @@ soCb.disabled = true; soStatus.textContent = "Saving…"; // CONTROL write — strict (FIX-B): loadpoint surplus-only toggle. - apiFetch("/api/loadpoints/" + encodeURIComponent(lp.id) + "/target", { + evWrite("/api/loadpoints/" + encodeURIComponent(lp.id) + "/target", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ surplus_only: soCb.checked }), @@ -3735,7 +3844,7 @@ // UTC minutes on save. var hasSched = !!(sched.soc || sched.recurring || sched.surplus_unlock_bat_soc); var initLocalHHMM = utcMinsToLocalHHMM(typeof sched.time_of_day_min_utc === "number" ? sched.time_of_day_min_utc : 360); - var initSoC = typeof sched.soc === "number" && sched.soc > 0 ? sched.soc * 100 : 50; + var initSoC = typeof sched.soc === "number" && sched.soc > 0 ? sched.soc * 100 : 80; var initRec = !!sched.recurring; var savedUnlock = typeof sched.surplus_unlock_bat_soc === "number" ? sched.surplus_unlock_bat_soc * 100 : 0; // Surplus on/off is derived from the saved threshold: > 0 ⇒ enabled. @@ -4026,15 +4135,17 @@ // from the new schedule. No Save button. var saveTimer = null; var saveSeq = 0; + var writeQueue = Promise.resolve(); var statusTimer = null; function scheduleSave() { if (saveTimer) clearTimeout(saveTimer); if (statusTimer) { clearTimeout(statusTimer); statusTimer = null; } - status.textContent = "Saving…"; + saveSeq++; + status.textContent = "Applying schedule…"; saveTimer = setTimeout(function () { saveTimer = null; doSave(); }, 400); } function doSave() { - var seq = ++saveSeq; + var seq = saveSeq; var localHHMM = timeInp.value || initLocalHHMM; var minUTC = localHHMMToUtcMins(localHHMM); // Surplus checkbox gates the threshold: when off (or hidden on @@ -4056,24 +4167,23 @@ surplus_unlock_bat_soc: unlockVal > 0 ? unlockVal / 100 : 0, }, }; - // CONTROL write — strict (FIX-B): loadpoint schedule save. - apiFetch("/api/loadpoints/" + encodeURIComponent(lp.id) + "/target", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(body), - }).then(function (r) { - if (!r.ok) throw new Error("HTTP " + r.status); - if (seq !== saveSeq) return; - hasSched = true; - paintClear(); - status.textContent = "Saved · replanning…"; - refreshEvModal(); - statusTimer = setTimeout(function () { - if (seq === saveSeq) status.textContent = "Plan updated."; - }, 1500); - }).catch(function (e) { + // Complete writes in order, even when the charger or planner is slow. + writeQueue = writeQueue.catch(function () {}).then(function () { if (seq !== saveSeq) return; - status.textContent = "Save failed: " + e.message; + return evWrite("/api/loadpoints/" + encodeURIComponent(lp.id) + "/target", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }).then(function (r) { + if (!r.ok) return r.json().then(function (j) { throw new Error(j.error || ("HTTP " + r.status)); }); + if (seq !== saveSeq) return; + hasSched = true; + paintClear(); + status.textContent = "Schedule saved. Reading the plan…"; + refreshEvModal(); + }).catch(function (e) { + if (seq === saveSeq) status.textContent = "Schedule not confirmed: " + e.message; + }); }); } @@ -4093,19 +4203,20 @@ if (saveTimer) { clearTimeout(saveTimer); saveTimer = null; } saveSeq++; status.textContent = "Removing…"; - // CONTROL write — strict (FIX-B): loadpoint schedule clear. - apiFetch("/api/loadpoints/" + encodeURIComponent(lp.id) + "/target", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ schedule: null }), - }).then(function (r) { - if (!r.ok) throw new Error("HTTP " + r.status); - status.textContent = "Schedule removed."; - schedNeedsRebuild = true; - refreshEvModal(); - }).catch(function (e) { - status.textContent = "Remove failed: " + e.message; - clearBtn.disabled = false; + writeQueue = writeQueue.catch(function () {}).then(function () { + return evWrite("/api/loadpoints/" + encodeURIComponent(lp.id) + "/target", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ schedule: null }), + }).then(function (r) { + if (!r.ok) return r.json().then(function (j) { throw new Error(j.error || ("HTTP " + r.status)); }); + status.textContent = "Schedule removed."; + schedNeedsRebuild = true; + refreshEvModal(); + }).catch(function (e) { + status.textContent = "Removal not confirmed: " + e.message; + clearBtn.disabled = false; + }); }); }); diff --git a/web/ev-charge-now.test.mjs b/web/ev-charge-now.test.mjs index 89755105..db4d2b5c 100644 --- a/web/ev-charge-now.test.mjs +++ b/web/ev-charge-now.test.mjs @@ -19,13 +19,14 @@ test('Start installs a hold without a SoC release', () => { assert.doesNotMatch(manual, /release_at_soc_pct/); assert.doesNotMatch(manual, /lp\.schedule/); // The button names its contract without a percentage. - assert.match(manual, /startBtn\.textContent = active \? "Update" : "Charge now";/); - assert.match(manual, /until the car is full, Stop or unplug/); + assert.match(manual, /startBtn\.textContent = "Charge now";/); + assert.match(manual, /slider\.addEventListener\("change"/); + assert.match(manual, /if \(lastLp\.manual_active\) requestCharge\(\)/); }); test('an API-installed release target is still explained when active', () => { // A hold with release_at_soc_pct can still arrive through the API; // the manual tab and the plan strip keep saying where it stops. - assert.match(source, /stops at " \+ Math\.round\(lp\.manual_release_soc \* 100\)/); - assert.match(source, /returns to plan at/); + assert.match(source, /Math\.round\(lp\.manual_release_soc \* 100\)/); + assert.match(source, /Returns to the plan at the estimated/); }); diff --git a/web/ev-manual-feedback.test.mjs b/web/ev-manual-feedback.test.mjs index d0bb6e80..d4fa8bc7 100644 --- a/web/ev-manual-feedback.test.mjs +++ b/web/ev-manual-feedback.test.mjs @@ -18,10 +18,10 @@ const statusText = source.slice( // screen moved, and the operator removed the charger to charge by hand. test('the status line follows the charger through every state', () => { - for (const state of ['sent', 'accepted', 'charging', 'not_drawing', 'stalled', 'limited']) { + for (const state of ['sent', 'accepted', 'charging', 'not_drawing', 'stalled', 'limited', 'unavailable']) { assert.match(statusText, new RegExp(`case "${state}":`)); } - assert.match(statusText, /Waiting for it to confirm/); + assert.match(statusText, /Waiting for the charger/); assert.match(statusText, /Waiting for the car to start drawing/); assert.match(statusText, /but the car is not drawing/); assert.match(statusText, /the charger has not acted on/); @@ -41,7 +41,7 @@ test('the manual tab is redrawn on every poll', () => { test("a refused Start reads as a failure with the server's reason", () => { assert.match(manual, /if \(r\.ok\) return r;/); assert.match(manual, /\.then\(failOn\)/); - assert.match(manual, /"Start failed: " \+ \(\(e && e\.message\) \|\| "try again"\)/); + assert.match(manual, /"Request not confirmed: " \+ \(\(e && e\.message\) \|\| "try again"\)/); assert.doesNotMatch(manual, /"Charging at " \+ a \+ " A until the car is full/); }); @@ -52,3 +52,29 @@ test('the plan strip uses the same sentence while a manual charge runs', () => { ); assert.match(strip, /manualStatusText\(lp, d\)/); }); + +const describeManual = new Function('formatW', 'evFmtElapsed', statusText + '; return manualStatusText;')( + w => `${w} W`, ms => `${Math.floor(ms / 1000)} s`, +); +const lp = { manual_active: true, current_power_w: 0, manual: { active: true, requested_a: 16, commanded_a: 16 } }; +test('an accepted limit at zero watts never claims that the car is charging', () => { + const words = describeManual({ ...lp, manual: { ...lp.manual, state: 'accepted' } }); + assert.match(words, /Charger reports a 16 A limit/); + assert.match(words, /Waiting for the car/); + assert.doesNotMatch(words, /Charging at/); +}); +test('a stale report overrides previously positive charging power', () => { + const words = describeManual({ ...lp, current_power_w: 11000, manual: { ...lp.manual, state: 'unavailable' } }); + assert.match(words, /out of date/); + assert.doesNotMatch(words, /Charging at/); +}); +test('charging reported without a power reading stays explicit', () => { + const words = describeManual({ ...lp, manual: { ...lp.manual, state: 'charging' } }); + assert.match(words, /Waiting for a power reading/); + assert.doesNotMatch(words, /Charging at 0/); +}); +test('an estimated release target remains visible while waiting', () => { + const words = describeManual({ ...lp, manual_release_soc: 0.8, manual: { ...lp.manual, state: 'sent' } }); + assert.match(words, /estimated 80 % target/); + assert.doesNotMatch(words, /Charging at/); +}); diff --git a/web/ev-plan-status.test.mjs b/web/ev-plan-status.test.mjs index f10a9200..aaa59761 100644 --- a/web/ev-plan-status.test.mjs +++ b/web/ev-plan-status.test.mjs @@ -14,7 +14,7 @@ test('plan-status strip renders every visibility state', () => { assert.match(source, /plan_total_wh \/ 1000/); // Charger offering power the car does not take, with the charger's // own reason when the driver reports one. - assert.match(source, /but the car isn't drawing/); + assert.match(source, /Waiting for the car to draw power/); assert.match(source, /reason_no_current_label/); // The silent grid-plan deferral is named instead of looking like a // PV-only mode nobody chose. @@ -22,9 +22,9 @@ test('plan-status strip renders every visibility state', () => { assert.match(source, /grid_deferred/); // Manual hold names its cost: the plan is off until the car is full, // Stop or unplug. - assert.match(source, /plan and PV logic are off until the car is full, Stop or unplug/); + assert.match(source, /Continues until the car stops drawing/); // The do-nothing default is called out with the three ways out. - assert.match(source, /set a schedule, turn on PV only, or press Start/); + assert.match(source, /No charging plan yet/); }); test('plan-status strip is the headline of the plan view', () => { diff --git a/web/ev-plug-in-view.test.mjs b/web/ev-plug-in-view.test.mjs index 9f20a362..3f0c38ed 100644 --- a/web/ev-plug-in-view.test.mjs +++ b/web/ev-plug-in-view.test.mjs @@ -18,7 +18,7 @@ test('the plan view draws the planned windows on a 24 h track', () => { assert.match(view, /w\.start_ms/); assert.match(view, /w\.wh \/ 1000/); // A manual hold is explained instead of drawn as a plan. - assert.match(view, /Manual charge is running/); + assert.match(view, /Manual charge is selected/); }); test('the charge-level slider writes on release, with no button', () => { @@ -27,7 +27,7 @@ test('the charge-level slider writes on release, with no button', () => { assert.doesNotMatch(view, /Set current charge/); assert.doesNotMatch(view, /createElement\("button"\)/); // The refetch right after the write is what moves the plan on screen. - assert.match(view, /Plan updated from/); + assert.match(view, /Charge level saved:/); assert.match(view, /refreshEvModal\(\)/); // Polls do not snap the slider while the operator holds it. assert.match(view, /operatorHolds\(\)/); @@ -45,5 +45,5 @@ test('the SoC editor left the Scheduled tab', () => { test('the plan view is mounted once per loadpoint and updated on polls', () => { assert.match(source, /evPlanEl = buildEvPlanView\(matched, d\)/); assert.match(source, /evPlanLpId !== matched\.id/); - assert.match(source, /evModalBody\.insertBefore\(evPlanEl\.el, statusTableEl\.nextSibling\)/); + assert.match(source, /evModalBody\.insertBefore\(evPlanEl\.el, statusTableEl\)/); }); diff --git a/web/ev-schedule-autosave.test.mjs b/web/ev-schedule-autosave.test.mjs index 9dcab9fa..c54dbabd 100644 --- a/web/ev-schedule-autosave.test.mjs +++ b/web/ev-schedule-autosave.test.mjs @@ -22,7 +22,7 @@ test('every schedule control saves on change', () => { // view above moves with the schedule. assert.match(sched, /setTimeout\(function \(\) \{ saveTimer = null; doSave\(\); \}, 400\)/); assert.match(sched, /if \(seq !== saveSeq\) return;/); - assert.match(sched, /Saved · replanning…/); + assert.match(sched, /Schedule saved. Reading the plan…/); assert.match(sched, /refreshEvModal\(\)/); }); diff --git a/web/loadpoint-battery-boost.test.mjs b/web/loadpoint-battery-boost.test.mjs index 23718013..3890cdfc 100644 --- a/web/loadpoint-battery-boost.test.mjs +++ b/web/loadpoint-battery-boost.test.mjs @@ -44,7 +44,7 @@ test('every controller stop reason has words', () => { test('the boost view is mounted last in the modal and updated on polls', () => { assert.match(app, /evBoostEl = buildEvBoostView\(matched, status\)/); assert.match(app, /evBoostEl\.update\(matched, status\)/); - assert.match(app, /evModalBody\.lastChild !== evBoostEl\.el/); + assert.match(app, /evModalBody\.lastChild !== evBoostDetails/); }); test('the legacy site-wide cover left the modal but is never hidden while on', () => { diff --git a/web/settings/tabs/loadpoints.js b/web/settings/tabs/loadpoints.js index 01bb1bfc..494691db 100644 --- a/web/settings/tabs/loadpoints.js +++ b/web/settings/tabs/loadpoints.js @@ -164,6 +164,11 @@ return html; } + if (!status.enabled && config && !config.ocpp) { + return html + '

For chargers that connect directly to FTW using OCPP.

' + + ''; + } + if (!status.enabled) { html += '

' + @@ -262,8 +267,7 @@ '

' + 'Pending chargers are connected but not part of the site: FTW ignores their ' + 'telemetry and never commands them, so an unknown device cannot influence dispatch. ' + - 'To adopt one, add a charger entry below with its id as the charger driver and save ' + - '— it joins the site on that save.' + + 'To use one, choose it under Add charger. FTW saves and adds it to the site.' + '

'; } } @@ -366,6 +370,61 @@ }); } + var chargerSaveQueue = Promise.resolve(); + var chargerSaveText = ''; + var chargerSaveFailed = false; + function persistChargers(ctx) { + var snapshot = JSON.parse(JSON.stringify(ctx.config.loadpoints || [])); + var button = document.getElementById('settings-save'); + var status = document.getElementById('settings-status'); + function feedback(text, kind) { + chargerSaveText = text; + chargerSaveFailed = kind === 'error'; + var inline = document.getElementById('charger-save-status'); + if (inline) inline.textContent = text; + var retry = document.getElementById('charger-save-retry'); + if (retry) retry.hidden = !chargerSaveFailed; + var added = document.getElementById('new-lp-status'); + if (added && /^Adding /.test(added.textContent) && kind) added.textContent = text; + if (status) { + status.textContent = text; + status.className = 'settings-status' + (kind ? ' ' + kind : ''); + status.setAttribute('role', kind === 'error' ? 'alert' : 'status'); + } + } + if (button) button.disabled = true; + feedback('Applying charger settings…'); + var operation = chargerSaveQueue.catch(function () {}).then(function () { + // Read the current config so this save never commits another tab's draft. + return ctx.apiFetch('/api/config').then(function (r) { + if (!r.ok) throw new Error('Could not read current settings (HTTP ' + r.status + ')'); + return r.json(); + }).then(function (latest) { + latest.loadpoints = snapshot; + return ctx.apiFetch('/api/config', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(latest), + }); + }).then(function (r) { + return r.json().then(function (body) { + if (!r.ok) throw new Error(body.error || ('HTTP ' + r.status)); + return body; + }); + }); + }); + chargerSaveQueue = operation; + return operation.then(function (result) { + if (chargerSaveQueue !== operation) return; + if (button) button.disabled = false; + feedback(result && result.restart_required + ? 'Charger settings saved. FTW requires a restart to apply them.' + : 'Charger settings saved. No Save button needed.', 'success'); + }).catch(function (e) { + if (chargerSaveQueue !== operation) return; + if (button) button.disabled = false; + feedback('Charger settings not confirmed: ' + e.message + '. Try again when ready.', 'error'); + }); + } + S.tabs.loadpoints = { render: function (ctx) { var help = ctx.help, escHtml = ctx.escHtml, config = ctx.config; @@ -375,12 +434,14 @@ var html = '

' + - 'A charger entry binds an EV charger to the planner so it can schedule charging against your tariff + PV forecast. ' + - 'The power source is either a driver added under Devices, or an OCPP charge point from the list below — ' + - 'pick it here and set the electrical envelope. (Config files call this binding a loadpoint.)' + + 'Choose the charger FTW will control, then check its power limit and your car’s battery size. ' + + 'Charger settings apply as you change them. Set when to charge from the car on Overview.' + '

'; - html += ocppSection(S.ocppStatus, (window.location && window.location.hostname) || "", escHtml, config.vehicles, config, help); + html += '

' + escHtml(chargerSaveText) + '

' + + ''; + + var ocppHtml = ocppSection(S.ocppStatus, (window.location && window.location.hostname) || "", escHtml, config.vehicles, config, help); if (!drivers.length) { html += @@ -414,11 +475,11 @@ '
' + '
' + - '' + + '' + '' + '
' + '
' + - '' + + '' + '' + + '' + '
' + - '' + + '' + '' + '
' + - '' + + '' + + '

' + ''; - html += vehiclesSection(config, escHtml, help); + html += '
OCPP connection settings' + ocppHtml + '
'; + html += '
Cars that share a charger' + vehiclesSection(config, escHtml, help) + '
'; return html; }, @@ -487,6 +550,29 @@ after: function (ctx) { var bodyEl = ctx.bodyEl, config = ctx.config; + var configureOcpp = document.getElementById('configure-ocpp'); + if (configureOcpp) configureOcpp.addEventListener('click', function () { + ctx.captureCurrentTab(); + config.ocpp = { enabled: true, port: 8887, username: 'ftw', path: '/' }; + ctx.renderTab('loadpoints'); + var button = bodyEl.querySelector('[data-checkbox-path="ocpp.enabled"]'); + if (button) button.closest('details').open = true; + }); + + function refreshTab() { + var idInput = document.getElementById('new-lp-id'); + var driverInput = document.getElementById('new-lp-driver'); + if (!driverInput) return; + var name = idInput ? idInput.value : ''; + var driver = driverInput ? driverInput.value : ''; + ctx.captureCurrentTab(); + ctx.renderTab('loadpoints'); + var nextName = document.getElementById('new-lp-id'); + var nextDriver = document.getElementById('new-lp-driver'); + if (nextName) nextName.value = name; + if (nextDriver && driver) nextDriver.value = driver; + } + // Live OCPP view. Re-render only when the answer actually changed, // so the refetch on every tab open cannot loop. apiFetch('/api/ocpp/chargers') @@ -497,8 +583,7 @@ if (raw === S._ocppStatusRaw) return; S._ocppStatusRaw = raw; S.ocppStatus = data; - ctx.captureCurrentTab(); - ctx.renderTab('loadpoints'); + refreshTab(); }) .catch(function () { /* section keeps its last known state */ }); @@ -515,12 +600,20 @@ }); S.catalogByLua = byLua; // Re-render so driver dropdowns populate. - ctx.captureCurrentTab(); - ctx.renderTab('loadpoints'); + refreshTab(); }) .catch(function () { /* leave dropdowns empty; user can still type */ }); } + function applyChargers(skipCapture) { + if (skipCapture !== true) ctx.captureCurrentTab(); + (config.loadpoints || []).forEach(function (lp) { delete lp.allowed_steps_w__str; }); + persistChargers(ctx); + } + + var retry = document.getElementById('charger-save-retry'); + if (retry) retry.addEventListener('click', applyChargers); + // Remove handlers. bodyEl.querySelectorAll('[data-action="remove-lp"]').forEach(function (btn) { btn.addEventListener('click', function () { @@ -528,6 +621,7 @@ if (!isFinite(idx)) return; ctx.captureCurrentTab(); config.loadpoints.splice(idx, 1); + applyChargers(true); ctx.renderTab('loadpoints'); }); }); @@ -540,11 +634,23 @@ var drvEl = document.getElementById('new-lp-driver'); var id = (idEl && idEl.value || '').trim(); var drv = (drvEl && drvEl.value || '').trim(); - if (!id) { idEl && idEl.focus(); return; } + var message = document.getElementById('new-lp-status'); + if (!drv) { + if (message) message.textContent = 'Choose a charger first. If it is missing, add it under Devices.'; + if (drvEl) drvEl.focus(); + return; + } + if (!id) id = drv; + var bound = (config.loadpoints || []).some(function (lp) { return lp.driver_name === drv; }); + if (bound) { + if (message) message.textContent = 'This charger is already in the list above.'; + return; + } // Reject duplicates — the controller treats id as the join key. var exists = (config.loadpoints || []).some(function (lp) { return lp.id === id; }); if (exists) { - alert('A charger with id "' + id + '" already exists.'); + if (message) message.textContent = 'That name is already in use. Choose another name.'; + if (idEl) idEl.focus(); return; } ctx.captureCurrentTab(); @@ -558,7 +664,12 @@ phase_mode: '3p', allowed_steps_w: [], }); + applyChargers(true); ctx.renderTab('loadpoints'); + var added = document.getElementById('new-lp-status'); + if (added) added.textContent = 'Adding ' + id + ' to FTW… Check its power limit and battery size above.'; + var card = bodyEl.querySelector('[data-action="remove-lp"][data-idx="' + (config.loadpoints.length - 1) + '"]'); + if (card) card.closest('fieldset').scrollIntoView({ block: 'nearest' }); }); } @@ -577,7 +688,7 @@ addVehicleBtn.addEventListener('click', function () { var idEl = document.getElementById('new-vehicle-id'); var id = (idEl && idEl.value || '').trim(); - if (!id) { idEl && idEl.focus(); return; } + if (!id) { if (idEl) idEl.focus(); return; } var exists = (config.vehicles || []).some(function (v) { return v.id === id; }); if (exists) { alert('A vehicle with id "' + id + '" already exists.'); @@ -627,6 +738,9 @@ inp.dispatchEvent(new Event('change')); }); }); + bodyEl.querySelectorAll('[data-path^="loadpoints."]').forEach(function (input) { + input.addEventListener('change', applyChargers); + }); }, // Pure helpers exposed for node tests. diff --git a/web/settings/tabs/loadpoints.test.mjs b/web/settings/tabs/loadpoints.test.mjs index ab2ecf95..bf4247f4 100644 --- a/web/settings/tabs/loadpoints.test.mjs +++ b/web/settings/tabs/loadpoints.test.mjs @@ -128,9 +128,9 @@ describe("charger state labels", () => { describe("OCPP section", () => { it("lets the operator turn a disabled server on, rather than sending them to config.yaml", () => { const html = ocppSection({ enabled: false, chargers: [] }, "192.168.1.209", escHtml, [], {}); - assert.match(html, /data-checkbox-path="ocpp\.enabled"/); - assert.match(html, /data-path="ocpp\.password"/); - assert.match(html, /ws:\/\/192\.168\.1\.209:8887/); + assert.match(html, /Set up OCPP/); + assert.doesNotMatch(html, /data-path="ocpp\.password"/); + assert.doesNotMatch(html, /data-checkbox-path="ocpp\.enabled"/); }); it("offers the server settings when it is already on", () => { @@ -159,7 +159,7 @@ describe("OCPP section", () => { assert.match(html, /intruder/); assert.match(html, /· pending/); assert.match(html, /ignores their telemetry/); - assert.match(html, /joins the site on that save/); + assert.match(html, /FTW saves and adds it to the site/); }); it("shows no quarantine note when every charger is adopted", () => { From 6ee9212ccfe274cd23622a895b7447c6af86a018 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Sun, 6 Sep 2026 10:29:52 +0200 Subject: [PATCH 07/57] Keep EV goals and solar rules in one session view --- .changeset/ev-feedback-and-direct-controls.md | 2 + web/app.js | 366 ++++++++---------- web/ev-manual-feedback.test.mjs | 6 +- web/ev-plug-in-view.test.mjs | 8 +- web/ev-schedule-autosave.test.mjs | 6 +- 5 files changed, 180 insertions(+), 208 deletions(-) diff --git a/.changeset/ev-feedback-and-direct-controls.md b/.changeset/ev-feedback-and-direct-controls.md index d4202954..a7a0305b 100644 --- a/.changeset/ev-feedback-and-direct-controls.md +++ b/.changeset/ev-feedback-and-direct-controls.md @@ -5,3 +5,5 @@ Add a charger without inventing an ID or pressing Save again. Charger settings apply on change, with errors and a retry beside the form. OCPP setup stays separate from cloud chargers. Charging feedback separates the FTW request, the charger's reported limit and measured power. Old charger readings cannot claim current charging. Manual current changes apply on release; Return to plan names the action that ends a manual hold. Charge level and schedule writes run in order, and failed requests stay visible. + +Keep the charging goal and solar rule together, with no mode tabs. Show when Charge now overrides them. Show the current slider only while manual charging is active. Opening goal settings does not send a command. diff --git a/web/app.js b/web/app.js index 6d3274f6..8ab48f33 100644 --- a/web/app.js +++ b/web/app.js @@ -2733,7 +2733,7 @@ : "Waiting for the charger's first status report."; tone = "var(--text)"; } else if (lp.manual_active) { - // The same sentence as the Manual tab, so the charger's own reason is + // The same sentence as the charge controls, so the charger's own reason is // never hidden behind "manual charge is running". text = manualStatusText(lp, d) || "Manual charge requested. Waiting for charger status."; if (lp.manual && (lp.manual.state === "not_drawing" || lp.manual.state === "stalled")) { @@ -2776,7 +2776,7 @@ } else if (lp.surplus_only) { text = "PV surplus only — charges when solar exceeds house load."; } else if (!hasSchedule) { - text = "No charging plan yet. Choose Scheduled to set a ready time, or Manual to charge now."; + text = "No charging plan yet. Set a ready time, or choose Charge now."; tone = "var(--text)"; } else { text = "No charge window in the current plan — the target may already be reached."; @@ -2792,25 +2792,17 @@ return p; } - // EV modal sub-elements held across refreshes. The status table is - // updated in place on every poll. The tabbed control (PV charging / - // Manual / Scheduled) is mounted exactly once per (modal-open × LP) - // and is NEVER detached on a poll — detaching+reattaching a focused - // blurs it mid-keystroke and resets caret position. After a - // Save/Clear/Start/Stop we set the matching *NeedsRebuild flag so the - // next poll rebuilds from the new authoritative server state. The - // active tab persists across rebuilds via evActiveTab. + // Keep controls mounted while polling so editing never loses focus. var statusTableEl = null; var evPlanEl = null; // { el, update } from buildEvPlanView var evPlanLpId = null; - var evTabsEl = null; - var evTabsLpId = null; + var evControlsEl = null; + var evControlsLpId = null; var evBoostEl = null; // { el, update } from buildEvBoostView var evBoostLpId = null; var evBoostDetails = null; var schedNeedsRebuild = false; var manualNeedsRebuild = false; - var evActiveTab = "manual"; // "pv" | "manual" | "scheduled" // Detect whether the site has any PV driver configured. Used to hide // the "surplus charge from PV" option on PV-less sites where the @@ -2827,9 +2819,10 @@ } var evReading = false; + var evReadPromise = null; var evLastLp = null; function refreshEvModal() { - if (evReading) return; + if (evReading) return evReadPromise; evReading = true; var requestedDriver = evModalDriver; // Pass driver query if known so the backend can scope the response @@ -2840,7 +2833,7 @@ // recent payload cached by fetchStatus() instead of issuing a // duplicate /api/status fetch on every 5 s modal tick. Falls back // to "no PV" until the dashboard's own fetchStatus lands once. - Promise.all([ + evReadPromise = Promise.all([ // Local API reads. boundedApiRead(url, function (r) { if (!r.ok) throw new Error("Charger status unavailable"); return r.json(); }), boundedApiRead("/api/loadpoints", function (r) { if (!r.ok) throw new Error("Charging settings unavailable"); return r.json(); }), @@ -2867,8 +2860,8 @@ evPlanLpId = null; evBoostEl = null; evBoostLpId = null; - evTabsEl = null; - evTabsLpId = null; + evControlsEl = null; + evControlsLpId = null; return; } // Status table: replace in place so the rest of the modal body @@ -2978,40 +2971,37 @@ evPlanLpId = null; } if (matched) { - // Build the tabbed control (PV charging / Manual / Scheduled) - // exactly once per LP. Polling never rebuilds it — inputs keep - // focus/value/caret and the active tab persists. Only a - // Save/Clear (schedNeedsRebuild) or Start/Stop/Set-SoC - // (manualNeedsRebuild), or switching LP (planet), forces a build. - var lpChanged = evTabsEl == null || evTabsLpId !== matched.id; + // Rebuild only for a changed charger or a removed goal. + var lpChanged = evControlsEl == null || evControlsLpId !== matched.id; if (lpChanged || schedNeedsRebuild || manualNeedsRebuild) { - if (evTabsEl && evTabsEl.parentNode === evModalBody) { - evModalBody.removeChild(evTabsEl); + if (evControlsEl && evControlsEl.parentNode === evModalBody) { + evModalBody.removeChild(evControlsEl); } - evTabsEl = buildEvTabbedControl(matched, siteHasPV(status)); - evTabsLpId = matched.id; + evControlsEl = buildEvControls(matched, siteHasPV(status)); + evControlsLpId = matched.id; schedNeedsRebuild = false; manualNeedsRebuild = false; - evModalBody.appendChild(evTabsEl); - } else if (evTabsEl.parentNode !== evModalBody) { + evModalBody.appendChild(evControlsEl); + } else if (evControlsEl.parentNode !== evModalBody) { // Modal was previously closed: body got wiped but our cached // section is still valid — re-attach. - evModalBody.appendChild(evTabsEl); + evModalBody.appendChild(evControlsEl); } } else { - if (evTabsEl && evTabsEl.parentNode === evModalBody) { - evModalBody.removeChild(evTabsEl); + if (evControlsEl && evControlsEl.parentNode === evModalBody) { + evModalBody.removeChild(evControlsEl); } - evTabsEl = null; - evTabsLpId = null; + evControlsEl = null; + evControlsLpId = null; } - if (matched && evTabsEl && typeof evTabsEl.update === "function") { - evTabsEl.update(matched, d); + if (matched && evControlsEl && statusTableEl) evModalBody.appendChild(statusTableEl); + if (matched && evControlsEl && typeof evControlsEl.update === "function") { + evControlsEl.update(matched, d); } - // Boost from the home battery: one control, below the tabs, for + // Boost from the home battery: one control, below the goal, for // whichever mode the loadpoint is in. Mounted once per loadpoint // (the reserve slider keeps its value); update() redraws state - // every poll. Always kept last so a tabs rebuild cannot land + // every poll. Always kept last so a controls rebuild cannot land // underneath it. if (matched) { if (evBoostEl == null || evBoostLpId !== matched.id) { @@ -3053,10 +3043,16 @@ charger: { known: true, available: false }, manual: Object.assign({}, evLastLp.manual, { state: "unavailable" }), }); - if (evTabsEl && evTabsEl.update) evTabsEl.update(stale, null); + if (evControlsEl && evControlsEl.update) evControlsEl.update(stale, null); if (evPlanEl) evPlanEl.update(stale, null); } }).finally(function () { evReading = false; }); + return evReadPromise; + } + + function refreshEvModalAfterWrite() { + if (evReading) return evReadPromise.then(function () { return refreshEvModal(); }); + return refreshEvModal(); } // Stop reasons the controller reports for a battery boost lease, in @@ -3237,7 +3233,7 @@ }).then(function (res) { return res.json().then(function (j) { return { ok: res.ok, body: j }; }); }) .then(function (res) { startBtn.disabled = false; - if (res.ok) { msg.textContent = ""; refreshEvModal(); } + if (res.ok) { msg.textContent = ""; refreshEvModalAfterWrite(); } else { msg.textContent = (res.body && res.body.error) || "Boost could not start."; } }).catch(function (e) { startBtn.disabled = false; msg.textContent = "Boost could not start: " + e.message; }); }); @@ -3248,7 +3244,7 @@ evWrite("/api/loadpoints/" + encodeURIComponent(lp.id) + "/battery_boost", { method: "DELETE" }) .then(function (res) { stopBtn.disabled = false; - if (res.ok) { msg.textContent = ""; refreshEvModal(); } + if (res.ok) { msg.textContent = ""; refreshEvModalAfterWrite(); } else { msg.textContent = "Boost could not be stopped."; } }).catch(function (e) { stopBtn.disabled = false; msg.textContent = "Boost could not be stopped: " + e.message; }); }); @@ -3325,7 +3321,8 @@ box.style.borderTop = "1px solid var(--line)"; var eyebrow = document.createElement("div"); - eyebrow.textContent = "Manual Charge"; + eyebrow.textContent = "Charge now is active"; + eyebrow.hidden = !active; eyebrow.style.fontFamily = "var(--mono)"; eyebrow.style.fontSize = "0.7rem"; eyebrow.style.letterSpacing = "0.18em"; @@ -3361,10 +3358,13 @@ readout.textContent = a + " A · " + (aToW(a) / 1000).toFixed(1) + " kW"; } renderReadout(); - slider.addEventListener("input", renderReadout); + var currentEditingUntil = 0; + slider.addEventListener("input", function () { currentEditingUntil = Date.now() + 1500; renderReadout(); }); row.appendChild(slider); row.appendChild(readout); + row.hidden = !active; + row.style.display = active ? "flex" : "none"; box.appendChild(row); // Status line. @@ -3375,7 +3375,7 @@ status.style.minHeight = "1em"; status.setAttribute("role", "status"); status.setAttribute("aria-live", "polite"); - var idleText = "Requests this current now. Return to plan restores your schedule and solar settings."; + var idleText = "Starts at up to " + maxA + " A · " + (aToW(maxA) / 1000).toFixed(1) + " kW. Ignores the goal and solar rule until you return to the plan or unplug."; status.textContent = active ? "Changes apply when you release the slider." : idleText; box.appendChild(status); @@ -3408,6 +3408,7 @@ stopBtn.style.cursor = "pointer"; stopBtn.style.background = "transparent"; stopBtn.style.color = "var(--fg)"; + stopBtn.hidden = !active; stopBtn.disabled = !active; stopBtn.style.opacity = active ? "1" : "0.5"; @@ -3426,9 +3427,14 @@ function renderStatus() { var on = !!(lastLp && lastLp.manual_active); if (!busy) { + stopBtn.hidden = !on; stopBtn.disabled = !on; + eyebrow.hidden = !on; + row.hidden = !on; + row.style.display = on ? "flex" : "none"; stopBtn.style.opacity = on ? "1" : "0.5"; startBtn.hidden = on; + if (!on) { slider.value = String(maxA); renderReadout(); } startBtn.disabled = false; } if (busy || Date.now() < holdLineUntil) return; @@ -3437,6 +3443,10 @@ function update(nextLp, d) { if (nextLp) lastLp = nextLp; if (d) lastD = d; + if (!busy && Date.now() >= currentEditingUntil && lastLp.manual_active) { + slider.value = String(Math.min(maxA, Math.max(minA, Math.round(wToA(lastLp.manual_charge_w || 0))))); + renderReadout(); + } renderStatus(); } @@ -3472,7 +3482,7 @@ slider.disabled = false; status.textContent = "FTW received " + a + " A. Waiting for the charger…"; holdLineUntil = Date.now() + 6000; - refreshEvModal(); // read the hold and charger response without rebuilding focused inputs + refreshEvModalAfterWrite(); // read the hold and charger response without rebuilding focused inputs }).catch(function (e) { busy = false; slider.disabled = false; @@ -3501,7 +3511,7 @@ startBtn.disabled = false; status.textContent = "Manual charge ended. The plan decides when to charge."; holdLineUntil = Date.now() + 6000; - refreshEvModal(); + refreshEvModalAfterWrite(); }).catch(function (e) { busy = false; slider.disabled = false; @@ -3558,7 +3568,7 @@ return s; } - // buildEvPlanView — the plug-in moment. Above the tabs, the operator + // buildEvPlanView — the plug-in moment. Above the controls, the operator // sees what the box will do with this car: the plan-status sentence, // the planned charge windows drawn on a 24 h track, and right under // it the car's CURRENT charge, which is the input the plan is built @@ -3607,7 +3617,7 @@ // Car's current charge. var socWrap = document.createElement("div"); socWrap.style.marginTop = "0.75rem"; - var hdr = sliderHeader("Car is at", "—"); + var hdr = sliderHeader("Battery now", "—"); socWrap.appendChild(hdr.row); var slider = fullWidthSlider(50, hdr.value); slider.setAttribute("aria-label", "Car's current charge, percent"); @@ -3663,9 +3673,9 @@ if (revision !== socRevision) return; note.textContent = "Charge level saved: " + v + " %." + (!(lastLp.schedule && lastLp.schedule.soc > 0) && !lastLp.manual_active && !lastLp.surplus_only - ? " Choose Scheduled or Manual to start charging." : " Reading the updated plan…"); + ? " Set a ready time, or choose Charge now." : " Reading the updated plan…"); noteTimer = setTimeout(function () { noteTimer = null; if (!socFailed) note.textContent = sourceNote(lastLp); }, 6000); - refreshEvModal(); + refreshEvModalAfterWrite(); }).catch(function (e) { if (revision === socRevision) { socFailed = true; note.textContent = "Charge level not confirmed: " + e.message; } }).finally(function () { @@ -3758,82 +3768,63 @@ return { el: box, update: update, slider: slider }; } - // buildPVModeSection — the per-loadpoint surplus-only toggle (PV - // charging tab). A hard flag, *independent* of any schedule: when on, - // dispatch refuses to import grid for this loadpoint regardless of what - // the MPC plans. Operators can run with this alone ("harvest PV when - // there's enough") or layer a schedule on top. Saves on click. + // Solar is a rule of the plan. A manual hold overrides it. function buildPVModeSection(lp) { var soBox = document.createElement("div"); - soBox.style.marginTop = "0.25rem"; - - var soEyebrow = document.createElement("div"); - soEyebrow.textContent = "PV Mode"; - soEyebrow.style.fontFamily = "var(--mono)"; - soEyebrow.style.fontSize = "0.7rem"; - soEyebrow.style.letterSpacing = "0.18em"; - soEyebrow.style.textTransform = "uppercase"; - soEyebrow.style.color = "var(--text-dim)"; - soEyebrow.style.marginBottom = "0.45rem"; - soBox.appendChild(soEyebrow); - + soBox.style.marginTop = "0.7rem"; var soWrap = document.createElement("label"); - soWrap.style.display = "flex"; - soWrap.style.alignItems = "center"; - soWrap.style.gap = "0.4rem"; - soWrap.style.fontSize = "0.85rem"; - soWrap.style.cursor = "pointer"; + soWrap.style.cssText = "display:flex;align-items:center;gap:0.5rem;font-size:0.85rem;cursor:pointer"; var soCb = document.createElement("input"); soCb.type = "checkbox"; - soCb.checked = !!(lp && lp.surplus_only); + soCb.setAttribute("role", "switch"); soCb.style.accentColor = "var(--accent-e)"; var soText = document.createElement("span"); - soText.textContent = "Surplus only (PV only — no grid or battery)"; + soText.textContent = "Only spare solar"; soWrap.appendChild(soCb); soWrap.appendChild(soText); - var soStatus = document.createElement("small"); - soStatus.style.display = "block"; - soStatus.style.color = "var(--text-dim)"; - soStatus.style.marginTop = "0.25rem"; - soStatus.style.marginLeft = "1.4rem"; - soStatus.style.minHeight = "1em"; - soStatus.textContent = "Saves automatically on click. Independent of the schedule below."; - - soBox.appendChild(soWrap); - soBox.appendChild(soStatus); - + soStatus.style.cssText = "display:block;color:var(--text-dim);margin:0.25rem 0 0 1.5rem"; + soStatus.setAttribute("role", "status"); + var busy = false, failure = "", latest = lp; + function update(next) { + latest = next; + if (busy) return; + soCb.checked = !!latest.surplus_only; + soCb.disabled = !!latest.manual_active; + soStatus.textContent = failure || (latest.manual_active + ? "Charge now overrides this rule. It resumes when you return to the plan." + : latest.surplus_only + ? "No grid or home battery. Your target may not be reached in time." + : "The plan may use grid power to reach your target."); + } soCb.addEventListener("change", function () { - // Surface the surplus-only ↔ schedule interaction immediately on - // toggle, before the network save returns — operators get instant - // feedback that flipping surplus on turns the deadline soft. - if (typeof surplusBestEffortHint !== "undefined" && surplusBestEffortHint) { - surplusBestEffortHint.style.display = soCb.checked ? "" : "none"; - } + var on = soCb.checked; + busy = true; + failure = ""; soCb.disabled = true; - soStatus.textContent = "Saving…"; - // CONTROL write — strict (FIX-B): loadpoint surplus-only toggle. + soStatus.textContent = "Applying solar rule…"; evWrite("/api/loadpoints/" + encodeURIComponent(lp.id) + "/target", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ surplus_only: soCb.checked }), - }).then(function () { - soCb.disabled = false; - soStatus.textContent = "Saved. Independent of the schedule below."; - // Rebuild on next poll so the schedule section reflects any - // server-side side effects (e.g. soc_source recompute). - schedNeedsRebuild = true; - }).catch(function () { - soCb.disabled = false; - soStatus.textContent = "Save failed — try again."; - }); + body: JSON.stringify({ surplus_only: on }), + }).then(function (r) { + if (!r.ok) return r.json().then(function (j) { throw new Error(j.error || ("HTTP " + r.status)); }); + soStatus.textContent = "Solar rule saved. Reading the plan…"; + // Keep it pending until the read finishes; a poll cannot undo the choice. + return refreshEvModalAfterWrite(); + }).catch(function (e) { + failure = "Solar rule not confirmed: " + e.message; + }).finally(function () { busy = false; update(latest); }); }); - + soBox.appendChild(soWrap); + soBox.appendChild(soStatus); + soBox.update = update; + update(lp); return soBox; } // buildScheduleSection — target SoC by a deadline + recurring + the - // bat-SoC surplus-unlock threshold (Scheduled tab). Persisted across + // home battery threshold for spare solar. Persisted across // restarts; the backend rolls the deadline forward daily when Recurring // is set and arms the surplus-grab when the home battery is at/above the // threshold (5 pp release hysteresis). @@ -3843,7 +3834,7 @@ // local zone. The UI shows local time everywhere; we marshal back to // UTC minutes on save. var hasSched = !!(sched.soc || sched.recurring || sched.surplus_unlock_bat_soc); - var initLocalHHMM = utcMinsToLocalHHMM(typeof sched.time_of_day_min_utc === "number" ? sched.time_of_day_min_utc : 360); + var initLocalHHMM = hasSched ? utcMinsToLocalHHMM(typeof sched.time_of_day_min_utc === "number" ? sched.time_of_day_min_utc : 360) : "07:00"; var initSoC = typeof sched.soc === "number" && sched.soc > 0 ? sched.soc * 100 : 80; var initRec = !!sched.recurring; var savedUnlock = typeof sched.surplus_unlock_bat_soc === "number" ? sched.surplus_unlock_bat_soc * 100 : 0; @@ -3853,10 +3844,7 @@ var initSurplus = savedUnlock > 0; var initUnlock = savedUnlock > 0 ? savedUnlock : 50; - // Hairline divider separating the current-charge slider above from the - // target + deadline controls below. The tab is already named - // "Scheduled", so no section eyebrow/explainer here — the field labels - // carry the meaning. + // The goal editor opens below the saved goal summary. var box = document.createElement("div"); box.style.marginTop = "0.9rem"; box.style.paddingTop = "0.9rem"; @@ -3891,7 +3879,7 @@ // above, so toggling it gives instant feedback without waiting on // the network save round-trip. var surplusBestEffortHint = document.createElement("div"); - surplusBestEffortHint.textContent = "Surplus only is on — the deadline becomes best-effort from real PV surplus only. Turn it off to let the planner grid-charge if PV can't cover."; + surplusBestEffortHint.textContent = "Only spare solar is on. Your target may not be reached in time."; surplusBestEffortHint.style.fontSize = "0.72rem"; surplusBestEffortHint.style.color = "var(--fg)"; surplusBestEffortHint.style.fontStyle = "italic"; @@ -3982,8 +3970,8 @@ return wrap; } - var recWrap = checkbox(initRec, "Repeat daily"); - var surWrap = checkbox(initSurplus && !!hasPV, "Also charge from PV surplus"); + var recWrap = checkbox(initRec, "Repeat on chosen days"); + var surWrap = checkbox(initSurplus && !!hasPV, "Also use spare solar before the planned hours"); var recCb = recWrap.input; var surCb = surWrap.input; @@ -4031,13 +4019,15 @@ paintChips(); // Target: same header + full-width slider treatment as the car's - // current charge above the tabs. - var targetHdr = sliderHeader("Target", Math.max(0, Math.min(100, Math.round(initSoC))) + "%"); + // current charge above the goal. + var targetHdr = sliderHeader("Charge to", Math.max(0, Math.min(100, Math.round(initSoC))) + "%"); box.appendChild(targetHdr.row); var targetSlider = fullWidthSlider(Math.max(0, Math.min(100, Math.round(initSoC))), targetHdr.value); + targetSlider.min = "10"; + targetSlider.step = "5"; targetSlider.setAttribute("aria-label", "Target charge, percent"); box.appendChild(targetSlider); - box.appendChild(row("Charge by", timeInp)); + box.appendChild(row("Ready by", timeInp)); var checkRow = document.createElement("div"); checkRow.style.display = "flex"; @@ -4047,11 +4037,17 @@ checkRow.appendChild(recWrap); box.appendChild(checkRow); box.appendChild(daysRow); + var solarDetails = document.createElement("details"); + var solarSummary = document.createElement("summary"); + solarSummary.textContent = "Solar timing"; + solarSummary.style.cssText = "cursor:pointer;font-size:0.8rem;color:var(--text-dim)"; + solarDetails.appendChild(solarSummary); + if (hasPV) box.appendChild(solarDetails); // Surplus-from-PV only makes sense on sites with a PV driver — the // bat-SoC unlock would have no surplus to grab otherwise. Omit the // checkbox + threshold entirely on PV-less sites. if (hasPV) { - checkRow.appendChild(surWrap); + solarDetails.appendChild(surWrap); } var unlockHint = document.createElement("small"); @@ -4064,8 +4060,8 @@ var thresholdRow = row("Home battery ≥", unlockWrap); if (hasPV) { - box.appendChild(unlockHint); - box.appendChild(thresholdRow); + solarDetails.appendChild(unlockHint); + solarDetails.appendChild(thresholdRow); } function applySurplusGate() { @@ -4180,7 +4176,7 @@ hasSched = true; paintClear(); status.textContent = "Schedule saved. Reading the plan…"; - refreshEvModal(); + refreshEvModalAfterWrite(); }).catch(function (e) { if (seq === saveSeq) status.textContent = "Schedule not confirmed: " + e.message; }); @@ -4212,7 +4208,7 @@ if (!r.ok) return r.json().then(function (j) { throw new Error(j.error || ("HTTP " + r.status)); }); status.textContent = "Schedule removed."; schedNeedsRebuild = true; - refreshEvModal(); + refreshEvModalAfterWrite(); }).catch(function (e) { status.textContent = "Removal not confirmed: " + e.message; clearBtn.disabled = false; @@ -4220,87 +4216,61 @@ }); }); + box.update = function (nextLp) { + surplusBestEffortHint.style.display = nextLp.surplus_only && !nextLp.manual_active ? "" : "none"; + }; return box; } - // buildEvTabbedControl assembles the modal's three tabs and routes each - // section into the right one: - // PV charging → surplus-only toggle - // Manual → amp slider + Start/Stop - // Scheduled → the target-SoC-by-deadline schedule (the car's - // current charge lives above the tabs, with the plan) - // The active tab persists across rebuilds via evActiveTab. - function buildEvTabbedControl(lp, hasPV) { + // One session view. Opening settings never changes the charging mode. + function buildEvControls(lp, hasPV) { var container = document.createElement("div"); - container.style.marginTop = "0.75rem"; - container.style.paddingTop = "0.6rem"; - container.style.borderTop = "1px solid var(--line)"; - - var tabBar = document.createElement("div"); - tabBar.style.display = "flex"; - tabBar.style.gap = "0.15rem"; - tabBar.style.borderBottom = "1px solid var(--line)"; - tabBar.style.marginBottom = "0.7rem"; - - var pvPanel = document.createElement("div"); - pvPanel.appendChild(buildPVModeSection(lp)); - - var manualPanel = document.createElement("div"); + container.className = "ev-controls"; var manual = buildManualChargeSection(lp); - manualPanel.appendChild(manual.el); - - var schedPanel = document.createElement("div"); - schedPanel.appendChild(buildScheduleSection(lp, hasPV)); - - var panels = { pv: pvPanel, manual: manualPanel, scheduled: schedPanel }; - var tabs = [ - { id: "pv", label: "PV charging" }, - { id: "manual", label: "Manual" }, - { id: "scheduled", label: "Scheduled" }, - ]; - var tabBtns = {}; - - function selectTab(id) { - if (!panels[id]) { id = "pv"; } - evActiveTab = id; - for (var k in panels) { panels[k].style.display = (k === id) ? "" : "none"; } - tabs.forEach(function (t) { - var on = t.id === id; - var b = tabBtns[t.id]; - b.style.color = on ? "var(--fg)" : "var(--text-dim)"; - b.style.borderBottom = on ? "2px solid var(--accent-e)" : "2px solid transparent"; - b.style.fontWeight = on ? "600" : "400"; - }); - } - - tabs.forEach(function (t) { - var b = document.createElement("button"); - b.type = "button"; - b.textContent = t.label; - b.style.background = "transparent"; - b.style.border = "none"; - b.style.borderBottom = "2px solid transparent"; - b.style.padding = "0.4rem 0.7rem"; - b.style.marginBottom = "-1px"; - b.style.cursor = "pointer"; - b.style.fontFamily = "var(--mono)"; - b.style.fontSize = "0.72rem"; - b.style.letterSpacing = "0.08em"; - b.style.textTransform = "uppercase"; - b.addEventListener("click", function () { selectTab(t.id); }); - tabBtns[t.id] = b; - tabBar.appendChild(b); - }); - - container.appendChild(tabBar); - container.appendChild(pvPanel); - container.appendChild(manualPanel); - container.appendChild(schedPanel); - - // Polls redraw the live parts of the panels without remounting them. - container.update = function (nextLp, d) { manual.update(nextLp, d); }; - - selectTab(evActiveTab); + container.appendChild(manual.el); + + var goal = document.createElement("section"); + goal.className = "ev-goal"; + goal.style.cssText = "margin-top:1rem;padding:0.8rem;border:1px solid var(--line);border-radius:8px"; + var heading = document.createElement("div"); + heading.textContent = "Your goal"; + heading.style.cssText = "font-weight:600;font-size:0.95rem;margin-bottom:0.3rem"; + goal.appendChild(heading); + var summary = document.createElement("p"); + summary.style.cssText = "margin:0;font-size:0.9rem"; + goal.appendChild(summary); + var suspended = document.createElement("small"); + suspended.style.cssText = "display:block;color:var(--text-dim);margin-top:0.3rem"; + goal.appendChild(suspended); + + var editor = document.createElement("details"); + var editLabel = document.createElement("summary"); + editLabel.style.cssText = "cursor:pointer;color:var(--accent-e);font-size:0.85rem;margin-top:0.65rem"; + editor.appendChild(editLabel); + var schedule = buildScheduleSection(lp, hasPV); + editor.appendChild(schedule); + goal.appendChild(editor); + var solar = hasPV || lp.surplus_only ? buildPVModeSection(lp) : null; + if (solar) goal.appendChild(solar); + container.appendChild(goal); + + container.update = function (nextLp, d) { + manual.el.hidden = !nextLp.plugged_in; + manual.update(nextLp, d); + var s = nextLp.schedule; + var hasGoal = !!(s && s.soc > 0); + summary.textContent = hasGoal + ? Math.round(s.soc * 100) + " % by " + utcMinsToLocalHHMM(s.time_of_day_min_utc) + + (s.recurring ? " · repeats" : " · once") + : "No ready time set."; + editLabel.textContent = hasGoal ? "Change goal" : "Set a ready time"; + suspended.textContent = nextLp.manual_active + ? "Charge now overrides this goal. Edits apply when you return to the plan." + : "Changes apply as you make them."; + schedule.update(nextLp); + if (solar) solar.update(nextLp); + }; + container.update(lp, null); return container; } diff --git a/web/ev-manual-feedback.test.mjs b/web/ev-manual-feedback.test.mjs index d4fa8bc7..5aaea895 100644 --- a/web/ev-manual-feedback.test.mjs +++ b/web/ev-manual-feedback.test.mjs @@ -32,10 +32,10 @@ test('the status line follows the charger through every state', () => { assert.match(statusText, /m\.since_ms/); }); -test('the manual tab is redrawn on every poll', () => { +test('the manual controls follow every poll', () => { assert.match(manual, /return \{ el: box, update: update \};/); - assert.match(source, /container\.update = function \(nextLp, d\) \{ manual\.update\(nextLp, d\); \};/); - assert.match(source, /evTabsEl\.update\(matched, d\);/); + assert.match(source, /manual\.update\(nextLp, d\);/); + assert.match(source, /evControlsEl\.update\(matched, d\);/); }); test("a refused Start reads as a failure with the server's reason", () => { diff --git a/web/ev-plug-in-view.test.mjs b/web/ev-plug-in-view.test.mjs index 3f0c38ed..670236d7 100644 --- a/web/ev-plug-in-view.test.mjs +++ b/web/ev-plug-in-view.test.mjs @@ -28,18 +28,18 @@ test('the charge-level slider writes on release, with no button', () => { assert.doesNotMatch(view, /createElement\("button"\)/); // The refetch right after the write is what moves the plan on screen. assert.match(view, /Charge level saved:/); - assert.match(view, /refreshEvModal\(\)/); + assert.match(view, /refreshEvModalAfterWrite\(\)/); // Polls do not snap the slider while the operator holds it. assert.match(view, /operatorHolds\(\)/); }); -test('the SoC editor left the Scheduled tab', () => { +test('the battery estimate stays separate from goal controls', () => { assert.doesNotMatch(source, /buildSoCSection/); const tabs = source.slice( - source.indexOf('function buildEvTabbedControl'), + source.indexOf('function buildEvControls'), source.indexOf('function utcMinsToLocalHHMM'), ); - assert.doesNotMatch(tabs, /soc/i); + assert.doesNotMatch(tabs, /Car is at|Car\'s current charge/); }); test('the plan view is mounted once per loadpoint and updated on polls', () => { diff --git a/web/ev-schedule-autosave.test.mjs b/web/ev-schedule-autosave.test.mjs index c54dbabd..8a27abbb 100644 --- a/web/ev-schedule-autosave.test.mjs +++ b/web/ev-schedule-autosave.test.mjs @@ -5,10 +5,10 @@ import test from 'node:test'; const source = readFileSync(new URL('./app.js', import.meta.url), 'utf8'); const sched = source.slice( source.indexOf('function buildScheduleSection'), - source.indexOf('function buildEvTabbedControl'), + source.indexOf('function buildEvControls'), ); -// The Scheduled tab is direct manipulation (#1065): every control writes +// The goal editor is direct manipulation (#1065): every control writes // when it changes and the plan view above redraws. No Save button. test('every schedule control saves on change', () => { @@ -23,7 +23,7 @@ test('every schedule control saves on change', () => { assert.match(sched, /setTimeout\(function \(\) \{ saveTimer = null; doSave\(\); \}, 400\)/); assert.match(sched, /if \(seq !== saveSeq\) return;/); assert.match(sched, /Schedule saved. Reading the plan…/); - assert.match(sched, /refreshEvModal\(\)/); + assert.match(sched, /refreshEvModalAfterWrite\(\)/); }); test('weekday chips speak the wire: bit 0 = Monday, all seven = zero', () => { From c459d15e34e6c456ae843569f9eafa231c6cca05 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Sun, 6 Sep 2026 11:03:29 +0200 Subject: [PATCH 08/57] Notify phones on confirmed charger connections --- .changeset/ev-connected-status.md | 5 ++ contract/push-catalogue.yaml | 3 + go/internal/events/bus.go | 9 +++ go/internal/loadpoint/connection_events.go | 29 +++++++++ .../loadpoint/connection_events_test.go | 59 +++++++++++++++++++ go/internal/loadpoint/loadpoint.go | 37 ++++++++++-- go/internal/notifications/catalogue_gen.go | 2 + go/internal/notifications/service.go | 23 ++++++-- .../notifications/service_push_test.go | 25 ++++++++ go/internal/notifications/webpush.go | 2 +- go/internal/notifications/webpush_test.go | 6 +- 11 files changed, 187 insertions(+), 13 deletions(-) create mode 100644 .changeset/ev-connected-status.md create mode 100644 go/internal/loadpoint/connection_events.go create mode 100644 go/internal/loadpoint/connection_events_test.go diff --git a/.changeset/ev-connected-status.md b/.changeset/ev-connected-status.md new file mode 100644 index 00000000..7d006778 --- /dev/null +++ b/.changeset/ev-connected-status.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +Notify subscribed phones when a car is plugged in. Ignore first readings and recovery after an outage. Charging notifications carry the charger identity so the app can open its status and controls. diff --git a/contract/push-catalogue.yaml b/contract/push-catalogue.yaml index 5f4a2e12..f34ab4c1 100644 --- a/contract/push-catalogue.yaml +++ b/contract/push-catalogue.yaml @@ -13,6 +13,9 @@ # lost every notification that mattered. version: 1 events: + - kind: charging.connected + title: Car plugged in + body: "Open to see charging status, check the battery level and set a ready time." - kind: charging.session_complete title: Car charged body: "{kwh} kWh delivered — ready to go." diff --git a/go/internal/events/bus.go b/go/internal/events/bus.go index ab845385..3628ce6c 100644 --- a/go/internal/events/bus.go +++ b/go/internal/events/bus.go @@ -68,6 +68,7 @@ const ( KindNotificationDispatched = "notifications.dispatched" KindUpdateAvailable = "update.available" KindUpdateInstalled = "update.installed" + KindChargingConnected = "charging.connected" KindChargingSessionComplete = "charging.session_complete" KindChargingInterrupted = "charging.interrupted" ) @@ -176,3 +177,11 @@ type ChargingInterrupted struct { } func (ChargingInterrupted) Kind() string { return KindChargingInterrupted } + +// ChargingConnected names a confirmed cable connection, not a charge start. +type ChargingConnected struct { + LoadpointID string + At time.Time +} + +func (ChargingConnected) Kind() string { return KindChargingConnected } diff --git a/go/internal/loadpoint/connection_events.go b/go/internal/loadpoint/connection_events.go new file mode 100644 index 00000000..ab519d70 --- /dev/null +++ b/go/internal/loadpoint/connection_events.go @@ -0,0 +1,29 @@ +package loadpoint + +import "time" + +type connectionEdge struct { + known bool + plugged bool + unpluggedAt time.Time +} + +// Called under the manager lock. Unknown readings never count as unplugging. +// A first reading, including after an outage or restart, only sets a baseline. +func (m *Manager) observeConnectionLocked(id, driver string, plugged bool, now time.Time) bool { + if m.connectionEdges == nil { + m.connectionEdges = make(map[string]connectionEdge) + } + edge := m.connectionEdges[id] + if !m.connectionHealth[driver] { + delete(m.connectionEdges, id) + return false + } + fire := edge.known && !edge.plugged && plugged && now.Sub(edge.unpluggedAt) >= 2*time.Second + if !plugged && (!edge.known || edge.plugged) { + edge.unpluggedAt = now + } + edge.known, edge.plugged = true, plugged + m.connectionEdges[id] = edge + return fire +} diff --git a/go/internal/loadpoint/connection_events_test.go b/go/internal/loadpoint/connection_events_test.go new file mode 100644 index 00000000..e4379815 --- /dev/null +++ b/go/internal/loadpoint/connection_events_test.go @@ -0,0 +1,59 @@ +package loadpoint + +import ( + "github.com/srcfl/ftw/go/internal/events" + "github.com/srcfl/ftw/go/internal/telemetry" + "testing" + "time" +) + +func TestConnectionEventNeedsFreshEdgeAndSurvivesReload(t *testing.T) { + m := NewManager() + cfg := []Config{{ID: "garage", DriverName: "easee"}} + m.Load(cfg) + now := time.Unix(1700000000, 0) + m.SetNowFn(func() time.Time { return now }) + b := events.NewBus() + m.SetBus(b) + count := 0 + b.Subscribe(events.KindChargingConnected, func(e events.Event) { + count++ + if e.(events.ChargingConnected).LoadpointID != "garage" { + t.Fatal(e) + } + m.State("garage") + }) + health := func(status telemetry.DriverStatus) { + b.Publish(events.HealthTick{Health: map[string]telemetry.DriverHealth{"easee": {Status: status}}, Now: now}) + } + tick := func(connected bool) { m.Observe("garage", connected, 0, 0, true); now = now.Add(3 * time.Second) } + health(telemetry.StatusOk) + tick(true) + tick(true) + if count != 0 { + t.Fatal("startup is not a new plug-in") + } + tick(false) + tick(true) + tick(true) + if count != 1 { + t.Fatalf("got %d, want one plug event", count) + } + m.Load(cfg) + tick(true) + if count != 1 { + t.Fatal("reload repeated the plug event") + } + health(telemetry.StatusOffline) + tick(false) + health(telemetry.StatusOk) + tick(true) + if count != 1 { + t.Fatal("recovery claimed a new plug-in") + } + tick(false) + tick(true) + if count != 2 { + t.Fatal("next real plug-in was lost") + } +} diff --git a/go/internal/loadpoint/loadpoint.go b/go/internal/loadpoint/loadpoint.go index 51f763e0..0980cfbe 100644 --- a/go/internal/loadpoint/loadpoint.go +++ b/go/internal/loadpoint/loadpoint.go @@ -244,9 +244,11 @@ type PlanWindow struct { // Manager holds the running set of loadpoints. Thread-safe. type Manager struct { - mu sync.RWMutex - byID map[string]*loadpointRuntime - order []string // insertion-preserving id list for deterministic listing + connectionHealth map[string]bool + connectionEdges map[string]connectionEdge + mu sync.RWMutex + byID map[string]*loadpointRuntime + order []string // insertion-preserving id list for deterministic listing // scheduleSaver, if non-nil, is invoked synchronously whenever a // schedule is set or cleared. Wired by main.go to persist via @@ -417,13 +419,26 @@ func NewManager() *Manager { return &Manager{byID: map[string]*loadpointRuntime{}} } -// SetBus wires the shared event bus. The manager publishes exactly two -// things on it: the session-completion latch tripping, and the interruption -// hysteresis firing. Nil stays a no-op. +// SetBus wires charging events and the freshness used to qualify cable edges. func (m *Manager) SetBus(bus *events.Bus) { m.mu.Lock() defer m.mu.Unlock() m.bus = bus + if bus == nil { + return + } + bus.Subscribe(events.KindHealthTick, func(e events.Event) { + tick, ok := e.(events.HealthTick) + if !ok { + return + } + m.mu.Lock() + defer m.mu.Unlock() + m.connectionHealth = make(map[string]bool, len(tick.Health)) + for name, h := range tick.Health { + m.connectionHealth[name] = h.TelemetryLive() + } + }) } // SetCommandedW records what the controller last ordered this loadpoint to @@ -512,6 +527,13 @@ func (m *Manager) Load(cfgs []Config) { newByID[c.ID] = lp newOrder = append(newOrder, c.ID) } + for id := range m.connectionEdges { + next, present := newByID[id] + previous := m.byID[id] + if !present || previous == nil || previous.DriverName != next.DriverName { + delete(m.connectionEdges, id) + } + } m.byID = newByID m.order = newOrder } @@ -614,6 +636,9 @@ func (m *Manager) Observe(id string, pluggedIn bool, powerW, deliveredWh float64 var fired []events.Event bus := m.bus now := m.now() + if m.observeConnectionLocked(id, lp.DriverName, pluggedIn, now) { + fired = append(fired, events.ChargingConnected{LoadpointID: id, At: now}) + } if pluggedIn && !lp.pluggedIn { // Plug-in transition: seed the session anchor and clear any // session-completion latched from a prior session. diff --git a/go/internal/notifications/catalogue_gen.go b/go/internal/notifications/catalogue_gen.go index c9c1e5ae..96a3415a 100644 --- a/go/internal/notifications/catalogue_gen.go +++ b/go/internal/notifications/catalogue_gen.go @@ -8,6 +8,7 @@ const PushCatalogueVersion = 1 // Push event kinds. Hand-writing one of these strings is what the // catalogue exists to stop. const ( + PushChargingConnected = "charging.connected" PushChargingSessionComplete = "charging.session_complete" PushChargingInterrupted = "charging.interrupted" PushUpdateInstalled = "update.installed" @@ -25,6 +26,7 @@ type PushSentence struct { // PushSentences is every sentence a push may carry, keyed by kind. var PushSentences = map[string]PushSentence{ + PushChargingConnected: {Title: "Car plugged in", Body: "Open to see charging status, check the battery level and set a ready time."}, PushChargingSessionComplete: {Title: "Car charged", Body: "{kwh} kWh delivered — ready to go."}, PushChargingInterrupted: {Title: "Charging stopped early", Body: "The car stopped charging before it was done."}, PushUpdateInstalled: {Title: "Your box updated itself", Body: "Now running {version}. Everything came back on its own."}, diff --git a/go/internal/notifications/service.go b/go/internal/notifications/service.go index 0cfd2575..2e6ae2ed 100644 --- a/go/internal/notifications/service.go +++ b/go/internal/notifications/service.go @@ -94,6 +94,7 @@ func DefaultRules() []config.NotificationRule { // fires once per plug-in. interrupted keeps an hour on top of the // emitter's hysteresis, because a charger failing repeatedly is // one fact, not a feed. + {Type: PushChargingConnected, Enabled: false, Priority: 3, CooldownS: 60}, {Type: PushChargingSessionComplete, Enabled: false, Priority: 3}, {Type: PushChargingInterrupted, Enabled: false, Priority: 4, CooldownS: 3600}, {Type: PushUpdateInstalled, Enabled: false, Priority: 2}, @@ -114,7 +115,7 @@ func KnownRuleTypes() []string { return []string{ EventDriverOffline, EventDriverRecovered, EventUpdateAvailable, EventFuseOverLimit, EventConcurrentDriversOffline, - PushChargingSessionComplete, PushChargingInterrupted, + PushChargingConnected, PushChargingSessionComplete, PushChargingInterrupted, PushUpdateInstalled, PushDriverOffline, PushFuseOverLimit, } } @@ -131,10 +132,12 @@ type FuseReader = func() (amps map[string]float64, limitA float64, ok bool) // Message is a rendered notification payload. type Message struct { - Title string - Body string - Priority int - Tags []string + Kind string + LoadpointID string + Title string + Body string + Priority int + Tags []string } // Publisher dispatches a rendered Message to its transport. @@ -386,6 +389,11 @@ func (s *Service) Subscribe(bus *events.Bus) { // real-world moment — the loadpoint latches, the boot version check — // so the rule adds only the operator's gate: enabled, priority, // cooldown. Words come from the catalogue and nowhere else. + bus.Subscribe(events.KindChargingConnected, func(e events.Event) { + if ev, ok := e.(events.ChargingConnected); ok { + go s.handleCatalogued(PushChargingConnected, ev.LoadpointID, nil) + } + }) bus.Subscribe(events.KindChargingSessionComplete, func(e events.Event) { ev, ok := e.(events.ChargingSessionComplete) if !ok { @@ -452,7 +460,12 @@ func (s *Service) handleCatalogued(kind, device string, args map[string]string) s.emitDispatched(kind, device, Message{Priority: prio}, "failed", err.Error()) return } + loadpointID := "" + if strings.HasPrefix(kind, "charging.") { + loadpointID = device + } s.deliver(kind, device, Message{ + Kind: kind, LoadpointID: loadpointID, Title: title, Body: body, Priority: prio, diff --git a/go/internal/notifications/service_push_test.go b/go/internal/notifications/service_push_test.go index 5fbf063a..6d960e66 100644 --- a/go/internal/notifications/service_push_test.go +++ b/go/internal/notifications/service_push_test.go @@ -212,3 +212,28 @@ func TestEngineOwnedPublisherSurvivesReload(t *testing.T) { bus.Publish(events.ChargingSessionComplete{LoadpointID: "garage", KWh: 1.0, At: time.Now()}) waitForMsgs(t, push, 1) } + +func TestConnectedPushNeedsOptInAndKeepsChargerDestination(t *testing.T) { + pub := &fakePub{published: make(chan struct{}, 4)} + svc, clk := newSvc(pushCfg(config.NotificationRule{Type: PushChargingConnected, Enabled: false, CooldownS: 60}), pub) + bus := events.NewBus() + svc.Subscribe(bus) + bus.Publish(events.ChargingConnected{LoadpointID: "garage", At: clk.now()}) + settle(svc) + if len(pub.Messages()) != 0 { + t.Fatal("sent without opt-in") + } + svc.Reload(pushCfg(config.NotificationRule{Type: PushChargingConnected, Enabled: true, CooldownS: 60})) + bus.Publish(events.ChargingConnected{LoadpointID: "garage", At: clk.now()}) + msgs := waitForMsgs(t, pub, 1) + if msgs[0].Kind != PushChargingConnected || msgs[0].LoadpointID != "garage" || msgs[0].Title != "Car plugged in" { + t.Fatalf("wrong message: %+v", msgs[0]) + } + bus.Publish(events.ChargingConnected{LoadpointID: "garage", At: clk.now()}) + settle(svc) + if len(pub.Messages()) != 1 { + t.Fatal("repeated inside cooldown") + } + bus.Publish(events.ChargingConnected{LoadpointID: "street", At: clk.now()}) + waitForMsgs(t, pub, 2) +} diff --git a/go/internal/notifications/webpush.go b/go/internal/notifications/webpush.go index e228e5d8..8d905d7a 100644 --- a/go/internal/notifications/webpush.go +++ b/go/internal/notifications/webpush.go @@ -151,7 +151,7 @@ func (w *WebPush) Publish(ctx context.Context, m Message) error { return ErrNothingToSend } - payload, err := json.Marshal(map[string]string{"title": m.Title, "body": m.Body}) + payload, err := json.Marshal(map[string]string{"title": m.Title, "body": m.Body, "kind": m.Kind, "loadpoint_id": m.LoadpointID}) if err != nil { return fmt.Errorf("webpush: encode payload: %w", err) } diff --git a/go/internal/notifications/webpush_test.go b/go/internal/notifications/webpush_test.go index cfa60a08..7ad43b6a 100644 --- a/go/internal/notifications/webpush_test.go +++ b/go/internal/notifications/webpush_test.go @@ -255,7 +255,7 @@ func TestPublishCarriesVAPIDAndDecryptablePayload(t *testing.T) { if err != nil { t.Fatal(err) } - if err := wp.Publish(context.Background(), Message{Title: "Car charged", Body: "7.4 kWh delivered — ready to go.", Priority: 3}); err != nil { + if err := wp.Publish(context.Background(), Message{Title: "Car charged", Body: "7.4 kWh delivered — ready to go.", Priority: 3, Kind: PushChargingSessionComplete, LoadpointID: "garage"}); err != nil { t.Fatalf("publish: %v", err) } @@ -279,6 +279,10 @@ func TestPublishCarriesVAPIDAndDecryptablePayload(t *testing.T) { t.Fatalf("payload = %v", payload) } + if payload["kind"] != PushChargingSessionComplete || payload["loadpoint_id"] != "garage" { + t.Fatalf("charger destination lost in encryption: %v", payload) + } + // The Authorization header verifies against the published key. auth := r.headers.Get("Authorization") if !strings.HasPrefix(auth, "vapid t=") { From 5801b4df7983f93bf7c227ff56f77757f2d1160c Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Sun, 6 Sep 2026 11:39:23 +0200 Subject: [PATCH 09/57] fix(settings): keep unchanged drivers running on charger saves Signed-off-by: Fredrik Ahlgren --- .changeset/driver-config-numbers.md | 5 ++ go/internal/drivers/registry.go | 14 +++++- .../drivers/registry_config_values_test.go | 47 +++++++++++++++++++ web/settings/tabs/loadpoints.js | 26 ++++++---- 4 files changed, 80 insertions(+), 12 deletions(-) create mode 100644 .changeset/driver-config-numbers.md create mode 100644 go/internal/drivers/registry_config_values_test.go diff --git a/.changeset/driver-config-numbers.md b/.changeset/driver-config-numbers.md new file mode 100644 index 00000000..f6493be9 --- /dev/null +++ b/.changeset/driver-config-numbers.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +Keep unchanged drivers running when settings pass through the web API. Compare numeric values from JSON and YAML equally, including nested driver settings. Bound charger settings requests so a missing reply leads to a visible retry state. diff --git a/go/internal/drivers/registry.go b/go/internal/drivers/registry.go index 5e71c49c..7f0810c1 100644 --- a/go/internal/drivers/registry.go +++ b/go/internal/drivers/registry.go @@ -1,6 +1,7 @@ package drivers import ( + "bytes" "context" "crypto/rand" "encoding/hex" @@ -1686,9 +1687,18 @@ func sameDriverConfig(a, b config.Driver) bool { // Compare the free-form Config map. Previously omitted, so a changed // cloud-driver password in drivers[i].config.password was silently // ignored by the hot-reload diff — the driver kept running with the - // stale credentials. DeepEqual also treats nil and empty maps as equal. + // stale credentials. The empty-map case below handles nil versus empty. if len(a.Config) == 0 && len(b.Config) == 0 { return true } - return reflect.DeepEqual(a.Config, b.Config) + if reflect.DeepEqual(a.Config, b.Config) { + return true + } + // YAML decodes whole numbers as int; JSON decodes them as float64. + // Compare their wire values so a settings save and its file-watcher + // reload do not restart an unchanged driver twice. JSON keeps strings, + // booleans and numbers distinct, including inside nested settings. + aj, aerr := json.Marshal(a.Config) + bj, berr := json.Marshal(b.Config) + return aerr == nil && berr == nil && bytes.Equal(aj, bj) } diff --git a/go/internal/drivers/registry_config_values_test.go b/go/internal/drivers/registry_config_values_test.go new file mode 100644 index 00000000..3b9e92ec --- /dev/null +++ b/go/internal/drivers/registry_config_values_test.go @@ -0,0 +1,47 @@ +package drivers + +import ( + "encoding/json" + "testing" + + "github.com/srcfl/ftw/go/internal/config" +) + +func TestDriverConfigJSONRoundTripDoesNotRestart(t *testing.T) { + fromYAML := config.Driver{Lua: "/app/drivers/sungrow.lua", Config: map[string]any{ + "unit_id": 1, "port": 502, "timeout": 2.5, + "nested": map[string]any{"registers": []any{1, 2, 3}, "enabled": true}, + "password": "test-value", + }} + data, err := json.Marshal(fromYAML) + if err != nil { + t.Fatal(err) + } + var fromJSON config.Driver + if err := json.Unmarshal(data, &fromJSON); err != nil { + t.Fatal(err) + } + if !sameDriverConfig(fromYAML, fromJSON) || !sameDriverConfig(fromJSON, fromYAML) { + t.Fatal("unchanged values restart on API save or file reload") + } + for _, tc := range []struct { + name, key string + value any + }{ + {"changed number", "unit_id", float64(2)}, + {"number became string", "unit_id", "1"}, + {"changed secret", "password", "new-value"}, + {"nested change", "nested", map[string]any{"registers": []any{1, 2, 4}, "enabled": true}}, + } { + t.Run(tc.name, func(t *testing.T) { + var changed config.Driver + if err := json.Unmarshal(data, &changed); err != nil { + t.Fatal(err) + } + changed.Config[tc.key] = tc.value + if sameDriverConfig(fromYAML, changed) { + t.Fatal("real setting change ignored") + } + }) + } +} diff --git a/web/settings/tabs/loadpoints.js b/web/settings/tabs/loadpoints.js index 494691db..65a4c9f1 100644 --- a/web/settings/tabs/loadpoints.js +++ b/web/settings/tabs/loadpoints.js @@ -396,19 +396,25 @@ feedback('Applying charger settings…'); var operation = chargerSaveQueue.catch(function () {}).then(function () { // Read the current config so this save never commits another tab's draft. - return ctx.apiFetch('/api/config').then(function (r) { - if (!r.ok) throw new Error('Could not read current settings (HTTP ' + r.status + ')'); - return r.json(); - }).then(function (latest) { + function request(opts) { + var abort = new AbortController(); + var timeout = setTimeout(function () { abort.abort(); }, 30000); + return ctx.apiFetch('/api/config', Object.assign({}, opts, { signal: abort.signal })) + .then(function (r) { return r.json().then(function (body) { + if (!r.ok) throw new Error(body.error || ('HTTP ' + r.status)); + return body; + }); }) + .catch(function (e) { + if (abort.signal.aborted) throw new Error('FTW did not answer within 30 seconds'); + throw e; + }) + .finally(function () { clearTimeout(timeout); }); + } + return request().then(function (latest) { latest.loadpoints = snapshot; - return ctx.apiFetch('/api/config', { + return request({ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(latest), }); - }).then(function (r) { - return r.json().then(function (body) { - if (!r.ok) throw new Error(body.error || ('HTTP ' + r.status)); - return body; - }); }); }); chargerSaveQueue = operation; From 8c334466944649402e52820768f9152636c89f85 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Sun, 6 Sep 2026 11:39:23 +0200 Subject: [PATCH 10/57] fix(ev): expose unconfirmed battery levels after restart Signed-off-by: Fredrik Ahlgren --- .changeset/ev-soc-confirmation.md | 5 ++ .../loadpoint/connection_events_test.go | 5 +- go/internal/loadpoint/loadpoint.go | 18 +++++- go/internal/loadpoint/loadpoint_test.go | 4 +- .../loadpoint/soc_confirmation_test.go | 58 +++++++++++++++++++ web/app.js | 3 +- 6 files changed, 86 insertions(+), 7 deletions(-) create mode 100644 .changeset/ev-soc-confirmation.md create mode 100644 go/internal/loadpoint/soc_confirmation_test.go diff --git a/.changeset/ev-soc-confirmation.md b/.changeset/ev-soc-confirmation.md new file mode 100644 index 00000000..24ea182f --- /dev/null +++ b/.changeset/ev-soc-confirmation.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +Mark the default EV battery level as unconfirmed. Ask for the car's level after a box restart instead of presenting a calculation from the default as a confirmed estimate. An entered level still applies on slider release and survives a settings reload. diff --git a/go/internal/loadpoint/connection_events_test.go b/go/internal/loadpoint/connection_events_test.go index e4379815..b3e9fe00 100644 --- a/go/internal/loadpoint/connection_events_test.go +++ b/go/internal/loadpoint/connection_events_test.go @@ -24,9 +24,12 @@ func TestConnectionEventNeedsFreshEdgeAndSurvivesReload(t *testing.T) { m.State("garage") }) health := func(status telemetry.DriverStatus) { - b.Publish(events.HealthTick{Health: map[string]telemetry.DriverHealth{"easee": {Status: status}}, Now: now}) + b.Publish(events.HealthTick{Health: map[string]telemetry.DriverHealth{"easee": {Status: status, LastSuccess: &now}}, Now: now}) } tick := func(connected bool) { m.Observe("garage", connected, 0, 0, true); now = now.Add(3 * time.Second) } + // Registered does not mean a charger has supplied its first reading. + b.Publish(events.HealthTick{Health: map[string]telemetry.DriverHealth{"easee": {Status: telemetry.StatusOk}}, Now: now}) + tick(false) health(telemetry.StatusOk) tick(true) tick(true) diff --git a/go/internal/loadpoint/loadpoint.go b/go/internal/loadpoint/loadpoint.go index 0980cfbe..b8b6b520 100644 --- a/go/internal/loadpoint/loadpoint.go +++ b/go/internal/loadpoint/loadpoint.go @@ -136,7 +136,8 @@ type State struct { // physical connection. Zero values when no online vehicle driver is // reporting. SoCSource is "vehicle" when CurrentSoC was overridden // from the car's BMS, "inferred" when it's the loadpoint manager's - // pluginSoC + deliveredWh estimate, "" when not plugged in. + // confirmed anchor + deliveredWh estimate, "assumed" before the user + // or a matched vehicle confirms a level, "" when not plugged in. VehicleSoC float64 `json:"vehicle_soc,omitempty"` VehicleChargeLimit float64 `json:"vehicle_charge_limit,omitempty"` VehicleChargingState string `json:"vehicle_charging_state,omitempty"` @@ -379,6 +380,9 @@ type loadpointRuntime struct { // "completed" when sessionComplete is latched so operators can // see why the inferred SoC pinned at target. socSource string + // socConfirmed is true only after a level from the user or a matched car. + // A configured/default plug-in level remains a planning assumption. + socConfirmed bool // surplusWithheld is set by the controller each tick: true when WE // are intentionally withholding power from this loadpoint (a @@ -436,7 +440,7 @@ func (m *Manager) SetBus(bus *events.Bus) { defer m.mu.Unlock() m.connectionHealth = make(map[string]bool, len(tick.Health)) for name, h := range tick.Health { - m.connectionHealth[name] = h.TelemetryLive() + m.connectionHealth[name] = h.TelemetryLive() && h.LastSuccess != nil && !h.LastSuccess.IsZero() } }) } @@ -505,6 +509,7 @@ func (m *Manager) Load(cfgs []Config) { lp.notRequestingSince = existing.notRequestingSince lp.sessionComplete = existing.sessionComplete lp.socSource = existing.socSource + lp.socConfirmed = existing.socConfirmed && existing.DriverName == c.DriverName lp.commandedW = existing.commandedW lp.commandedReason = existing.commandedReason lp.commandedKnown = existing.commandedKnown @@ -647,6 +652,7 @@ func (m *Manager) Observe(id string, pluggedIn bool, powerW, deliveredWh float64 anchor = units.DefaultPluginSoC } lp.sessionPluginSoC = anchor + lp.socConfirmed = false lp.notRequestingSince = time.Time{} lp.sessionComplete = false lp.socSource = "" @@ -1013,12 +1019,15 @@ func (m *Manager) AnchorVehicleSoC(id string, socPct float64) bool { // paths so they stay arithmetically identical. func reanchorSoCLocked(lp *loadpointRuntime, soc float64) { soc = units.ClampFraction(soc) + lp.socConfirmed = true // Re-anchor: new_anchor + delivered/capacity == soc. delivered := 0.0 if lp.VehicleCapacityWh > 0 { delivered = lp.deliveredWhSession / lp.VehicleCapacityWh } - lp.sessionPluginSoC = units.ClampFraction(soc - delivered) + // The offset may be negative when the corrected level is below the + // energy already delivered. Clamp the resulting level, not the offset. + lp.sessionPluginSoC = soc - delivered lp.currentSoC = estimateSoC(lp.sessionPluginSoC, lp.deliveredWhSession, lp.VehicleCapacityWh) lp.updatedAtMs = time.Now().UnixMilli() } @@ -1048,6 +1057,9 @@ func (lp *loadpointRuntime) snapshot() State { CommandedReason: lp.commandedReason, CommandedKnown: lp.commandedKnown, } + if st.PluggedIn && st.SoCSource == "" && !lp.socConfirmed { + st.SoCSource = "assumed" + } if !lp.commandedSince.IsZero() { st.CommandedSinceMs = lp.commandedSince.UnixMilli() } diff --git a/go/internal/loadpoint/loadpoint_test.go b/go/internal/loadpoint/loadpoint_test.go index 22f0a026..315394fe 100644 --- a/go/internal/loadpoint/loadpoint_test.go +++ b/go/internal/loadpoint/loadpoint_test.go @@ -230,8 +230,8 @@ func TestSessionCompletionSnapsToTarget(t *testing.T) { // Tick 1: connected and charging — request_active = true. m.Observe("garage", true, 7400, 0, true) - if st, _ := m.State("garage"); st.SoCSource != "" { - t.Errorf("session start should not be marked completed: %+v", st) + if st, _ := m.State("garage"); st.SoCSource != "assumed" { + t.Errorf("session start should expose its unconfirmed level: %+v", st) } // Tick 2 (T+1m of charging): some energy delivered, inferred SoC rises. diff --git a/go/internal/loadpoint/soc_confirmation_test.go b/go/internal/loadpoint/soc_confirmation_test.go new file mode 100644 index 00000000..78e8639f --- /dev/null +++ b/go/internal/loadpoint/soc_confirmation_test.go @@ -0,0 +1,58 @@ +package loadpoint + +import "testing" + +func TestSoCConfirmationDoesNotSurviveUnknownSession(t *testing.T) { + cfg := []Config{{ID: "garage", DriverName: "evse", VehicleCapacityWh: 60000}} + m := NewManager() + m.Load(cfg) + m.Observe("garage", true, 4300, 6000, true) + assertSource := func(want string) { + t.Helper() + s, _ := m.State("garage") + if s.SoCSource != want { + t.Fatalf("source = %q, want %q", s.SoCSource, want) + } + } + assertSource("assumed") + if !m.SetCurrentSoC("garage", .84) { + t.Fatal("input rejected") + } + assertSource("") // API supplies "inferred" for a confirmed anchor. + m.Load(cfg) + m.Observe("garage", true, 4300, 6600, true) + assertSource("") + s, _ := m.State("garage") + if s.CurrentSoC < .849 || s.CurrentSoC > .851 { + t.Fatalf("confirmed estimate lost on config reload: %v", s.CurrentSoC) + } + // Until the charger reports a stable session identity, a process restart + // cannot prove the same car stayed connected. Never present the default as + // a level inferred from the user's last input. + m = NewManager() + m.Load(cfg) + m.Observe("garage", true, 4300, 6600, true) + assertSource("assumed") + m.AnchorVehicleSoC("garage", .85) + assertSource("") + m.Observe("garage", false, 0, 0, true) + assertSource("") + m.Observe("garage", true, 0, 0, true) + assertSource("assumed") +} + +func TestLowSoCCorrectionAfterEnergyWasDelivered(t *testing.T) { + m := NewManager() + m.Load([]Config{{ID: "garage", VehicleCapacityWh: 60000}}) + m.Observe("garage", true, 4300, 9000, true) + m.SetCurrentSoC("garage", .05) + s, _ := m.State("garage") + if s.CurrentSoC < .049 || s.CurrentSoC > .051 { + t.Fatalf("slider correction was ignored: %v", s.CurrentSoC) + } + m.Observe("garage", true, 4300, 9600, true) + s, _ = m.State("garage") + if s.CurrentSoC < .059 || s.CurrentSoC > .061 { + t.Fatalf("energy must accrue from the corrected level: %v", s.CurrentSoC) + } +} diff --git a/web/app.js b/web/app.js index 8ab48f33..19fd4edb 100644 --- a/web/app.js +++ b/web/app.js @@ -3649,6 +3649,7 @@ function sourceNote(lpNow) { var src = (lpNow && lpNow.soc_source) || ""; + if (src === "assumed") return "Battery level needs confirmation. The plan currently assumes " + Math.round(lpNow.current_soc * 100) + " %. Drag to match the car. This level must be entered again after a box restart."; if (src === "vehicle") return "Live from the car. Drag only to correct drift."; if (src === "completed") return "The car stopped asking for current, so the box assumes the target was reached. Drag to correct."; return "Estimated from energy delivered. Drag to the real value and the plan follows."; @@ -3759,7 +3760,7 @@ var cur = (lpNow.current_soc != null) ? Math.max(0, Math.min(100, Math.round(lpNow.current_soc * 100))) : null; if (!operatorHolds() && cur != null) { slider.value = String(cur); - hdr.value.textContent = cur + "%"; + hdr.value.textContent = lpNow.soc_source === "assumed" ? "Not confirmed" : cur + "%"; } if (!noteTimer && !socFailed && !operatorHolds()) note.textContent = sourceNote(lpNow); } From c0c5bfb54f45d8db83b0026905c869f8318120dd Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Sun, 6 Sep 2026 11:45:20 +0200 Subject: [PATCH 11/57] fix(ev): keep manual sessions while waiting for first telemetry Signed-off-by: Fredrik Ahlgren --- .changeset/ev-missing-reading.md | 5 +++ go/internal/loadpoint/controller.go | 13 ++++-- .../controller_missing_reading_test.go | 43 +++++++++++++++++++ 3 files changed, 58 insertions(+), 3 deletions(-) create mode 100644 .changeset/ev-missing-reading.md create mode 100644 go/internal/loadpoint/controller_missing_reading_test.go diff --git a/.changeset/ev-missing-reading.md b/.changeset/ev-missing-reading.md new file mode 100644 index 00000000..b125959a --- /dev/null +++ b/.changeset/ev-missing-reading.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +Preserve manual charging while the charger driver starts or has no reading. An absent reading no longer counts as an unplug. A confirmed unplug still ends the manual session. diff --git a/go/internal/loadpoint/controller.go b/go/internal/loadpoint/controller.go index 3ede525a..de10d125 100644 --- a/go/internal/loadpoint/controller.go +++ b/go/internal/loadpoint/controller.go @@ -1395,9 +1395,16 @@ func (c *Controller) TickWithDispatch(ctx context.Context, now time.Time, dispat } func (c *Controller) tickOne(ctx context.Context, now time.Time, lpCfg Config, dispatchAllowed bool) { - var sample EVSample - if c.tel != nil { - sample, _ = c.tel(lpCfg.DriverName) + if c.tel == nil { + return + } + sample, observed := c.tel(lpCfg.DriverName) + if !observed { + // No reading is not an unplug. In particular, startup must not + // clear a restored manual hold while the driver is still logging in. + // Core's driver-health owner handles autonomous recovery; without + // an EV sample this loop cannot confirm a session or send a setpoint. + return } // Resolve the schedule once per tick — used for bat-SoC unlock // (surplusActive) below. Zero value when no schedule is set, diff --git a/go/internal/loadpoint/controller_missing_reading_test.go b/go/internal/loadpoint/controller_missing_reading_test.go new file mode 100644 index 00000000..f65aa3ac --- /dev/null +++ b/go/internal/loadpoint/controller_missing_reading_test.go @@ -0,0 +1,43 @@ +package loadpoint + +import ( + "context" + "testing" + "time" +) + +func TestMissingReadingDoesNotEndManualSession(t *testing.T) { + now := time.Now() + cfg := holdLoadpoint() + samples := map[string]EVSample{} + sender := &fakeSender{} + c := newTestController(t, []Config{cfg}, &Directive{SlotStart: now, SlotEnd: now.Add(time.Hour)}, samples, sender) + c.SetManualHold(cfg.ID, ManualHold{PowerW: 4140, PhaseMode: "3p", Persistent: true}) + c.Tick(context.Background(), now) + if _, ok := c.GetManualHold(cfg.ID, now); !ok { + t.Fatal("startup without a reading cleared the hold") + } + if len(sender.calls) != 0 { + t.Fatal("sent a command without charger data") + } + samples[cfg.DriverName] = EVSample{Connected: true, RequestActive: true, PowerW: 4140, SessionWh: 6000} + c.Tick(context.Background(), now.Add(time.Second)) + if len(sender.calls) != 1 || sender.calls[0].power != 4140 { + t.Fatalf("confirmed connection did not restore manual charging: %+v", sender.calls) + } + c.manager.SetCurrentSoC(cfg.ID, .84) + delete(samples, cfg.DriverName) + c.TickWithDispatch(context.Background(), now.Add(2*time.Second), false) + if _, ok := c.GetManualHold(cfg.ID, now); !ok { + t.Fatal("missing reading during outage cleared the hold") + } + state, _ := c.manager.State(cfg.ID) + if !state.PluggedIn || state.CurrentSoC < .83 { + t.Fatalf("missing reading replaced the confirmed session: %+v", state) + } + samples[cfg.DriverName] = EVSample{Connected: false} + c.Tick(context.Background(), now.Add(3*time.Second)) + if _, ok := c.GetManualHold(cfg.ID, now); ok { + t.Fatal("a confirmed unplug must clear the hold") + } +} From 212b045595088a9cf195fef5be8a36349a540c63 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Sun, 6 Sep 2026 12:02:30 +0200 Subject: [PATCH 12/57] fix(ev): remove the active target when its goal is removed Signed-off-by: Fredrik Ahlgren --- .changeset/ev-remove-goal.md | 5 +++ .../api/api_loadpoint_schedule_test.go | 5 +++ go/internal/loadpoint/loadpoint.go | 18 ++------- .../loadpoint/schedule_remove_goal_test.go | 40 +++++++++++++++++++ 4 files changed, 54 insertions(+), 14 deletions(-) create mode 100644 .changeset/ev-remove-goal.md create mode 100644 go/internal/loadpoint/schedule_remove_goal_test.go diff --git a/.changeset/ev-remove-goal.md b/.changeset/ev-remove-goal.md new file mode 100644 index 00000000..28cd5992 --- /dev/null +++ b/.changeset/ev-remove-goal.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +Remove the active derived target when a charging goal is removed. The old deadline no longer drives the planner after the UI says the goal is gone. A separate Charge now request continues unchanged. diff --git a/go/internal/api/api_loadpoint_schedule_test.go b/go/internal/api/api_loadpoint_schedule_test.go index 9ccfd136..0dce9576 100644 --- a/go/internal/api/api_loadpoint_schedule_test.go +++ b/go/internal/api/api_loadpoint_schedule_test.go @@ -99,6 +99,7 @@ func TestScheduleDeleteClearsAndReplans(t *testing.T) { // Seeded on the manager directly, so the replan reason below can // only have come from the DELETE. mgr.SetSchedule("garage", loadpoint.Schedule{SoC: 0.8, TimeOfDayMinUTC: 360, Recurring: true}) + mgr.RollSchedules(time.Now()) req := httptest.NewRequest(http.MethodDelete, "/api/loadpoints/garage/schedule", nil) rr := httptest.NewRecorder() @@ -110,6 +111,10 @@ func TestScheduleDeleteClearsAndReplans(t *testing.T) { if _, ok := mgr.GetSchedule("garage"); ok { t.Fatal("DELETE did not clear the schedule") } + state, _ := mgr.State("garage") + if state.TargetSoC != 0 || !state.TargetTime.IsZero() { + t.Fatalf("DELETE left an active target after removing the goal: %+v", state) + } if _, reason := svc.LastReplanInfo(); reason != "loadpoint_schedule_changed" { t.Fatalf("replan reason = %q, want loadpoint_schedule_changed", reason) } diff --git a/go/internal/loadpoint/loadpoint.go b/go/internal/loadpoint/loadpoint.go index b8b6b520..b9ebfa71 100644 --- a/go/internal/loadpoint/loadpoint.go +++ b/go/internal/loadpoint/loadpoint.go @@ -1132,20 +1132,10 @@ func (m *Manager) GetSchedule(id string) (Schedule, bool) { // reload doesn't resurrect the old schedule from disk. Returns false // for unknown IDs. func (m *Manager) ClearSchedule(id string) bool { - m.mu.Lock() - lp, ok := m.byID[id] - if !ok { - m.mu.Unlock() - return false - } - lp.schedule = Schedule{} - lp.lastRolledFor = time.Time{} - saver := m.scheduleSaver - m.mu.Unlock() - if saver != nil { - saver(id, Schedule{}) - } - return true + // Removing the goal also removes its active derived deadline. Leaving + // that target behind would keep planning a charge the UI says was removed. + // Manual holds belong to the controller and are unaffected. + return m.SetSchedule(id, Schedule{}) } // HydrateSchedules loads persisted schedules at boot. `loader(id)` diff --git a/go/internal/loadpoint/schedule_remove_goal_test.go b/go/internal/loadpoint/schedule_remove_goal_test.go new file mode 100644 index 00000000..67b532a2 --- /dev/null +++ b/go/internal/loadpoint/schedule_remove_goal_test.go @@ -0,0 +1,40 @@ +package loadpoint + +import ( + "context" + "testing" + "time" +) + +func TestRemoveScheduleClearsDerivedGoalButKeepsManualCharge(t *testing.T) { + now := time.Date(2026, 9, 6, 20, 0, 0, 0, time.UTC) + cfg := holdLoadpoint() + m := NewManager() + m.Load([]Config{cfg}) + m.SetSchedule(cfg.ID, Schedule{SoC: .8, TimeOfDayMinUTC: 420}) + m.RollSchedules(now) + before, _ := m.State(cfg.ID) + if before.TargetSoC != .8 || before.TargetTime.IsZero() { + t.Fatal("test needs a rolled goal") + } + var saved Schedule + m.SetScheduleSaver(func(_ string, s Schedule) { saved = s }) + sender := &fakeSender{} + c := NewController(m, func(time.Time) (Directive, bool) { return Directive{}, false }, func(string) (EVSample, bool) { return EVSample{Connected: true, RequestActive: true}, true }, sender.Send) + c.SetManualHold(cfg.ID, ManualHold{PowerW: 4140, PhaseMode: "3p", Persistent: true}) + if !m.ClearSchedule(cfg.ID) { + t.Fatal("goal removal failed") + } + m.RollSchedules(now.Add(time.Second)) + after, _ := m.State(cfg.ID) + if !after.Schedule.Empty() || after.TargetSoC != 0 || !after.TargetTime.IsZero() || !saved.Empty() { + t.Fatalf("removed goal still affects planning: %+v", after) + } + c.Tick(context.Background(), now.Add(time.Second)) + if _, ok := c.GetManualHold(cfg.ID, now); !ok { + t.Fatal("removing a goal ended manual charging") + } + if len(sender.calls) != 1 || sender.calls[0].power != 4140 { + t.Fatalf("manual charge changed: %+v", sender.calls) + } +} From 51ce2f26aee40994c6764a5104a6d1eec64dec9d Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Sun, 6 Sep 2026 12:02:30 +0200 Subject: [PATCH 13/57] fix(ev): show charging on home and make the first goal selectable Signed-off-by: Fredrik Ahlgren --- .changeset/ev-owner-flow.md | 5 +++ web/app.js | 84 +++++++++++++++++++++++++++++++++++-- 2 files changed, 85 insertions(+), 4 deletions(-) create mode 100644 .changeset/ev-owner-flow.md diff --git a/.changeset/ev-owner-flow.md b/.changeset/ev-owner-flow.md new file mode 100644 index 00000000..2c949209 --- /dev/null +++ b/.changeset/ev-owner-flow.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +Show the connected car and its next action on the home screen, with a direct route to charging controls. Keep stale status visible until a fresh reading confirms unplugging. Let the user choose the displayed first goal without changing its time or battery target. diff --git a/web/app.js b/web/app.js index 19fd4edb..8feb706c 100644 --- a/web/app.js +++ b/web/app.js @@ -556,7 +556,7 @@ if (lpEv.vehicle_soc > 0) { p.soc = lpEv.vehicle_soc * 100; p.socSource = "vehicle"; - } else if (lpEv.current_soc > 0) { + } else if (lpEv.current_soc > 0 && lpEv.soc_source !== "assumed") { p.soc = lpEv.current_soc * 100; p.socSource = lpEv.soc_source || "inferred"; } @@ -2121,6 +2121,68 @@ // entries mean "no loadpoint for this driver" and the planet // falls back to legacy kW-only rendering. var loadpointsByDriver = null; + var chargingNoticePoints = []; + var chargingNoticeRows = new Map(); + var chargingNoticeTimer = null; + function updateChargingNotice(payload) { + var fresh = !!(payload && Array.isArray(payload.loadpoints)); + if (fresh) { + chargingNoticePoints = payload.loadpoints.map(function (lp) { + var previous = chargingNoticePoints.find(function (old) { return old.id === lp.id; }); + // Offline telemetry cannot establish that the cable was removed. + return previous && previous.plugged_in && lp.charger && !lp.charger.available + ? Object.assign({}, lp, { plugged_in: true }) : lp; + }); + if (chargingNoticeTimer) clearTimeout(chargingNoticeTimer); + chargingNoticeTimer = setTimeout(function () { updateChargingNotice(null); }, 15000); + } + fresh = fresh && !document.hidden; + var host = document.getElementById("charging-notices"); + var anchor = document.getElementById("power-now"); + if (!anchor) return; + if (!host) { + host = document.createElement("div"); + host.id = "charging-notices"; + anchor.parentNode.insertBefore(host, anchor); + } + var keep = new Set(); + chargingNoticePoints.filter(function (lp) { return lp.plugged_in; }).forEach(function (lp) { + keep.add(lp.id); + var row = chargingNoticeRows.get(lp.id); + if (!row) { + var section = document.createElement("section"); + section.className = "overview-card"; + section.setAttribute("aria-label", "Car connection"); + section.style.cssText = "padding:0.9rem;margin-bottom:1rem"; + var title = document.createElement("strong"); + var status = document.createElement("p"); + status.setAttribute("role", "status"); + status.style.cssText = "margin:0.45rem 0;font-size:0.9rem"; + var button = document.createElement("button"); + button.type = "button"; + button.style.cssText = "background:none;border:0;color:var(--accent-e);padding:0.3rem 0;text-align:left;text-decoration:underline;font:inherit;cursor:pointer"; + section.append(title, status, button); + host.appendChild(section); + row = { section: section, title: title, status: status, button: button }; + chargingNoticeRows.set(lp.id, row); + } + var available = fresh && (!lp.charger || lp.charger.available); + row.title.textContent = available ? "Car connected" : "Car status is out of date"; + var message = available ? renderEvPlanStatus(lp, null) : null; + row.status.textContent = message ? message.textContent : "Waiting for current charger status. The last reading cannot confirm charging."; + row.button.textContent = "Check charging" + (lp.soc_source !== "vehicle" ? " and battery level" : ""); + row.button.onclick = function () { + if (energyFlowEl) energyFlowEl.dispatchEvent(new CustomEvent("ftw-planet-click", { detail: { role: "ev", name: lp.driver_name } })); + }; + }); + chargingNoticeRows.forEach(function (row, id) { + if (!keep.has(id)) { row.section.remove(); chargingNoticeRows.delete(id); } + }); + host.hidden = keep.size === 0; + } + document.addEventListener("visibilitychange", function () { + if (document.hidden) updateChargingNotice(null); + }); // Last successful /api/status payload — surfaced so secondary // consumers (e.g. the EV modal's 5 s refresh) can read derived // facts like siteHasPV() without re-fetching. `null` until the @@ -2156,6 +2218,7 @@ }); loadpointsByDriver = idx; } + updateChargingNotice(lp); setConnected(true); if (firstLoad) { firstLoad = false; } if (setupBannerShown) { hideSetupBanner(); } @@ -2172,6 +2235,7 @@ }) .catch(function (e) { console.warn("status fetch failed:", e); + updateChargingNotice(null); setConnected(false); if (firstLoad) { showSetupBanner(); } }); @@ -2779,7 +2843,7 @@ text = "No charging plan yet. Set a ready time, or choose Charge now."; tone = "var(--text)"; } else { - text = "No charge window in the current plan — the target may already be reached."; + text = "No charge window yet for this goal. Choose Charge now if you need to charge immediately."; } if (!text) return null; var p = document.createElement("p"); @@ -4105,8 +4169,15 @@ b.style.color = "var(--fg)"; return b; } + var chooseBtn = mkBtn("Use " + Math.round(initSoC) + " % by " + initLocalHHMM); + chooseBtn.style.textTransform = "none"; + chooseBtn.style.letterSpacing = "normal"; + chooseBtn.addEventListener("click", scheduleSave); + btnRow.appendChild(chooseBtn); var clearBtn = mkBtn("Remove schedule"); function paintClear() { + chooseBtn.hidden = hasSched; + clearBtn.hidden = !hasSched; clearBtn.disabled = !hasSched; clearBtn.style.opacity = hasSched ? "1" : "0.4"; } @@ -4121,7 +4192,7 @@ status.style.minHeight = "1em"; status.textContent = hasSched ? "Changes save as you make them; the plan above follows." - : "Move the target or pick a time to set a schedule; the plan above follows."; + : "No goal set yet. Choose this goal, or change the level or time."; box.appendChild(status); // Every control writes when it changes: the slider on release, the @@ -4138,6 +4209,7 @@ if (saveTimer) clearTimeout(saveTimer); if (statusTimer) { clearTimeout(statusTimer); statusTimer = null; } saveSeq++; + chooseBtn.disabled = true; status.textContent = "Applying schedule…"; saveTimer = setTimeout(function () { saveTimer = null; doSave(); }, 400); } @@ -4179,7 +4251,11 @@ status.textContent = "Schedule saved. Reading the plan…"; refreshEvModalAfterWrite(); }).catch(function (e) { - if (seq === saveSeq) status.textContent = "Schedule not confirmed: " + e.message; + if (seq === saveSeq) { + chooseBtn.disabled = false; + chooseBtn.textContent = "Use " + targetSlider.value + " % by " + timeInp.value; + status.textContent = "Schedule not confirmed: " + e.message; + } }); }); } From 46d04a7a511414e89a3271902dc1114b4ee2d6ea Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Sun, 6 Sep 2026 12:06:24 +0200 Subject: [PATCH 14/57] fix(ev): clear the goal saving progress after confirmation Signed-off-by: Fredrik Ahlgren --- web/app.js | 2 +- web/ev-schedule-autosave.test.mjs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/web/app.js b/web/app.js index 8feb706c..a6dcd16e 100644 --- a/web/app.js +++ b/web/app.js @@ -4248,7 +4248,7 @@ if (seq !== saveSeq) return; hasSched = true; paintClear(); - status.textContent = "Schedule saved. Reading the plan…"; + status.textContent = "Schedule saved."; refreshEvModalAfterWrite(); }).catch(function (e) { if (seq === saveSeq) { diff --git a/web/ev-schedule-autosave.test.mjs b/web/ev-schedule-autosave.test.mjs index 8a27abbb..b10b7c8b 100644 --- a/web/ev-schedule-autosave.test.mjs +++ b/web/ev-schedule-autosave.test.mjs @@ -22,7 +22,7 @@ test('every schedule control saves on change', () => { // view above moves with the schedule. assert.match(sched, /setTimeout\(function \(\) \{ saveTimer = null; doSave\(\); \}, 400\)/); assert.match(sched, /if \(seq !== saveSeq\) return;/); - assert.match(sched, /Schedule saved. Reading the plan…/); + assert.match(sched, /Schedule saved\./); assert.match(sched, /refreshEvModalAfterWrite\(\)/); }); From 99371b83d331e3c337713345188b390fa38827b6 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Sun, 6 Sep 2026 11:03:29 +0200 Subject: [PATCH 15/57] Resolve portable driver paths before applying settings --- .changeset/settings-driver-paths.md | 5 +++++ go/internal/api/api.go | 4 ++++ go/internal/api/api_config_apply_test.go | 28 ++++++++++++++++++++++++ 3 files changed, 37 insertions(+) create mode 100644 .changeset/settings-driver-paths.md diff --git a/.changeset/settings-driver-paths.md b/.changeset/settings-driver-paths.md new file mode 100644 index 00000000..c3f7b3f2 --- /dev/null +++ b/.changeset/settings-driver-paths.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +Keep driver paths consistent when settings are saved. Adding a charger no longer restarts other drivers with paths that fail to load. diff --git a/go/internal/api/api.go b/go/internal/api/api.go index 38bd42dc..aa0af393 100644 --- a/go/internal/api/api.go +++ b/go/internal/api/api.go @@ -1653,6 +1653,10 @@ func (s *Server) handlePostConfig(w http.ResponseWriter, r *http.Request) { writeJSON(w, 400, map[string]string{"error": "validation: " + err.Error()}) return } + // API input carries portable paths, while the registry and file watcher + // use resolved paths. Resolve before comparing or applying so an unrelated + // settings edit cannot restart every driver with a missing relative file. + newCfg.ResolveDriverPaths(filepath.Dir(s.deps.ConfigPath)) // Diff against the live config BEFORE we mutate the shared pointer — // otherwise the comparison would always come back empty. s.deps.CfgMu.RLock() diff --git a/go/internal/api/api_config_apply_test.go b/go/internal/api/api_config_apply_test.go index e17a822a..34b4d2ef 100644 --- a/go/internal/api/api_config_apply_test.go +++ b/go/internal/api/api_config_apply_test.go @@ -3,6 +3,7 @@ package api import ( "net/http" "net/http/httptest" + "path/filepath" "strings" "sync" "testing" @@ -124,3 +125,30 @@ func TestPostConfigRunsTheSharedApplierWithOldSnapshot(t *testing.T) { t.Fatalf("applier oldCfg.SiteMeterDriver() = %q, want the pre-POST snapshot %q", got, "") } } + +func TestPostConfigResolvesDriverPathsBeforeApply(t *testing.T) { + for _, bundled := range []bool{false, true} { + t.Run(map[bool]string{false: "relative-to-config", true: "container-driver-directory"}[bundled], func(t *testing.T) { + oldOverride := config.DriversDirOverride + if bundled { + config.DriversDirOverride = t.TempDir() + } else { + config.DriversDirOverride = "" + } + t.Cleanup(func() { config.DriversDirOverride = oldOverride }) + var appliedPath string + srv, _, cfg := postConfigServer(t, func(next, old *config.Config) { appliedPath = next.Drivers[0].Lua }) + srv.deps.SaveConfig = config.SaveAtomic + if code := postConfig(t, srv, firstSiteMeterConfig); code != 200 { + t.Fatalf("status %d", code) + } + loaded, err := config.Load(srv.deps.ConfigPath) + if err != nil { + t.Fatal(err) + } + if !filepath.IsAbs(appliedPath) || appliedPath != loaded.Drivers[0].Lua || cfg.Drivers[0].Lua != appliedPath { + t.Fatalf("API apply %q, file watcher %q, live %q must agree", appliedPath, loaded.Drivers[0].Lua, cfg.Drivers[0].Lua) + } + }) + } +} From 135c6d51e5c091485651e769130b766746a86212 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Sun, 6 Sep 2026 12:01:10 +0200 Subject: [PATCH 16/57] docs(api): clarify removal of the active charging goal Signed-off-by: Fredrik Ahlgren --- go/internal/api/api.go | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/go/internal/api/api.go b/go/internal/api/api.go index aa0af393..e4f9349c 100644 --- a/go/internal/api/api.go +++ b/go/internal/api/api.go @@ -3849,10 +3849,9 @@ func (s *Server) handleLoadpointSchedulePut(w http.ResponseWriter, r *http.Reque } // DELETE /api/loadpoints/{id}/schedule clears the schedule. Same price -// as PUT: removing the standing instruction is configuration too. The -// one-shot target a previous roll derived stays until it expires — -// clearing the schedule is not a stop button, and stopping a charge in -// progress remains an actuation. +// as PUT: removing the standing instruction is configuration too. It also +// removes the target derived from that schedule. Manual charging has its +// own release action and continues unchanged. func (s *Server) handleLoadpointScheduleClear(w http.ResponseWriter, r *http.Request) { if s.deps.Loadpoints == nil { writeJSON(w, 404, map[string]string{"error": "loadpoints not configured"}) From 9bbc69d7ff111b24a9c51c222e38953aed1f1fc0 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Sun, 6 Sep 2026 12:51:26 +0200 Subject: [PATCH 17/57] fix(ev): retain confirmed levels only for the same charger session Signed-off-by: Fredrik Ahlgren --- .changeset/ev-battery-session-truth.md | 7 + go/internal/events/bus.go | 6 +- go/internal/loadpoint/loadpoint.go | 165 +++++++++++------- .../loadpoint_surplus_withheld_test.go | 4 +- go/internal/loadpoint/loadpoint_test.go | 31 ++-- go/internal/loadpoint/session_events_test.go | 9 +- go/internal/loadpoint/session_sqlite_test.go | 42 +++++ go/internal/loadpoint/session_state.go | 158 +++++++++++++++++ go/internal/loadpoint/session_state_test.go | 128 ++++++++++++++ 9 files changed, 466 insertions(+), 84 deletions(-) create mode 100644 .changeset/ev-battery-session-truth.md create mode 100644 go/internal/loadpoint/session_sqlite_test.go create mode 100644 go/internal/loadpoint/session_state.go create mode 100644 go/internal/loadpoint/session_state_test.go diff --git a/.changeset/ev-battery-session-truth.md b/.changeset/ev-battery-session-truth.md new file mode 100644 index 00000000..d69c0af7 --- /dev/null +++ b/.changeset/ev-battery-session-truth.md @@ -0,0 +1,7 @@ +--- +"ftw": patch +--- + +Keep a confirmed EV battery level across restart only when fresh charger telemetry identifies the same hardware and charging session. Show when the level cannot be retained or the disk write failed. Changing battery capacity preserves the current level and its confidence. + +Treat a car declining current as a separate charging status. It no longer changes the estimated battery level to the target or sends a completed notification. Completion needs a fresh matched vehicle battery reading. diff --git a/go/internal/events/bus.go b/go/internal/events/bus.go index 3628ce6c..27072c48 100644 --- a/go/internal/events/bus.go +++ b/go/internal/events/bus.go @@ -156,9 +156,9 @@ type UpdateInstalled struct { func (UpdateInstalled) Kind() string { return KindUpdateInstalled } -// ChargingSessionComplete is emitted by the loadpoint manager at its -// session-completion latch — the vehicle held "not requesting" past -// SessionCompletionTimeout — which already fires exactly once per plug-in. +// ChargingSessionComplete is emitted once per session when a fresh, +// matched vehicle BMS reading confirms that the active target was reached. +// A charger refusing current does not establish battery state. type ChargingSessionComplete struct { LoadpointID string KWh float64 // what the session meter delivered diff --git a/go/internal/loadpoint/loadpoint.go b/go/internal/loadpoint/loadpoint.go index b9ebfa71..f391dde1 100644 --- a/go/internal/loadpoint/loadpoint.go +++ b/go/internal/loadpoint/loadpoint.go @@ -121,6 +121,12 @@ func (f SiteFuse) Phases() int { // Read-only for consumers — only the Manager or dispatch paths mutate // it under lock. type State struct { + VehicleCapacityWh float64 `json:"vehicle_capacity_wh"` + CapacitySource string `json:"capacity_source"` + // 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. + SoCRetention string `json:"soc_retention,omitempty"` ID string `json:"id"` DriverName string `json:"driver_name"` PluggedIn bool `json:"plugged_in"` @@ -245,6 +251,8 @@ type PlanWindow struct { // Manager holds the running set of loadpoints. Thread-safe. type Manager struct { + sessionMu sync.Mutex + sessionStore SessionStore connectionHealth map[string]bool connectionEdges map[string]connectionEdge mu sync.RWMutex @@ -283,13 +291,8 @@ type Manager struct { bus *events.Bus } -// SessionCompletionTimeout is how long a vehicle must stay connected -// but explicitly not requesting current before the loadpoint treats -// the session as vehicle-side-complete. Tuned to swallow short bursts -// of retry-flap that some EVSEs emit while the vehicle holds steady -// at refusing (observed cycles in the ~10 s–90 s range) without -// snapping on a transient hiccup. Once tripped, the snap persists -// until the cable is unplugged. +// SessionCompletionTimeout debounces a sustained vehicle-side refusal. +// A refusal is not evidence that the battery reached its target. const SessionCompletionTimeout = 90 * time.Second // The interruption hysteresis. A charge that had run steadily for at least @@ -317,6 +320,10 @@ const ( // union of configured parameters and observed state. Lives behind // Manager so consumers access it via the public State snapshot. type loadpointRuntime struct { + sessionDeviceID string + sessionID string + socRetention string + completionNotified bool Config pluggedIn bool @@ -368,17 +375,13 @@ type loadpointRuntime struct { // Drives session-completion (see Observe). notRequestingSince time.Time - // sessionComplete latches once the vehicle has held "not + // chargingDeclined latches once the vehicle has held "not // requesting" past SessionCompletionTimeout for this session. - // While set, the inferred SoC is pinned to targetSoC so the - // MPC stops allocating PV surplus to a sink the vehicle has - // already declined. Cleared on plug-out. - sessionComplete bool - - // socSource, when non-empty, overrides the API layer's - // vehicle-driver attribution in the State snapshot. Set to - // "completed" when sessionComplete is latched so operators can - // see why the inferred SoC pinned at target. + // It suspends planning while the vehicle refuses energy, without + // changing the estimated battery level. Cleared on plug-out. + chargingDeclined bool + + // socSource is reserved for measured battery-level attribution. socSource string // socConfirmed is true only after a level from the user or a matched car. // A configured/default plug-in level remains a planning assumption. @@ -480,8 +483,16 @@ func (m *Manager) SetCommanded(id string, w float64, reason string) { // Load replaces the configured set. Idempotent: existing state is // carried across when the ID is kept; removed IDs are dropped. func (m *Manager) Load(cfgs []Config) { + m.sessionMu.Lock() + defer m.sessionMu.Unlock() + var changedCapacity []string m.mu.Lock() - defer m.mu.Unlock() + defer func() { + m.mu.Unlock() + for _, id := range changedCapacity { + m.persistSession(id) + } + }() newByID := make(map[string]*loadpointRuntime, len(cfgs)) newOrder := make([]string, 0, len(cfgs)) @@ -507,9 +518,19 @@ func (m *Manager) Load(cfgs []Config) { lp.schedule = existing.schedule lp.lastRolledFor = existing.lastRolledFor lp.notRequestingSince = existing.notRequestingSince - lp.sessionComplete = existing.sessionComplete + lp.chargingDeclined = existing.chargingDeclined lp.socSource = existing.socSource lp.socConfirmed = existing.socConfirmed && existing.DriverName == c.DriverName + if existing.DriverName == c.DriverName { + lp.sessionDeviceID = existing.sessionDeviceID + lp.sessionID = existing.sessionID + lp.socRetention = existing.socRetention + lp.completionNotified = existing.completionNotified + } else { + lp.pluggedIn = false + lp.currentSoC = 0 + lp.chargingDeclined = false + } lp.commandedW = existing.commandedW lp.commandedReason = existing.commandedReason lp.commandedKnown = existing.commandedKnown @@ -528,6 +549,16 @@ func (m *Manager) Load(cfgs []Config) { lp.Config.VehicleCapacityWh = existing.VehicleCapacityWh } } + if existing.DriverName == c.DriverName && lp.pluggedIn && existing.VehicleCapacityWh != lp.VehicleCapacityWh { + // A capacity correction changes future Wh-to-SoC conversion, + // not the battery level the user just saw or its confidence. + delivered := 0.0 + if lp.VehicleCapacityWh > 0 { + delivered = lp.deliveredWhSession / lp.VehicleCapacityWh + } + lp.sessionPluginSoC = existing.currentSoC - delivered + changedCapacity = append(changedCapacity, c.ID) + } } newByID[c.ID] = lp newOrder = append(newOrder, c.ID) @@ -610,8 +641,8 @@ func (m *Manager) Configs() []Config { // false on the latter; drivers without that distinction always pass // true and pre-existing behaviour is preserved. After // SessionCompletionTimeout of sustained !requestActive on a connected -// session, the inferred SoC is pinned to targetSoC so the MPC stops -// allocating PV surplus to a sink the vehicle has already declined. +// session, charging_declined tells the planner to stop allocating energy. +// This never changes the battery level or claims that its target was reached. // // No-op for unknown IDs — a misconfigured driver shouldn't crash the // manager. @@ -629,11 +660,15 @@ func (m *Manager) SetSurplusWithheld(id string, withheld bool) { } func (m *Manager) Observe(id string, pluggedIn bool, powerW, deliveredWh float64, requestActive bool) { + m.ObserveSession(id, pluggedIn, powerW, deliveredWh, requestActive, "", "") +} + +func (m *Manager) observe(id string, pluggedIn bool, powerW, deliveredWh float64, requestActive bool) ([]events.Event, *events.Bus) { m.mu.Lock() lp, ok := m.byID[id] if !ok { m.mu.Unlock() - return + return nil, nil } // Events decided under the lock, published after it: the bus runs // handlers inline on the publisher, and a handler that looked back at @@ -653,14 +688,15 @@ func (m *Manager) Observe(id string, pluggedIn bool, powerW, deliveredWh float64 } lp.sessionPluginSoC = anchor lp.socConfirmed = false + lp.completionNotified = false lp.notRequestingSince = time.Time{} - lp.sessionComplete = false + lp.chargingDeclined = false lp.socSource = "" } if !pluggedIn { // Plug-out: drop any pending completion timer / latch. lp.notRequestingSince = time.Time{} - lp.sessionComplete = false + lp.chargingDeclined = false lp.socSource = "" if lp.vehicleName != "" || lp.capacityFromCar { // The identified car left with its session — the next one may @@ -675,7 +711,7 @@ func (m *Manager) Observe(id string, pluggedIn bool, powerW, deliveredWh float64 lp.currentPowerW = powerW lp.deliveredWhSession = deliveredWh - if pluggedIn && !requestActive && lp.surplusWithheld { + if pluggedIn && !requestActive && (lp.surplusWithheld || (lp.commandedKnown && lp.commandedW < DeliveringW)) { // Self-induced "not requesting": we paused this surplus_only // loadpoint below its floor, so the vehicle dropping current is // our doing, not a vehicle-side decline. Do not start/advance the @@ -684,29 +720,22 @@ func (m *Manager) Observe(id string, pluggedIn bool, powerW, deliveredWh float64 // day. Reset the clock so a genuine refusal (once we resume // offering power) is timed from a clean start. lp.notRequestingSince = time.Time{} - } else if pluggedIn && !requestActive { + } else if pluggedIn && !requestActive && (!lp.commandedKnown || lp.commandedW >= DeliveringW) { // Vehicle has explicitly stopped requesting current while we ARE // offering power. Start (or continue) the completion timer; latch // once it elapses. if lp.notRequestingSince.IsZero() { lp.notRequestingSince = now } - if !lp.sessionComplete && lp.targetSoC > 0 && + if !lp.chargingDeclined && lp.targetSoC > 0 && !lp.notRequestingSince.IsZero() && now.Sub(lp.notRequestingSince) >= SessionCompletionTimeout { - lp.sessionComplete = true - lp.socSource = "completed" - // The latch is the once-per-session moment, so it is the - // publish point: nothing downstream needs its own dedupe. - fired = append(fired, events.ChargingSessionComplete{ - LoadpointID: id, - KWh: deliveredWh / 1000, - At: now, - }) + lp.chargingDeclined = true + } } else if pluggedIn && requestActive { // Vehicle is back to requesting. Reset the timer, but keep - // sessionComplete latched — once a vehicle has declined this + // chargingDeclined latched — once a vehicle has declined this // session, treating it as "still hungry" the moment an EVSE // retry briefly succeeds would reopen the export hole the // completion latch exists to close. Plug-cycle to reset. @@ -741,7 +770,7 @@ func (m *Manager) Observe(id string, pluggedIn bool, powerW, deliveredWh float64 // that stopped requesting chose to stop. Neither is a failure. selfInflicted := lp.surplusWithheld || (lp.commandedKnown && lp.commandedW < steadyChargeFloorW) - if lp.steadyRunArmed && !lp.sessionComplete && requestActive && + if lp.steadyRunArmed && !lp.chargingDeclined && requestActive && !selfInflicted && !lp.stoppedSince.IsZero() && now.Sub(lp.stoppedSince) >= interruptConfirm { lp.steadyRunArmed = false @@ -763,30 +792,21 @@ func (m *Manager) Observe(id string, pluggedIn bool, powerW, deliveredWh float64 // kilowatt-hours go in; if the car declines once more, the timer // re-arms it. A 900 W renegotiation burst stays below the steady // floor and never gets here. - if pluggedIn && lp.sessionComplete && lp.steadyRunArmed && requestActive && powerW >= steadyChargeFloorW { - lp.sessionComplete = false + if pluggedIn && lp.chargingDeclined && lp.steadyRunArmed && requestActive && powerW >= steadyChargeFloorW { + lp.chargingDeclined = false lp.socSource = "" } if pluggedIn { - if lp.sessionComplete && lp.targetSoC > 0 { - // Snap the inferred SoC to target; the planner reads - // currentSoC as the MPC LoadpointSpec.InitialSoC, - // so InitialSoC == TargetSoC → DP allocates 0 W. - lp.currentSoC = lp.targetSoC - } else { - lp.currentSoC = estimateSoC(lp.sessionPluginSoC, - deliveredWh, lp.VehicleCapacityWh) - } + lp.currentSoC = estimateSoC(lp.sessionPluginSoC, + deliveredWh, lp.VehicleCapacityWh) } else { lp.currentSoC = 0 } lp.updatedAtMs = now.UnixMilli() m.mu.Unlock() - for _, e := range fired { - bus.Publish(e) - } + return fired, bus } // now returns the manager's clock, defaulting to time.Now when nowFn @@ -958,21 +978,20 @@ 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 m.mu.Unlock() + defer func() { m.mu.Unlock(); m.persistSession(id) }() lp, ok := m.byID[id] if !ok { return false } - if !lp.pluggedIn { + if !lp.pluggedIn || !finite(socPct) { return false } - // An operator who sets the level is telling us the latch's guess - // ("declined, so it must be at target") was wrong. Drop it, or the - // next Observe pins the estimate straight back to targetSoC and the - // slider looks broken. If the car really does decline, the latch - // re-arms after SessionCompletionTimeout as usual. - lp.sessionComplete = false + // A correction gives the planner another chance to offer energy. + // Sustained refusal can re-arm after SessionCompletionTimeout. + lp.chargingDeclined = false lp.socSource = "" lp.notRequestingSince = time.Time{} reanchorSoCLocked(lp, socPct) @@ -999,15 +1018,29 @@ func (m *Manager) SetCurrentSoC(id string, socPct float64) bool { // // Returns false for unknown IDs or when the loadpoint is unplugged. func (m *Manager) AnchorVehicleSoC(id string, socPct float64) bool { + m.sessionMu.Lock() m.mu.Lock() - defer m.mu.Unlock() + var completion *events.ChargingSessionComplete + bus := m.bus + defer func() { + m.mu.Unlock() + m.persistSession(id) + m.sessionMu.Unlock() + if completion != nil { + bus.Publish(*completion) + } + }() lp, ok := m.byID[id] if !ok { return false } - if !lp.pluggedIn { + if !lp.pluggedIn || !finite(socPct) || socPct < 0 || socPct > 1 { return false } + if lp.targetSoC > 0 && socPct >= lp.targetSoC && !lp.completionNotified { + lp.completionNotified = true + completion = &events.ChargingSessionComplete{LoadpointID: id, KWh: lp.deliveredWhSession / 1000, At: m.now()} + } reanchorSoCLocked(lp, socPct) return true } @@ -1037,6 +1070,8 @@ func (lp *loadpointRuntime) snapshot() State { copy(steps, lp.AllowedStepsW) sort.Float64s(steps) st := State{ + VehicleCapacityWh: lp.VehicleCapacityWh, + CapacitySource: "configured", ID: lp.ID, DriverName: lp.DriverName, PluggedIn: lp.pluggedIn, @@ -1052,11 +1087,17 @@ func (lp *loadpointRuntime) snapshot() State { SurplusOnly: lp.Config.SurplusOnly, Schedule: lp.schedule, SoCSource: lp.socSource, + ChargingDeclined: lp.chargingDeclined, + SoCRetention: lp.socRetention, VehicleName: lp.vehicleName, CommandedW: lp.commandedW, CommandedReason: lp.commandedReason, CommandedKnown: lp.commandedKnown, } + if lp.VehicleCapacityWh <= 0 { + st.VehicleCapacityWh = 60000 + st.CapacitySource = "default" + } if st.PluggedIn && st.SoCSource == "" && !lp.socConfirmed { st.SoCSource = "assumed" } diff --git a/go/internal/loadpoint/loadpoint_surplus_withheld_test.go b/go/internal/loadpoint/loadpoint_surplus_withheld_test.go index e5bfbafe..dc4be0ba 100644 --- a/go/internal/loadpoint/loadpoint_surplus_withheld_test.go +++ b/go/internal/loadpoint/loadpoint_surplus_withheld_test.go @@ -28,7 +28,7 @@ func TestSelfWithheldNCRQDoesNotComplete(t *testing.T) { clock = clock.Add(5 * time.Minute) // well past the 90s completion timeout m.Observe("garage", true, 0, 0, false) - if st, _ := m.State("garage"); st.SoCSource == "completed" { + if st, _ := m.State("garage"); st.ChargingDeclined { t.Errorf("self-withheld NCRQ must not latch session complete: %+v", st) } } @@ -60,7 +60,7 @@ func TestGenuineNCRQStillCompletesAfterWithheldClears(t *testing.T) { clock = clock.Add(2 * time.Minute) // past 90s of genuine refusal m.Observe("garage", true, 0, 0, false) - if st, _ := m.State("garage"); st.SoCSource != "completed" { + if st, _ := m.State("garage"); !st.ChargingDeclined { t.Errorf("genuine NCRQ after withheld clears should complete: %+v", st) } } diff --git a/go/internal/loadpoint/loadpoint_test.go b/go/internal/loadpoint/loadpoint_test.go index 315394fe..415c1acd 100644 --- a/go/internal/loadpoint/loadpoint_test.go +++ b/go/internal/loadpoint/loadpoint_test.go @@ -1,6 +1,7 @@ package loadpoint import ( + "math" "testing" "time" ) @@ -209,7 +210,7 @@ func TestStatesReturnsAllInOrder(t *testing.T) { } } -// TestSessionCompletionSnapsToTarget walks the scenario where the +// TestVehicleDeclineDoesNotInventTargetSoC walks the scenario where the // vehicle charges normally, then explicitly stops requesting current // for a sustained window (typically because it hit its own onboard // SoC target or its onboard schedule ended). Without the completion @@ -217,7 +218,7 @@ func TestStatesReturnsAllInOrder(t *testing.T) { // the MPC would keep allocating PV surplus to a phantom sink, spilling // it to the grid. With the latch the inferred SoC snaps to the target // and the planner sees the EV as done. -func TestSessionCompletionSnapsToTarget(t *testing.T) { +func TestVehicleDeclineDoesNotInventTargetSoC(t *testing.T) { m := NewManager() m.Load([]Config{{ ID: "garage", DriverName: "evse-test", @@ -245,14 +246,14 @@ func TestSessionCompletionSnapsToTarget(t *testing.T) { // delivered_wh frozen, power drops to 0. clock = clock.Add(6 * time.Second) m.Observe("garage", true, 0, 1000, false) - if st, _ := m.State("garage"); st.SoCSource == "completed" { + if st, _ := m.State("garage"); st.ChargingDeclined { t.Errorf("first not-requesting tick should NOT yet complete (under threshold): %+v", st) } // Tick 4 (T+30s of not-requesting): below threshold — still not completed. clock = clock.Add(30 * time.Second) m.Observe("garage", true, 0, 1000, false) - if st, _ := m.State("garage"); st.SoCSource == "completed" { + if st, _ := m.State("garage"); st.ChargingDeclined { t.Errorf("30s not-requesting should NOT yet complete (under 90s threshold): %+v", st) } @@ -260,10 +261,10 @@ func TestSessionCompletionSnapsToTarget(t *testing.T) { clock = clock.Add(60 * time.Second) m.Observe("garage", true, 0, 1000, false) st, _ := m.State("garage") - if st.SoCSource != "completed" { + if !st.ChargingDeclined { t.Errorf("expected SoCSource='completed' after threshold, got %q (state=%+v)", st.SoCSource, st) } - if st.CurrentSoC != 0.6 { + if math.Abs(st.CurrentSoC-(0.2+1000.0/60000)) > 1e-9 { t.Errorf("expected inferred SoC pinned to target 60, got %.2f", st.CurrentSoC) } @@ -274,7 +275,7 @@ func TestSessionCompletionSnapsToTarget(t *testing.T) { // clears it. clock = clock.Add(15 * time.Second) m.Observe("garage", true, 0, 1000, true) - if st, _ := m.State("garage"); st.SoCSource != "completed" || st.CurrentSoC != 0.6 { + if st, _ := m.State("garage"); !st.ChargingDeclined || math.Abs(st.CurrentSoC-(0.2+1000.0/60000)) > 1e-9 { t.Errorf("brief request_active flicker should not clear latch: %+v", st) } @@ -306,7 +307,7 @@ func TestSessionCompletionRequiresTarget(t *testing.T) { m.Observe("garage", true, 0, 0, false) st, _ := m.State("garage") - if st.SoCSource == "completed" { + if st.ChargingDeclined { t.Errorf("completion should not trigger without a target: %+v", st) } } @@ -332,7 +333,7 @@ func TestRequestActiveDefaultPreservesInference(t *testing.T) { clock = clock.Add(5 * time.Minute) m.Observe("garage", true, 7400, 600, true) // 600 Wh in → SoC = 30 + 1 st, _ := m.State("garage") - if st.SoCSource == "completed" { + if st.ChargingDeclined { t.Errorf("request_active=true must not trigger completion: %+v", st) } if st.CurrentSoC < 0.305 || st.CurrentSoC > 0.315 { @@ -359,7 +360,7 @@ func latchedManager(t *testing.T) (*Manager, *time.Time) { clock = clock.Add(SessionCompletionTimeout) m.Observe("garage", true, 0, 1000, false) st, _ := m.State("garage") - if st.SoCSource != "completed" || st.CurrentSoC != 0.6 { + if !st.ChargingDeclined || math.Abs(st.CurrentSoC-(0.2+1000.0/60000)) > 1e-9 { t.Fatalf("precondition: latch should have fired, got %+v", st) } return m, &clock @@ -377,7 +378,7 @@ func TestSetCurrentSoCClearsCompletionLatch(t *testing.T) { *clock = clock.Add(3 * time.Second) m.Observe("garage", true, 0, 1000, false) // still not requesting, timer restarts st, _ := m.State("garage") - if st.SoCSource == "completed" { + if st.ChargingDeclined { t.Errorf("latch should be cleared by the operator's correction: %+v", st) } if st.CurrentSoC < 0.49 || st.CurrentSoC > 0.51 { @@ -386,7 +387,7 @@ func TestSetCurrentSoCClearsCompletionLatch(t *testing.T) { // A car that keeps declining re-arms the latch after the timeout. *clock = clock.Add(SessionCompletionTimeout) m.Observe("garage", true, 0, 1000, false) - if st, _ := m.State("garage"); st.SoCSource != "completed" || st.CurrentSoC != 0.6 { + if st, _ := m.State("garage"); !st.ChargingDeclined || math.Abs(st.CurrentSoC-0.5) > 1e-9 { t.Errorf("latch should re-arm after sustained not-requesting: %+v", st) } } @@ -401,18 +402,18 @@ func TestSustainedChargingClearsCompletionLatch(t *testing.T) { // Charging resumes at full power; a brief run must not release. *clock = clock.Add(5 * time.Second) m.Observe("garage", true, 11000, 1500, true) - if st, _ := m.State("garage"); st.SoCSource != "completed" || st.CurrentSoC != 0.6 { + if st, _ := m.State("garage"); !st.ChargingDeclined || math.Abs(st.CurrentSoC-(0.2+1500.0/60000)) > 1e-9 { t.Fatalf("a fresh run must not release the latch yet: %+v", st) } *clock = clock.Add(InterruptSteadyRun / 2) m.Observe("garage", true, 11000, 3000, true) - if st, _ := m.State("garage"); st.SoCSource != "completed" { + if st, _ := m.State("garage"); !st.ChargingDeclined { t.Fatalf("half a steady run must not release the latch: %+v", st) } *clock = clock.Add(InterruptSteadyRun/2 + time.Second) m.Observe("garage", true, 11000, 4000, true) st, _ := m.State("garage") - if st.SoCSource == "completed" { + if st.ChargingDeclined { t.Errorf("a full steady run should release the latch: %+v", st) } // anchor 0.2 + 4000/60000 ≈ 0.267 — the estimate moved off the pin. diff --git a/go/internal/loadpoint/session_events_test.go b/go/internal/loadpoint/session_events_test.go index 52e4cab0..232492d3 100644 --- a/go/internal/loadpoint/session_events_test.go +++ b/go/internal/loadpoint/session_events_test.go @@ -81,6 +81,10 @@ func TestSessionCompletePublishesOnceWithSessionKWh(t *testing.T) { t.Fatal("latched before the timeout") } r.tick(SessionCompletionTimeout, true, 0, 7_420, false) + if c, _ := r.log.counts(); c != 0 { + t.Fatal("refusal invented a completed goal") + } + r.mgr.AnchorVehicleSoC("garage", .8) if c, _ := r.log.counts(); c != 1 { t.Fatalf("complete events = %d, want 1", c) } @@ -102,6 +106,7 @@ func TestSessionCompletePublishesOnceWithSessionKWh(t *testing.T) { r.tick(time.Minute, true, 11_000, 0, true) r.tick(time.Minute, true, 0, 500, false) r.tick(SessionCompletionTimeout, true, 0, 500, false) + r.mgr.AnchorVehicleSoC("garage", .8) if c, _ := r.log.counts(); c != 2 { t.Fatalf("complete events = %d after replug, want 2", c) } @@ -189,8 +194,8 @@ func TestVehicleDeclineIsNotAnInterruption(t *testing.T) { r.tick(time.Second, true, 0, 8_000, false) r.tick(interruptConfirm+SessionCompletionTimeout, true, 0, 8_000, false) c, i := r.log.counts() - if c != 1 { - t.Fatalf("complete events = %d, want 1", c) + if c != 0 { + t.Fatalf("complete events = %d, want 0", c) } if i != 0 { t.Fatalf("interrupted events = %d for a finished car, want 0", i) diff --git a/go/internal/loadpoint/session_sqlite_test.go b/go/internal/loadpoint/session_sqlite_test.go new file mode 100644 index 00000000..23826162 --- /dev/null +++ b/go/internal/loadpoint/session_sqlite_test.go @@ -0,0 +1,42 @@ +package loadpoint_test + +import ( + "math" + "path/filepath" + "testing" + + "github.com/srcfl/ftw/go/internal/loadpoint" + "github.com/srcfl/ftw/go/internal/state" +) + +func TestConfirmedBatteryLevelSurvivesDatabaseCloseAndReopen(t *testing.T) { + path := filepath.Join(t.TempDir(), "state.db") + store, err := state.Open(path) + if err != nil { + t.Fatal(err) + } + cfg := []loadpoint.Config{{ID: "garage", DriverName: "charger", VehicleCapacityWh: 60000}} + m := loadpoint.NewManager() + m.Load(cfg) + m.SetSessionStore(store) + m.ObserveSession("garage", true, 4300, 9000, true, "easee:TEST", "728:2026-01-01T08:00:00Z") + if !m.SetCurrentSoC("garage", .84) { + t.Fatal("level refused") + } + if err := store.Close(); err != nil { + t.Fatal(err) + } + store, err = state.Open(path) + if err != nil { + t.Fatal(err) + } + defer store.Close() + m = loadpoint.NewManager() + m.Load(cfg) + m.SetSessionStore(store) + m.ObserveSession("garage", true, 4300, 9600, true, "easee:TEST", "728:2026-01-01T08:00:00Z") + s, _ := m.State("garage") + if math.Abs(s.CurrentSoC-.85) > 1e-9 || s.SoCSource == "assumed" || s.SoCRetention != "session" { + t.Fatalf("restart did not retain level: %+v", s) + } +} diff --git a/go/internal/loadpoint/session_state.go b/go/internal/loadpoint/session_state.go new file mode 100644 index 00000000..d905e702 --- /dev/null +++ b/go/internal/loadpoint/session_state.go @@ -0,0 +1,158 @@ +package loadpoint + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "math" + "strings" + + "github.com/srcfl/ftw/go/internal/events" +) + +// SessionStore is implemented by state.Store. The charger hardware identity, +// never a loadpoint or driver name, keys the saved record. +type SessionStore interface { + LoadConfig(key string) (string, bool) + SaveConfig(key, value string) error +} + +type savedSession struct { + Version int `json:"version"` + DeviceID string `json:"device_id"` + SessionID string `json:"session_id"` + AnchorSoC float64 `json:"anchor_soc"` + ConfirmedAtWh float64 `json:"confirmed_at_wh"` + CapacityWh float64 `json:"capacity_wh"` + CompletionNotified bool `json:"completion_notified,omitempty"` +} + +func sessionKey(deviceID string) string { + h := sha256.Sum256([]byte(deviceID)) + return "ev_session:" + hex.EncodeToString(h[:]) +} + +func finite(v float64) bool { return !math.IsNaN(v) && !math.IsInf(v, 0) } + +// SetSessionStore wires durable storage before the controller starts. A +// missing store or hardware session identity leaves SoC usable in memory and +// reports soc_retention=unavailable; it never guesses a prior car's level. +func (m *Manager) SetSessionStore(store SessionStore) { + m.sessionMu.Lock() + defer m.sessionMu.Unlock() + m.sessionStore = store +} + +// ObserveSession accepts the same reading as Observe plus hardware-issued +// identity. The caller must use fresh telemetry from the currently running +// device. SessionID must identify one physical connection across process +// restart and change after disconnect; missing or ambiguous IDs are empty. +// Endpoint addresses, YAML names and timestamps invented by core are not IDs. +func (m *Manager) ObserveSession(id string, pluggedIn bool, powerW, deliveredWh float64, requestActive bool, deviceID, sessionID string) { + m.sessionMu.Lock() + var fired []events.Event + var bus *events.Bus + defer func() { + m.sessionMu.Unlock() + for _, event := range fired { + bus.Publish(event) + } + }() + deviceID, sessionID = strings.TrimSpace(deviceID), strings.TrimSpace(sessionID) + // Endpoint identity can move to another charger without its name changing. + if strings.HasPrefix(deviceID, "ep:") { + deviceID = "" + } + if !finite(deliveredWh) || deliveredWh < 0 { + return + } + + m.mu.Lock() + lp := m.byID[id] + if lp == nil { + m.mu.Unlock() + return + } + previousDevice, previousSession := lp.sessionDeviceID, lp.sessionID + changed := previousDevice != deviceID || previousSession != sessionID + regressed := pluggedIn && lp.pluggedIn && deliveredWh < lp.deliveredWhSession + // 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.pluggedIn = false + } + lp.sessionDeviceID, lp.sessionID = deviceID, sessionID + confirmed := lp.socConfirmed && lp.pluggedIn + m.mu.Unlock() + + if !pluggedIn || 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 && previousDevice != "" { + _ = m.sessionStore.SaveConfig(sessionKey(previousDevice), "{}") + } + } + var restore *savedSession + if pluggedIn && !confirmed && !regressed && deviceID != "" && sessionID != "" && m.sessionStore != nil { + if raw, ok := m.sessionStore.LoadConfig(sessionKey(deviceID)); ok { + var saved savedSession + if json.Unmarshal([]byte(raw), &saved) == nil && saved.Version == 1 && + saved.DeviceID == deviceID && saved.SessionID == sessionID && + finite(saved.AnchorSoC) && finite(saved.ConfirmedAtWh) && saved.ConfirmedAtWh >= 0 && + deliveredWh >= saved.ConfirmedAtWh && finite(saved.CapacityWh) && saved.CapacityWh > 0 { + atConfirmation := saved.AnchorSoC + saved.ConfirmedAtWh/saved.CapacityWh + if atConfirmation >= 0 && atConfirmation <= 1 { + restore = &saved + } + } + } + } + fired, bus = m.observe(id, pluggedIn, powerW, deliveredWh, requestActive) + m.mu.Lock() + lp = m.byID[id] + if restore != nil && lp.pluggedIn && lp.VehicleCapacityWh == restore.CapacityWh { + lp.sessionPluginSoC = restore.AnchorSoC + lp.currentSoC = estimateSoC(restore.AnchorSoC, deliveredWh, restore.CapacityWh) + lp.socConfirmed = true + lp.completionNotified = restore.CompletionNotified + lp.socRetention = "session" + } else if !lp.socConfirmed || deviceID == "" || sessionID == "" || m.sessionStore == nil { + lp.socRetention = "unavailable" + } + m.mu.Unlock() +} + +// persistSession runs outside Manager.mu, but sessionMu serializes it with +// observations, unplug and user edits. A slow write cannot block API reads or +// allow an older edit to overwrite a newer one. +func (m *Manager) persistSession(id string) { + m.mu.RLock() + lp := m.byID[id] + if lp == nil { + m.mu.RUnlock() + return + } + record := savedSession{Version: 1, DeviceID: lp.sessionDeviceID, SessionID: lp.sessionID, + AnchorSoC: lp.sessionPluginSoC, ConfirmedAtWh: lp.deliveredWhSession, + CapacityWh: lp.VehicleCapacityWh, CompletionNotified: lp.completionNotified} + eligible := lp.pluggedIn && lp.socConfirmed && record.DeviceID != "" && record.SessionID != "" && + finite(record.AnchorSoC) && finite(record.ConfirmedAtWh) && record.ConfirmedAtWh >= 0 && + finite(record.CapacityWh) && record.CapacityWh > 0 + m.mu.RUnlock() + retention := "unavailable" + if eligible && m.sessionStore != nil { + b, err := json.Marshal(record) + if err == nil { + err = m.sessionStore.SaveConfig(sessionKey(record.DeviceID), string(b)) + } + retention = "session" + if err != nil { + retention = "error" + } + } + m.mu.Lock() + if lp := m.byID[id]; lp != nil { + lp.socRetention = retention + } + m.mu.Unlock() +} diff --git a/go/internal/loadpoint/session_state_test.go b/go/internal/loadpoint/session_state_test.go new file mode 100644 index 00000000..620c52ad --- /dev/null +++ b/go/internal/loadpoint/session_state_test.go @@ -0,0 +1,128 @@ +package loadpoint + +import ( + "errors" + "math" + "testing" +) + +type sessionMemory struct { + data map[string]string + fail bool +} + +func (s *sessionMemory) LoadConfig(k string) (string, bool) { v, ok := s.data[k]; return v, ok } +func (s *sessionMemory) SaveConfig(k, v string) error { + if s.fail { + return errors.New("disk full") + } + s.data[k] = v + return nil +} +func sessionManager(store SessionStore, id, driver string) *Manager { + m := NewManager() + m.Load([]Config{{ID: id, DriverName: driver, VehicleCapacityWh: 60000}}) + m.SetSessionStore(store) + return m +} +func TestConfirmedSoCSurvivesRestartOnlyForSameHardwareSession(t *testing.T) { + store := &sessionMemory{data: map[string]string{}} + m := sessionManager(store, "garage", "charger") + m.ObserveSession("garage", true, 4300, 9000, true, "easee:ABC", "connection-1") + m.SetCurrentSoC("garage", .84) + if s, _ := m.State("garage"); s.SoCRetention != "session" { + t.Fatalf("not saved: %+v", s) + } + // Renaming the configured driver and loadpoint does not change hardware. + m = sessionManager(store, "renamed", "renamed-driver") + m.ObserveSession("renamed", true, 4300, 9600, true, "easee:ABC", "connection-1") + if s, _ := m.State("renamed"); math.Abs(s.CurrentSoC-.85) > 1e-9 || s.SoCSource == "assumed" { + t.Fatalf("same session failed to restore: %+v", s) + } + for _, tc := range []struct { + name, device, session string + wh float64 + }{ + {"new session", "easee:ABC", "connection-2", 9600}, + {"new charger", "easee:OTHER", "connection-1", 9600}, + {"no session", "easee:ABC", "", 9600}, + {"endpoint identity", "ep:192.168.1.40", "connection-1", 9600}, + {"counter reset", "easee:ABC", "connection-1", 500}, + } { + t.Run(tc.name, func(t *testing.T) { + m := sessionManager(store, "garage", "charger") + m.ObserveSession("garage", true, 0, tc.wh, true, tc.device, tc.session) + if s, _ := m.State("garage"); s.SoCSource != "assumed" { + t.Fatalf("restored wrong car: %+v", s) + } + }) + } +} +func TestUnseenReconnectResetsConfirmedSoC(t *testing.T) { + store := &sessionMemory{data: map[string]string{}} + m := sessionManager(store, "garage", "charger") + m.ObserveSession("garage", true, 4300, 9000, true, "easee:ABC", "session-1") + m.SetCurrentSoC("garage", .84) + // A charger returns after an outage. No unplug sample reached core. + m.ObserveSession("garage", true, 4300, 12000, true, "easee:ABC", "session-2") + if s, _ := m.State("garage"); s.SoCSource != "assumed" { + t.Fatalf("previous car level leaked: %+v", s) + } +} +func TestObservedUnplugErasesConfirmedSoC(t *testing.T) { + store := &sessionMemory{data: map[string]string{}} + m := sessionManager(store, "garage", "charger") + m.ObserveSession("garage", true, 4300, 9000, true, "easee:ABC", "session-1") + m.SetCurrentSoC("garage", .84) + m.ObserveSession("garage", false, 0, 0, false, "easee:ABC", "") + m = sessionManager(store, "garage", "charger") + m.ObserveSession("garage", true, 0, 9000, true, "easee:ABC", "session-1") + if s, _ := m.State("garage"); s.SoCSource != "assumed" { + t.Fatalf("unplug resurrected old level: %+v", s) + } +} +func TestSessionSaveFailureIsVisibleAndRetryable(t *testing.T) { + store := &sessionMemory{data: map[string]string{}, fail: true} + m := sessionManager(store, "garage", "charger") + m.ObserveSession("garage", true, 4300, 9000, true, "easee:ABC", "session-1") + m.SetCurrentSoC("garage", .84) + if s, _ := m.State("garage"); s.SoCRetention != "error" || math.Abs(s.CurrentSoC-.84) > 1e-9 { + t.Fatalf("failed write hidden: %+v", s) + } + store.fail = false + m.SetCurrentSoC("garage", .84) + if s, _ := m.State("garage"); s.SoCRetention != "session" { + t.Fatalf("retry failed: %+v", s) + } +} +func TestUnknownSessionKeepsManualLevelOnlyInMemory(t *testing.T) { + m := sessionManager(&sessionMemory{data: map[string]string{}}, "garage", "charger") + m.Observe("garage", true, 4300, 9000, true) + m.SetCurrentSoC("garage", .84) + m.Observe("garage", true, 4300, 9600, true) + if s, _ := m.State("garage"); s.SoCRetention != "unavailable" || math.Abs(s.CurrentSoC-.85) > 1e-9 { + t.Fatalf("unsupported charger lost input: %+v", s) + } +} + +func TestCapacityChangeKeepsCurrentLevelAndConfidence(t *testing.T) { + for _, confirmed := range []bool{false, true} { + m := sessionManager(&sessionMemory{data: map[string]string{}}, "garage", "charger") + m.ObserveSession("garage", true, 4300, 9000, true, "easee:ABC", "session-1") + if confirmed { + m.SetCurrentSoC("garage", .84) + } + before, _ := m.State("garage") + m.Load([]Config{{ID: "garage", DriverName: "charger", VehicleCapacityWh: 100000}}) + m.ObserveSession("garage", true, 4300, 9000, true, "easee:ABC", "session-1") + after, _ := m.State("garage") + if math.Abs(after.CurrentSoC-before.CurrentSoC) > 1e-9 || after.SoCSource != before.SoCSource { + t.Fatalf("capacity changed level or confidence: before=%+v after=%+v", before, after) + } + m.ObserveSession("garage", true, 4300, 10000, true, "easee:ABC", "session-1") + after, _ = m.State("garage") + if math.Abs(after.CurrentSoC-before.CurrentSoC-.01) > 1e-9 { + t.Fatalf("new capacity not used: %+v", after) + } + } +} From f9e9ecad69a33eb360b90e321aca3468abc0c572 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Sun, 6 Sep 2026 12:54:52 +0200 Subject: [PATCH 18/57] fix(ev): resume planning when a car accepts energy again Signed-off-by: Fredrik Ahlgren --- .changeset/ev-battery-session-truth.md | 2 + go/internal/loadpoint/loadpoint.go | 46 +++++++++++---------- go/internal/loadpoint/loadpoint_test.go | 54 ++++++++++++------------- 3 files changed, 54 insertions(+), 48 deletions(-) diff --git a/.changeset/ev-battery-session-truth.md b/.changeset/ev-battery-session-truth.md index d69c0af7..df51e5b5 100644 --- a/.changeset/ev-battery-session-truth.md +++ b/.changeset/ev-battery-session-truth.md @@ -5,3 +5,5 @@ Keep a confirmed EV battery level across restart only when fresh charger telemetry identifies the same hardware and charging session. Show when the level cannot be retained or the disk write failed. Changing battery capacity preserves the current level and its confidence. Treat a car declining current as a separate charging status. It no longer changes the estimated battery level to the target or sends a completed notification. Completion needs a fresh matched vehicle battery reading. + +A higher goal, an explicit retry or measured charging lets the planner resume after a prior refusal. diff --git a/go/internal/loadpoint/loadpoint.go b/go/internal/loadpoint/loadpoint.go index f391dde1..8249329f 100644 --- a/go/internal/loadpoint/loadpoint.go +++ b/go/internal/loadpoint/loadpoint.go @@ -381,8 +381,6 @@ type loadpointRuntime struct { // changing the estimated battery level. Cleared on plug-out. chargingDeclined bool - // socSource is reserved for measured battery-level attribution. - socSource string // socConfirmed is true only after a level from the user or a matched car. // A configured/default plug-in level remains a planning assumption. socConfirmed bool @@ -519,7 +517,6 @@ func (m *Manager) Load(cfgs []Config) { lp.lastRolledFor = existing.lastRolledFor lp.notRequestingSince = existing.notRequestingSince lp.chargingDeclined = existing.chargingDeclined - lp.socSource = existing.socSource lp.socConfirmed = existing.socConfirmed && existing.DriverName == c.DriverName if existing.DriverName == c.DriverName { lp.sessionDeviceID = existing.sessionDeviceID @@ -691,13 +688,11 @@ func (m *Manager) observe(id string, pluggedIn bool, powerW, deliveredWh float64 lp.completionNotified = false lp.notRequestingSince = time.Time{} lp.chargingDeclined = false - lp.socSource = "" } if !pluggedIn { // Plug-out: drop any pending completion timer / latch. lp.notRequestingSince = time.Time{} lp.chargingDeclined = false - lp.socSource = "" 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. @@ -711,7 +706,12 @@ func (m *Manager) observe(id string, pluggedIn bool, powerW, deliveredWh float64 lp.currentPowerW = powerW lp.deliveredWhSession = deliveredWh - if pluggedIn && !requestActive && (lp.surplusWithheld || (lp.commandedKnown && lp.commandedW < DeliveringW)) { + if pluggedIn && powerW >= DeliveringW { + // Measured energy delivery is stronger evidence than a delayed + // request_active flag. Let planning follow the car immediately. + lp.chargingDeclined = false + lp.notRequestingSince = time.Time{} + } else if pluggedIn && !requestActive && (lp.surplusWithheld || (lp.commandedKnown && lp.commandedW < DeliveringW)) { // Self-induced "not requesting": we paused this surplus_only // loadpoint below its floor, so the vehicle dropping current is // our doing, not a vehicle-side decline. Do not start/advance the @@ -731,7 +731,6 @@ func (m *Manager) observe(id string, pluggedIn bool, powerW, deliveredWh float64 !lp.notRequestingSince.IsZero() && now.Sub(lp.notRequestingSince) >= SessionCompletionTimeout { lp.chargingDeclined = true - } } else if pluggedIn && requestActive { // Vehicle is back to requesting. Reset the timer, but keep @@ -785,18 +784,6 @@ func (m *Manager) observe(id string, pluggedIn bool, powerW, deliveredWh float64 lp.steadyRunArmed = false } - // InterruptSteadyRun of real current after the completion latch is - // not an EVSE retry blip: the car is taking energy again (operator - // Start, a raised charge limit in the car). Release the latch so the - // estimate follows delivered Wh instead of sitting at targetSoC while - // kilowatt-hours go in; if the car declines once more, the timer - // re-arms it. A 900 W renegotiation burst stays below the steady - // floor and never gets here. - if pluggedIn && lp.chargingDeclined && lp.steadyRunArmed && requestActive && powerW >= steadyChargeFloorW { - lp.chargingDeclined = false - lp.socSource = "" - } - if pluggedIn { lp.currentSoC = estimateSoC(lp.sessionPluginSoC, deliveredWh, lp.VehicleCapacityWh) @@ -858,6 +845,10 @@ func (m *Manager) SetTarget(id string, soc float64, targetTime time.Time) bool { if !ok { return false } + if units.ClampFraction(soc) > lp.targetSoC { + lp.chargingDeclined = false + lp.notRequestingSince = time.Time{} + } lp.targetSoC = units.ClampFraction(soc) lp.targetTime = targetTime return true @@ -992,7 +983,6 @@ func (m *Manager) SetCurrentSoC(id string, socPct float64) bool { // A correction gives the planner another chance to offer energy. // Sustained refusal can re-arm after SessionCompletionTimeout. lp.chargingDeclined = false - lp.socSource = "" lp.notRequestingSince = time.Time{} reanchorSoCLocked(lp, socPct) return true @@ -1086,7 +1076,6 @@ func (lp *loadpointRuntime) snapshot() State { AllowedStepsW: steps, SurplusOnly: lp.Config.SurplusOnly, Schedule: lp.schedule, - SoCSource: lp.socSource, ChargingDeclined: lp.chargingDeclined, SoCRetention: lp.socRetention, VehicleName: lp.vehicleName, @@ -1130,6 +1119,10 @@ func (m *Manager) SetSchedule(id string, s Schedule) bool { // 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 s.SoC > lp.schedule.SoC { + lp.chargingDeclined = false + lp.notRequestingSince = time.Time{} + } lp.schedule = s // Force RollSchedules to re-evaluate on next call — operator just // changed the contract so any previous idempotence cache is stale. @@ -1245,3 +1238,14 @@ func (m *Manager) RollSchedules(now time.Time) { } } } + +// RetryCharging lets an explicit Start action retry a vehicle that previously +// declined current. It changes no battery level or stored user intent. +func (m *Manager) RetryCharging(id string) { + m.mu.Lock() + defer m.mu.Unlock() + if lp := m.byID[id]; lp != nil { + lp.chargingDeclined = false + lp.notRequestingSince = time.Time{} + } +} diff --git a/go/internal/loadpoint/loadpoint_test.go b/go/internal/loadpoint/loadpoint_test.go index 415c1acd..cfbad190 100644 --- a/go/internal/loadpoint/loadpoint_test.go +++ b/go/internal/loadpoint/loadpoint_test.go @@ -392,38 +392,38 @@ func TestSetCurrentSoCClearsCompletionLatch(t *testing.T) { } } -// Same morning, later: a manual charge pushed 11 kW into the car for -// forty minutes and the estimate still read 80 %, because the latch -// only ever released on plug-out. Ten minutes of steady current is the -// car charging, not an EVSE retry blip; the estimate must follow the -// delivered energy again. -func TestSustainedChargingClearsCompletionLatch(t *testing.T) { +// A car that actually resumes charging is eligible for planning immediately. +func TestMeasuredChargingClearsVehicleDecline(t *testing.T) { m, clock := latchedManager(t) - // Charging resumes at full power; a brief run must not release. *clock = clock.Add(5 * time.Second) m.Observe("garage", true, 11000, 1500, true) - if st, _ := m.State("garage"); !st.ChargingDeclined || math.Abs(st.CurrentSoC-(0.2+1500.0/60000)) > 1e-9 { - t.Fatalf("a fresh run must not release the latch yet: %+v", st) - } - *clock = clock.Add(InterruptSteadyRun / 2) - m.Observe("garage", true, 11000, 3000, true) - if st, _ := m.State("garage"); !st.ChargingDeclined { - t.Fatalf("half a steady run must not release the latch: %+v", st) - } - *clock = clock.Add(InterruptSteadyRun/2 + time.Second) - m.Observe("garage", true, 11000, 4000, true) st, _ := m.State("garage") - if st.ChargingDeclined { - t.Errorf("a full steady run should release the latch: %+v", st) + if st.ChargingDeclined || math.Abs(st.CurrentSoC-(0.2+1500.0/60000)) > 1e-9 { + t.Fatalf("resumed delivery did not clear refusal: %+v", st) } - // anchor 0.2 + 4000/60000 ≈ 0.267 — the estimate moved off the pin. - if st.CurrentSoC < 0.26 || st.CurrentSoC > 0.27 { - t.Errorf("estimate should follow delivered Wh after release, got %.3f", st.CurrentSoC) +} + +func TestHigherTargetRetriesVehicleWithoutInventingBatteryLevel(t *testing.T) { + m, _ := latchedManager(t) + before, _ := m.State("garage") + m.SetTarget("garage", .9, time.Now().Add(time.Hour)) + after, _ := m.State("garage") + if after.ChargingDeclined || after.CurrentSoC != before.CurrentSoC { + t.Fatalf("higher target did not retry honestly: %+v", after) } - // And keeps following as more energy goes in. - *clock = clock.Add(time.Minute) - m.Observe("garage", true, 11000, 6000, true) - if st, _ := m.State("garage"); st.CurrentSoC < 0.29 || st.CurrentSoC > 0.31 { - t.Errorf("estimate should keep rising, got %.3f", st.CurrentSoC) +} + +func TestExplicitRetryAndHigherScheduleClearVehicleDecline(t *testing.T) { + m, _ := latchedManager(t) + before, _ := m.State("garage") + m.RetryCharging("garage") + after, _ := m.State("garage") + if after.ChargingDeclined || after.CurrentSoC != before.CurrentSoC { + t.Fatalf("retry changed level: %+v", after) + } + m, _ = latchedManager(t) + m.SetSchedule("garage", Schedule{SoC: .9, TimeOfDayMinUTC: 7 * 60}) + if after, _ := m.State("garage"); after.ChargingDeclined { + t.Fatalf("new goal retained refusal: %+v", after) } } From 5e01da859b7e5a7790342aca062123b12582a852 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Sun, 6 Sep 2026 12:54:30 +0200 Subject: [PATCH 19/57] fix(ev): distinguish pause confirmation and expose battery size Signed-off-by: Fredrik Ahlgren --- .changeset/ev-client-readiness.md | 5 + web/app.js | 149 ++++++++++++++++++++++++++---- web/ev-manual-feedback.test.mjs | 12 ++- web/ev-plug-in-view.test.mjs | 2 +- web/ev-write-timeout.test.mjs | 32 +++++++ 5 files changed, 178 insertions(+), 22 deletions(-) create mode 100644 .changeset/ev-client-readiness.md create mode 100644 web/ev-write-timeout.test.mjs diff --git a/.changeset/ev-client-readiness.md b/.changeset/ev-client-readiness.md new file mode 100644 index 00000000..cbae8cc8 --- /dev/null +++ b/.changeset/ev-client-readiness.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +Add Pause charging and Resume plan beside Charge now, with status that waits for the charger to stop. Let users change battery size beside the current level. Explain whether that level survives a restart, and never call a car that declined charge full. Keep the request deadline active until the response body arrives. diff --git a/web/app.js b/web/app.js index a6dcd16e..a6e2f5af 100644 --- a/web/app.js +++ b/web/app.js @@ -69,6 +69,12 @@ var controller = new AbortController(); var timer = setTimeout(function () { controller.abort(); }, 30000); return apiFetch(path, Object.assign({}, options, { signal: controller.signal })) + .then(function (response) { + // Keep the deadline until the body arrives, including an error body. + return response.arrayBuffer().then(function (body) { + return new Response(response.status === 204 ? null : body, { status: response.status, statusText: response.statusText, headers: response.headers }); + }); + }) .catch(function (e) { if (e && e.name === "AbortError") throw new Error("FTW has not confirmed the request. Check its current state before trying again"); throw e; @@ -2747,6 +2753,10 @@ ? " Returns to the plan at the estimated " + Math.round(lp.manual_release_soc * 100) + " % target." : " Continues until the car stops drawing, you return to the plan, or unplug."; switch (m.state) { + case "pausing": + return "Pause requested. " + ((lp.current_power_w || 0) >= 100 ? formatW(lp.current_power_w) + " is still flowing. " : "") + "Waiting for the charger to stop."; + case "paused": + return "Paused by you. Charging stays off until you resume the plan, choose Charge now, or unplug."; case "unavailable": return "Charger status is out of date. FTW cannot confirm whether the car is charging."; case "charging": @@ -2761,6 +2771,7 @@ return "Charger offers " + cmdA + " but the car is not drawing" + sinceP + "." + (reason || " It may be full, or held by its own charge limit or schedule."); case "stalled": + if (evIsPaused(lp)) return "The charger has not stopped after your pause request. Check the charger’s app."; return "Nothing is charging" + (since ? " after " + since : "") + ": the charger has not acted on " + reqA + "." + (reason || " Check the car's own charge limit or schedule, then the charger's app."); case "limited": @@ -2810,6 +2821,8 @@ if (lp.commanded_reason === "fuse_limit") { text += " Rate is limited by the main fuse right now."; } + } else if (lp.charging_declined) { + text = "The car stopped asking for charge. Check its charge limit or schedule. This does not confirm the battery is full."; } else if (lp.commanded_known && lp.commanded_w > 0) { text = "FTW requests " + formatW(lp.commanded_w) + ". Waiting for the car to draw power."; var chargerReason = lp.charger && lp.charger.reason || d && d.reason_no_current_label; @@ -3356,6 +3369,11 @@ // loadpoint is in (PV-surplus-only if that toggle is on). The amperage // is sent as watts (power_w = A × phases × voltage); the driver // converts back to amps given the wallbox it's talking to. + function evIsPaused(lp) { + return !!(lp && lp.manual_active && (lp.manual_charge_w === 0 || + lp.manual && (lp.manual.requested_w === 0 || lp.manual.state === "paused" || lp.manual.state === "pausing"))); + } + function buildManualChargeSection(lp) { var phases = (lp && lp.phases) || 3; var voltage = (lp && lp.voltage_v) || 230; @@ -3476,7 +3494,13 @@ stopBtn.disabled = !active; stopBtn.style.opacity = active ? "1" : "0.5"; + var pauseBtn = stopBtn.cloneNode(false); + pauseBtn.textContent = "Pause charging"; + pauseBtn.hidden = false; + pauseBtn.disabled = false; + pauseBtn.style.opacity = "1"; btnRow.appendChild(startBtn); + btnRow.appendChild(pauseBtn); btnRow.appendChild(stopBtn); box.appendChild(btnRow); @@ -3490,19 +3514,24 @@ var holdLineUntil = 0; function renderStatus() { var on = !!(lastLp && lastLp.manual_active); + var paused = evIsPaused(lastLp); + if (!busy && paused && lastLp.manual && lastLp.manual.state === "paused") holdLineUntil = 0; if (!busy) { stopBtn.hidden = !on; stopBtn.disabled = !on; - eyebrow.hidden = !on; - row.hidden = !on; - row.style.display = on ? "flex" : "none"; + eyebrow.hidden = !on || paused; + row.hidden = !on || paused; + row.style.display = on && !paused ? "flex" : "none"; + pauseBtn.hidden = paused; + pauseBtn.disabled = false; + stopBtn.textContent = paused ? "Resume plan" : "Return to plan"; stopBtn.style.opacity = on ? "1" : "0.5"; - startBtn.hidden = on; - if (!on) { slider.value = String(maxA); renderReadout(); } + startBtn.hidden = on && !paused; + if (!on || paused) { slider.value = String(maxA); renderReadout(); } startBtn.disabled = false; } if (busy || Date.now() < holdLineUntil) return; - status.textContent = on ? "Changes apply when you release the slider." : idleText; + status.textContent = paused ? "The goal and solar rule wait until you resume the plan. Charge now starts immediately." : on ? "Changes apply when you release the slider." : idleText; } function update(nextLp, d) { if (nextLp) lastLp = nextLp; @@ -3523,28 +3552,30 @@ }); } - function requestCharge() { + function requestCharge(pause) { + pause = pause === true; if (busy) return; busy = true; slider.disabled = true; stopBtn.disabled = true; startBtn.disabled = true; var a = parseInt(slider.value, 10) || minA; - status.textContent = "Asking FTW for " + a + " A…"; + pauseBtn.disabled = true; + status.textContent = pause ? "Sending pause request…" : "Asking FTW for " + a + " A…"; // CONTROL write — strict (FIX-B): persistent manual hold (hold_s:0) // with no SoC release — see the note above the slider. evWrite("/api/loadpoints/" + encodeURIComponent(lp.id) + "/manual_hold", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ - power_w: aToW(a), + power_w: pause ? 0 : aToW(a), hold_s: 0, phase_mode: phases === 1 ? "1p" : "3p", }), }).then(failOn).then(function () { busy = false; slider.disabled = false; - status.textContent = "FTW received " + a + " A. Waiting for the charger…"; + status.textContent = pause ? "Pause requested. Waiting for the charger to stop…" : "FTW received " + a + " A. Waiting for the charger…"; holdLineUntil = Date.now() + 6000; refreshEvModalAfterWrite(); // read the hold and charger response without rebuilding focused inputs }).catch(function (e) { @@ -3552,11 +3583,13 @@ slider.disabled = false; startBtn.disabled = false; stopBtn.disabled = !lastLp.manual_active; + pauseBtn.disabled = false; status.textContent = "Request not confirmed: " + ((e && e.message) || "try again") + "."; holdLineUntil = Infinity; }); } - startBtn.addEventListener("click", requestCharge); + startBtn.addEventListener("click", function () { requestCharge(false); }); + pauseBtn.addEventListener("click", function () { requestCharge(true); }); slider.addEventListener("change", function () { if (lastLp.manual_active) requestCharge(); }); @@ -3566,6 +3599,7 @@ stopBtn.disabled = true; slider.disabled = true; startBtn.disabled = true; + pauseBtn.disabled = true; status.textContent = "Returning to the plan…"; evWrite("/api/loadpoints/" + encodeURIComponent(lp.id) + "/manual_hold", { method: "DELETE", @@ -3573,7 +3607,8 @@ busy = false; slider.disabled = false; startBtn.disabled = false; - status.textContent = "Manual charge ended. The plan decides when to charge."; + pauseBtn.disabled = false; + status.textContent = "The plan decides when to charge."; holdLineUntil = Date.now() + 6000; refreshEvModalAfterWrite(); }).catch(function (e) { @@ -3581,7 +3616,8 @@ slider.disabled = false; startBtn.disabled = false; stopBtn.disabled = false; - status.textContent = "Stop failed: " + ((e && e.message) || "try again") + "."; + pauseBtn.disabled = false; + status.textContent = "Return to plan not confirmed: " + ((e && e.message) || "try again") + "."; holdLineUntil = Infinity; }); }); @@ -3695,6 +3731,8 @@ box.appendChild(planWrap); box.appendChild(socWrap); + var capacityView = buildEvCapacityView(lp); + box.appendChild(capacityView.el); // While the operator holds the slider (pointer down, or keyboard // input in the last moment), polls must not snap it back to the @@ -3713,10 +3751,15 @@ function sourceNote(lpNow) { var src = (lpNow && lpNow.soc_source) || ""; - if (src === "assumed") return "Battery level needs confirmation. The plan currently assumes " + Math.round(lpNow.current_soc * 100) + " %. Drag to match the car. This level must be entered again after a box restart."; - if (src === "vehicle") return "Live from the car. Drag only to correct drift."; - if (src === "completed") return "The car stopped asking for current, so the box assumes the target was reached. Drag to correct."; - return "Estimated from energy delivered. Drag to the real value and the plan follows."; + var retention = lpNow.soc_retention === "session" + ? " FTW keeps this level for the same charging session, including after a box restart." + : lpNow.soc_retention === "error" + ? " This level could not be saved for a box restart. Enter it again before relying on the plan after restarting." + : " This level must be entered again after a box restart."; + if (src === "assumed") return "Battery level needs confirmation. The plan currently assumes " + Math.round(lpNow.current_soc * 100) + " %. Drag to match the car." + retention; + if (src === "vehicle") return "Reported by the car. Drag only to correct drift."; + if (src === "completed") return "The car stopped asking for charge. Its actual battery level is not confirmed. Drag to match the car."; + return "Estimated from energy delivered. Drag to the real value and the plan follows." + retention; } var socPending = null; @@ -3740,7 +3783,13 @@ (!(lastLp.schedule && lastLp.schedule.soc > 0) && !lastLp.manual_active && !lastLp.surplus_only ? " Set a ready time, or choose Charge now." : " Reading the updated plan…"); noteTimer = setTimeout(function () { noteTimer = null; if (!socFailed) note.textContent = sourceNote(lastLp); }, 6000); - refreshEvModalAfterWrite(); + refreshEvModalAfterWrite().then(function () { + if (revision === socRevision && lastLp.soc_retention === "error") { + if (noteTimer) { clearTimeout(noteTimer); noteTimer = null; } + socFailed = true; + note.textContent = "Charge level updated: " + v + " %. It could not be saved for a box restart."; + } + }); }).catch(function (e) { if (revision === socRevision) { socFailed = true; note.textContent = "Charge level not confirmed: " + e.message; } }).finally(function () { @@ -3810,6 +3859,7 @@ function update(lpNow, dNow) { lastLp = lpNow; + capacityView.update(lpNow); var fresh = renderEvPlanStatus(lpNow, dNow); if (headline && headline.parentNode === box) { if (fresh) { box.replaceChild(fresh, headline); } else { box.removeChild(headline); } @@ -3833,6 +3883,61 @@ return { el: box, update: update, slider: slider }; } + function buildEvCapacityView(lp) { + var wrap = document.createElement("details"); + wrap.style.cssText = "margin-top:0.6rem;font-size:0.85rem"; + var summary = document.createElement("summary"); + summary.style.cssText = "cursor:pointer;color:var(--text-dim)"; + wrap.appendChild(summary); + var hint = document.createElement("p"); + hint.style.cssText = "color:var(--text-dim);margin:0.4rem 0"; + wrap.appendChild(hint); + var label = document.createElement("label"); + label.textContent = "Usable battery size (kWh) "; + var input = document.createElement("input"); + input.type = "number"; input.min = "1"; input.max = "300"; input.step = "0.1"; + input.setAttribute("inputmode", "decimal"); + input.setAttribute("aria-label", "Usable battery size, kWh"); + input.style.cssText = "width:7em;padding:0.3rem;border:1px solid var(--line);border-radius:4px;background:var(--bg);color:var(--fg)"; + label.appendChild(input); wrap.appendChild(label); + var help = document.createElement("p"); + help.style.cssText = "color:var(--text-dim);margin:0.4rem 0"; + help.textContent = "Applies when you leave the field. Find the usable size in your car’s specifications."; + wrap.appendChild(help); + var note = document.createElement("p"); + note.setAttribute("role", "status"); + note.style.cssText = "color:var(--text-dim);margin:0.4rem 0"; + wrap.appendChild(note); + var retry = document.createElement("button"); + retry.type = "button"; retry.textContent = "Try battery size again"; retry.hidden = true; + wrap.appendChild(retry); + var busy = false, dirty = false; + input.addEventListener("input", function () { dirty = true; }); + function save() { + if (busy) return; + var value = Number(input.value); + if (!isFinite(value) || value < 1 || value > 300) { note.textContent = "Enter the usable battery size from 1 to 300 kWh."; note.setAttribute("role", "alert"); return; } + busy = true; input.disabled = true; retry.hidden = true; + note.setAttribute("role", "status"); note.textContent = "Sending battery size…"; + evWrite("/api/loadpoints/" + encodeURIComponent(lp.id) + "/vehicle", { + method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ capacity_wh: Math.round(value * 1000) }), + }).then(function (r) { return r.json().then(function (j) { if (!r.ok || !j.ok) throw new Error(j.error || "FTW refused the change."); return j; }); }) + .then(function () { dirty = false; note.textContent = "Battery size saved. The plan uses this size for its estimates."; return refreshEvModalAfterWrite(); }) + .catch(function (err) { note.textContent = "Battery size not confirmed: " + err.message; note.setAttribute("role", "alert"); retry.hidden = false; }) + .finally(function () { busy = false; input.disabled = false; }); + } + input.addEventListener("change", save); retry.addEventListener("click", save); + function update(next) { + var capacity = Number(next.vehicle_capacity_wh); + wrap.hidden = !next.plugged_in || !(capacity > 0); + summary.textContent = "Car battery · " + (capacity / 1000) + " kWh"; + hint.textContent = next.capacity_source === "default" ? "FTW is using a default size. Check it against your car." : "Used for estimates. Check this size if you use another car."; + if (!busy && !dirty && document.activeElement !== input) input.value = String(capacity / 1000); + } + update(lp); + return { el: wrap, update: update }; + } + // Solar is a rule of the plan. A manual hold overrides it. function buildPVModeSection(lp) { var soBox = document.createElement("div"); @@ -3856,7 +3961,9 @@ if (busy) return; soCb.checked = !!latest.surplus_only; soCb.disabled = !!latest.manual_active; - soStatus.textContent = failure || (latest.manual_active + soStatus.textContent = failure || (evIsPaused(latest) + ? "This rule resumes with the plan." + : latest.manual_active ? "Charge now overrides this rule. It resumes when you return to the plan." : latest.surplus_only ? "No grid or home battery. Your target may not be reached in time." @@ -4341,7 +4448,9 @@ (s.recurring ? " · repeats" : " · once") : "No ready time set."; editLabel.textContent = hasGoal ? "Change goal" : "Set a ready time"; - suspended.textContent = nextLp.manual_active + suspended.textContent = evIsPaused(nextLp) + ? "Resume the plan to use this goal. Edits apply then." + : nextLp.manual_active ? "Charge now overrides this goal. Edits apply when you return to the plan." : "Changes apply as you make them."; schedule.update(nextLp); diff --git a/web/ev-manual-feedback.test.mjs b/web/ev-manual-feedback.test.mjs index 5aaea895..d4154165 100644 --- a/web/ev-manual-feedback.test.mjs +++ b/web/ev-manual-feedback.test.mjs @@ -18,7 +18,7 @@ const statusText = source.slice( // screen moved, and the operator removed the charger to charge by hand. test('the status line follows the charger through every state', () => { - for (const state of ['sent', 'accepted', 'charging', 'not_drawing', 'stalled', 'limited', 'unavailable']) { + for (const state of ['sent', 'accepted', 'charging', 'not_drawing', 'stalled', 'limited', 'unavailable', 'pausing', 'paused']) { assert.match(statusText, new RegExp(`case "${state}":`)); } assert.match(statusText, /Waiting for the charger/); @@ -78,3 +78,13 @@ test('an estimated release target remains visible while waiting', () => { assert.match(words, /estimated 80 % target/); assert.doesNotMatch(words, /Charging at/); }); + + +test('pause status waits for the charger before claiming it stopped', () => { + const waiting = describeManual({ ...lp, manual: { ...lp.manual, state: 'pausing', requested_a: 0, requested_w: 0 }, current_power_w: 11000 }); + assert.match(waiting, /Waiting for the charger to stop/); + assert.doesNotMatch(waiting, /Paused by you/); + const paused = describeManual({ ...lp, manual: { ...lp.manual, state: 'paused', requested_a: 0, requested_w: 0 } }); + assert.match(paused, /Paused by you/); + assert.match(paused, /until you resume the plan/); +}); diff --git a/web/ev-plug-in-view.test.mjs b/web/ev-plug-in-view.test.mjs index 670236d7..6a6ee5dc 100644 --- a/web/ev-plug-in-view.test.mjs +++ b/web/ev-plug-in-view.test.mjs @@ -5,7 +5,7 @@ import test from 'node:test'; const source = readFileSync(new URL('./app.js', import.meta.url), 'utf8'); const view = source.slice( source.indexOf('function buildEvPlanView'), - source.indexOf('function buildPVModeSection'), + source.indexOf('function buildEvCapacityView'), ); // The plug-in moment (#1059): the modal shows what the box will do and diff --git a/web/ev-write-timeout.test.mjs b/web/ev-write-timeout.test.mjs new file mode 100644 index 00000000..bbb1bd68 --- /dev/null +++ b/web/ev-write-timeout.test.mjs @@ -0,0 +1,32 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import test from 'node:test'; + +const source = readFileSync(new URL('./app.js', import.meta.url), 'utf8'); +const implementation = source.slice(source.indexOf(' function evWrite('), source.indexOf(' // ---- Chart data ----')); + +test('a charger write that sends headers but stalls its body settles with an unconfirmed result', async () => { + let expire; + const write = new Function('apiFetch', 'setTimeout', 'clearTimeout', implementation + '; return evWrite;')( + (_path, options) => Promise.resolve({ + arrayBuffer: () => new Promise((_resolve, reject) => { + options.signal.addEventListener('abort', () => reject(new DOMException('Aborted', 'AbortError'))); + }), + }), + callback => { expire = callback; return 1; }, + () => {}, + ); + const request = write('/api/loadpoints/car/manual_hold', { method: 'POST' }); + await Promise.resolve(); + expire(); + await assert.rejects(request, /has not confirmed the request/); +}); + +test('a complete refusal keeps the server response for the control that sent it', async () => { + const write = new Function('apiFetch', implementation + '; return evWrite;')( + () => Promise.resolve(new Response(JSON.stringify({ error: 'Charger unavailable' }), { status: 409 })), + ); + const response = await write('/api/loadpoints/car/manual_hold', { method: 'POST' }); + assert.equal(response.status, 409); + assert.deepEqual(await response.json(), { error: 'Charger unavailable' }); +}); From 2391dd12737b97b26af1ade2fcac5c037771c6db Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Sun, 6 Sep 2026 12:56:58 +0200 Subject: [PATCH 20/57] fix(update): keep restart images and restore portable managed drivers Signed-off-by: Fredrik Ahlgren --- .changeset/portable-managed-driver-backups.md | 5 + .changeset/restart-keeps-running-release.md | 5 + docs/backup-and-restore.md | 5 + docs/self-update.md | 14 ++- go/cmd/ftw-updater/main.go | 66 +++++++--- go/cmd/ftw-updater/main_test.go | 103 +++++++++++++--- go/cmd/ftw-updater/self_replace_test.go | 23 ++++ go/internal/api/api_selfupdate.go | 8 +- go/internal/api/api_selfupdate_test.go | 20 ++++ go/internal/backup/archive.go | 67 ++++++++++- go/internal/backup/archive_test.go | 113 ++++++++++++++++++ go/internal/selfupdate/selfupdate.go | 28 ++++- go/internal/selfupdate/selfupdate_test.go | 72 +++++++++++ 13 files changed, 481 insertions(+), 48 deletions(-) create mode 100644 .changeset/portable-managed-driver-backups.md create mode 100644 .changeset/restart-keeps-running-release.md diff --git a/.changeset/portable-managed-driver-backups.md b/.changeset/portable-managed-driver-backups.md new file mode 100644 index 00000000..f6e9eb8d --- /dev/null +++ b/.changeset/portable-managed-driver-backups.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +Full backups now include managed drivers whose active links use absolute paths inside the data directory. The archive stores relative links so restore works at a new path. Links that escape the data directory or form cycles remain blocked. diff --git a/.changeset/restart-keeps-running-release.md b/.changeset/restart-keeps-running-release.md new file mode 100644 index 00000000..58e34fed --- /dev/null +++ b/.changeset/restart-keeps-running-release.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +Restart restarts the existing container and keeps its exact image, including local test builds. It never pulls or recreates from a stale Compose tag. Core refuses the unsafe restart path on older updaters and explains how to update the updater. diff --git a/docs/backup-and-restore.md b/docs/backup-and-restore.md index 4a235714..b41aad35 100644 --- a/docs/backup-and-restore.md +++ b/docs/backup-and-restore.md @@ -24,6 +24,11 @@ FTW: 4. hashes every file, verifies the finished archive and runs SQLite `quick_check` before publishing it. +Managed-driver links that point inside the persistent directory become relative +links in the archive. Restore can therefore move the data to another directory +or machine. Backup never follows these links to copy a host file; verification +rejects link chains that escape the data directory or form a cycle. + Choose **Download**, save the `.ftwbak` file on another computer or USB disk, and keep at least one older known-good copy. **Verify** rechecks the server copy; it does not prove that a download exists elsewhere. diff --git a/docs/self-update.md b/docs/self-update.md index 5ef9e094..fece6d00 100644 --- a/docs/self-update.md +++ b/docs/self-update.md @@ -120,7 +120,19 @@ The version badge selects `stable` or `beta`, checks availability and starts an update. Changing channel does not deploy anything. A skipped version remains hidden only until a newer version appears. -For manual Core + updater operation: +**Restart** stops and starts the existing Core container, then checks its health. +It keeps that container's image and environment, even if `.env` or Compose now +names a different release. It does not apply changes to Compose; use the update +flow for a new image. Core sends `restart_existing` so an older updater refuses +before it can pull or replace anything. If FTW reports that safe restart needs a +newer updater, update Core and updater together using the paired commands below. +A normal Core update also asks the updater to replace itself with the same tag +after Core passes its health check. + +For manual Core + updater operation, first set `FTW_IMAGE_TAG` and +`FTW_UPDATER_IMAGE_TAG` in the project's `.env` to the same published immutable +tag. Then run the commands below, using `forty-two-watts` instead of `ftw` on a +legacy installation: ```bash cd ~/ftw diff --git a/go/cmd/ftw-updater/main.go b/go/cmd/ftw-updater/main.go index b33b1d26..94989482 100644 --- a/go/cmd/ftw-updater/main.go +++ b/go/cmd/ftw-updater/main.go @@ -346,6 +346,11 @@ func (s *server) handleUpdate(w http.ResponseWriter, r *http.Request) { http.Error(w, "bad json: "+err.Error(), 400) return } + // A distinct action lets new Core fail closed on an old updater: old + // sidecars reject it before running Docker instead of pulling :latest. + if body.Action == "restart_existing" { + body.Action = "restart" + } if body.Component == "" { body.Component = "core" } @@ -372,9 +377,13 @@ func (s *server) handleUpdate(w http.ResponseWriter, r *http.Request) { return } case "restart": - // target optional — when empty, compose's `${FTW_IMAGE_TAG:-latest}` - // substitution falls through to :latest. That's the dev path for - // exercising the flow without a real release. + // Older Core may send a release target. Validate the old wire shape, + // but never use that hint to select an image during a restart. + if body.Target != "" && !isImmutableImageTag(body.Target) { + http.Error(w, "target must be stable vX.Y.Z or beta vX.Y.Z-beta.N", 400) + return + } + body.Target = "" case "rollback": if body.Snapshot == "" { http.Error(w, "rollback requires snapshot id", 400) @@ -451,19 +460,40 @@ func (s *server) handleStatus(w http.ResponseWriter, r *http.Request) { _ = json.NewEncoder(w).Encode(st) } -// runJob executes a pull+up (or pull+up --force-recreate) sequence, -// emitting state transitions between steps. Runs inside a goroutine so -// the HTTP handler that kicked it off has already responded. -// -// When target is non-empty (always the case for action=update), it's -// passed as FTW_IMAGE_TAG= so docker-compose.yml's image tag -// substitution pulls the specific version. action=restart with empty -// target falls through to compose's default (`:latest`) — that's the -// dev path for exercising the flow without a real release. +// runJob dispatches a component update or a restart of its existing container. func (s *server) runJob(action, target string) { s.runComponentJob(action, target, "core", time.Time{}) } +// restartExisting never pulls or recreates a container. Its image ID, mounts +// and environment survive even when Compose or .env now names another build. +func (s *server) restartExisting(spec componentSpec, startedAt time.Time) { + st := State{State: "restarting", Action: "restart", Component: spec.name, + StartedAt: startedAt, PhaseStartedAt: time.Now(), UpdatedAt: time.Now(), + Message: "Restarting the existing container", Step: 1, TotalSteps: 3} + s.writeState(st) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) + err := s.runWithStateHeartbeat(st, func() error { + return s.runner(ctx, nil, s.composeArgs("restart", "--no-deps", spec.service)...) + }) + cancel() + if err == nil && s.healthCheck != nil { + st.State, st.Message, st.Step = "checking", "Waiting for the service to become ready", 2 + st.PhaseStartedAt, st.UpdatedAt = time.Now(), time.Now() + s.writeState(st) + ctx, cancel = context.WithTimeout(context.Background(), componentHealthTimeout(spec.name)) + err = s.runWithStateHeartbeat(st, func() error { return s.healthCheck(ctx, spec.service) }) + cancel() + } + st.UpdatedAt = time.Now() + if err != nil { + st.State, st.Message = "failed", "restart failed: "+err.Error() + } else { + st.State, st.Message, st.Step = "done", "Service restarted and ready", 3 + } + s.writeState(st) +} + func (s *server) runComponentJob(action, target, component string, startedAt time.Time) { now := startedAt if now.IsZero() { @@ -474,6 +504,10 @@ func (s *server) runComponentJob(action, target, component string, startedAt tim s.writeState(State{State: "failed", Action: action, Component: component, Target: target, StartedAt: now, UpdatedAt: now, Message: err.Error()}) return } + if action == "restart" { + s.restartExisting(spec, now) + return + } if action == "update" && spec.name == "core" { if err := s.requireHealthyOptimizer(); err != nil { msg := "core update blocked: " + err.Error() @@ -503,7 +537,7 @@ func (s *server) runComponentJob(action, target, component string, startedAt tim if target != "" { env = []string{spec.tagEnv + "=" + target} } - if action == "update" || action == "restart" { + if action == "update" { cleanup, err := s.prepareComponentImagePin(spec) if err != nil { msg := "compose preflight failed: " + err.Error() @@ -590,12 +624,6 @@ func (s *server) runComponentJob(action, target, component string, startedAt tim defer upCancel() upArgs := s.composeArgs("up", "-d", spec.service) - if action == "restart" { - // --force-recreate is what makes restart actually restart when the - // image digest didn't change — exactly the dev/test path the main - // UI exposes as the "Restart" button. - upArgs = s.composeArgs("up", "-d", "--force-recreate", spec.service) - } if err := s.runWithStateHeartbeat(restartState, func() error { return s.runner(upCtx, env, upArgs...) }); err != nil { diff --git a/go/cmd/ftw-updater/main_test.go b/go/cmd/ftw-updater/main_test.go index fb24b25f..a57f33de 100644 --- a/go/cmd/ftw-updater/main_test.go +++ b/go/cmd/ftw-updater/main_test.go @@ -319,7 +319,7 @@ func TestHandleUpdate_MissingOptimizerLeavesUserOverrideUntouched(t *testing.T) } } -func TestHandleUpdate_RestartForceRecreates(t *testing.T) { +func TestHandleUpdate_RestartDoesNotRecreate(t *testing.T) { s, runner := newTestServer(t) req := httptest.NewRequest(http.MethodPost, "/update", strings.NewReader(`{"action":"restart"}`)) rr := httptest.NewRecorder() @@ -328,9 +328,9 @@ func TestHandleUpdate_RestartForceRecreates(t *testing.T) { t.Fatalf("status = %d", rr.Code) } waitForState(t, s, "done") - up := strings.Join(runner.snapshot()[1], " ") - if !strings.Contains(up, "--force-recreate") { - t.Errorf("restart path must force-recreate: %v", up) + calls := runner.snapshot() + if len(calls) != 1 || strings.Join(calls[0], " ") != strings.Join(s.composeArgs("restart", "--no-deps", s.mainServiceName), " ") { + t.Fatalf("restart must only restart the existing container: %v", calls) } } @@ -473,7 +473,7 @@ func TestHandleUpdate_MigratesHardcodedImageWithTransientOverride(t *testing.T) } } -func TestHandleUpdate_RestartMigratesHardcodedImageWithTransientOverride(t *testing.T) { +func TestHandleUpdate_RestartPreservesHardcodedImage(t *testing.T) { s, runner := newTestServer(t) writeCompose(t, s.composeFile, `services: forty-two-watts: @@ -494,8 +494,8 @@ func TestHandleUpdate_RestartMigratesHardcodedImageWithTransientOverride(t *test waitForState(t, s, "done") for _, call := range runner.snapshot() { joined := strings.Join(call, " ") - if !strings.Contains(joined, "ftw-compose-update-") { - t.Fatalf("legacy restart must use compatibility override: %v", call) + if strings.Contains(joined, "ftw-compose-update-") || !strings.Contains(joined, "restart --no-deps") { + t.Fatalf("legacy restart must keep its container without a migration override: %v", call) } if call[len(call)-1] != legacyMainServiceName { t.Fatalf("legacy service identity must be preserved: %v", call) @@ -744,21 +744,88 @@ func TestPrepareUpdateImagePin_WinsOverHardcodedUserOverride(t *testing.T) { } } -// `restart` is the dev path — no target needed, no env override, falls -// through to compose's :latest default. -func TestHandleUpdate_RestartLeavesEnvUnset(t *testing.T) { +func TestHandleUpdate_RestartKeepsEveryRunningImage(t *testing.T) { + for _, tc := range []struct{ name, image, target, action string }{ + {"beta with stale env", "ghcr.io/srcfl/ftw:v2.14.0-beta.1", "", "restart"}, + {"wrong caller version", "ghcr.io/srcfl/ftw:v2.14.0-beta.1", "v2.0.0", "restart"}, + {"moving tag", "ghcr.io/srcfl/ftw:latest", "", "restart"}, + {"local review", "ftw-ev-review:46d04a7a", "", "restart_existing"}, + {"digest", "ghcr.io/srcfl/ftw@sha256:abc123", "", "restart_existing"}, + } { + t.Run(tc.name, func(t *testing.T) { + s, runner := newTestServer(t) + writeCompose(t, s.composeFile, "services:\n ftw:\n image: "+tc.image+"\n") + writeCompose(t, filepath.Join(filepath.Dir(s.composeFile), ".env"), "FTW_IMAGE_TAG=v2.0.0-beta.2\n") + s.imageRef = func(context.Context, string) (string, error) { + t.Error("restart must not resolve an image") + return "", errors.New("inspect unavailable") + } + healthChecks := 0 + s.healthCheck = func(_ context.Context, service string) error { + healthChecks++ + if service != canonicalMainServiceName { + t.Errorf("health checked %s", service) + } + return nil + } + req := httptest.NewRequest(http.MethodPost, "/update", strings.NewReader(`{"action":"`+tc.action+`","target":"`+tc.target+`"}`)) + rr := httptest.NewRecorder() + s.handleUpdate(rr, req) + if rr.Code != http.StatusAccepted { + t.Fatalf("status = %d: %s", rr.Code, rr.Body.String()) + } + st := waitForState(t, s, "done") + if st.Action != "restart" || st.Target != "" || st.Step != st.TotalSteps { + t.Fatalf("restart state = %+v", st) + } + calls := runner.snapshot() + if len(calls) != 1 || strings.Join(calls[0], " ") != strings.Join(s.composeArgs("restart", "--no-deps", canonicalMainServiceName), " ") { + t.Fatalf("restart selected or pulled a replacement image: %v", calls) + } + for _, env := range runner.envSnapshot() { + if len(env) != 0 { + t.Fatalf("restart image env = %v", env) + } + } + if healthChecks != 1 { + t.Fatalf("health checks = %d", healthChecks) + } + pin, _ := os.ReadFile(filepath.Join(filepath.Dir(s.composeFile), ".env")) + if string(pin) != "FTW_IMAGE_TAG=v2.0.0-beta.2\n" { + t.Fatalf("restart rewrote .env: %s", pin) + } + }) + } +} + +func TestHandleUpdate_RestartRejectsMovingTarget(t *testing.T) { s, runner := newTestServer(t) - req := httptest.NewRequest(http.MethodPost, "/update", strings.NewReader(`{"action":"restart"}`)) + req := httptest.NewRequest(http.MethodPost, "/update", strings.NewReader(`{"action":"restart","target":"latest"}`)) rr := httptest.NewRecorder() s.handleUpdate(rr, req) - if rr.Code != 202 { - t.Fatalf("status = %d", rr.Code) + if rr.Code != 400 { + t.Fatalf("status = %d, want 400", rr.Code) } - waitForState(t, s, "done") - for i, env := range runner.envSnapshot() { - if len(env) != 0 { - t.Errorf("restart call %d should have no extra env, got %v", i, env) - } + if len(runner.snapshot()) != 0 { + t.Fatal("rejected restart called Docker") + } +} + +func TestRestartFailureDoesNotFallBackToRecreate(t *testing.T) { + for _, failed := range []string{"restart", "health"} { + t.Run(failed, func(t *testing.T) { + s, runner := newTestServer(t) + runner.fail = failed == "restart" + s.healthCheck = func(context.Context, string) error { return errors.New("health did not recover") } + s.runJob("restart", "v2.0.0") + st := s.readState() + if st.State != "failed" || !strings.Contains(st.Message, "restart failed") { + t.Fatalf("state = %+v", st) + } + if len(runner.snapshot()) != 1 { + t.Fatalf("failure must not pull/recreate: %v", runner.snapshot()) + } + }) } } diff --git a/go/cmd/ftw-updater/self_replace_test.go b/go/cmd/ftw-updater/self_replace_test.go index 0b86f1bc..2221fa30 100644 --- a/go/cmd/ftw-updater/self_replace_test.go +++ b/go/cmd/ftw-updater/self_replace_test.go @@ -268,3 +268,26 @@ func TestIsUpdaterImage(t *testing.T) { } } } + +func TestBetaUpdateReplacesUpdaterWithTheSameCandidate(t *testing.T) { + s, _ := newTestServer(t) + healthy := false + s.healthCheck = func(_ context.Context, service string) error { + if service == canonicalMainServiceName { + healthy = true + } + return nil + } + var replacement string + s.selfReplace = func(target string) error { + if !healthy || s.readState().State != "done" { + t.Error("updater replacement preceded healthy Core") + } + replacement = target + return nil + } + s.runJob("update", "v2.15.0-beta.1") + if replacement != "v2.15.0-beta.1" { + t.Fatalf("updater replacement = %q", replacement) + } +} diff --git a/go/internal/api/api_selfupdate.go b/go/internal/api/api_selfupdate.go index 7ec268fc..55704841 100644 --- a/go/internal/api/api_selfupdate.go +++ b/go/internal/api/api_selfupdate.go @@ -425,16 +425,14 @@ func containsTraversal(id string) bool { return id == "." || id == ".." } -// handleVersionRestart signals the sidecar to pull + force-recreate the -// main service regardless of whether a newer image exists. Exists so the -// full update flow can be exercised end-to-end in dev / CI before cutting -// a real release. +// handleVersionRestart restarts the existing Core container. No image is +// selected or downloaded, even when Compose now names a different release. func (s *Server) handleVersionRestart(w http.ResponseWriter, r *http.Request) { if s.deps.SelfUpdate == nil { writeJSON(w, 503, map[string]string{"error": "self-update disabled"}) return } - if err := s.deps.SelfUpdate.Trigger(r.Context(), "restart", ""); err != nil { + if err := s.deps.SelfUpdate.TriggerRestart(r.Context()); err != nil { writeJSON(w, 502, map[string]string{"error": err.Error()}) return } diff --git a/go/internal/api/api_selfupdate_test.go b/go/internal/api/api_selfupdate_test.go index d348b8ea..62d3b0c6 100644 --- a/go/internal/api/api_selfupdate_test.go +++ b/go/internal/api/api_selfupdate_test.go @@ -883,3 +883,23 @@ func TestVersionUpdateStatus_Idle(t *testing.T) { t.Errorf("state = %q, want idle (no StatusPath configured)", out.State) } } + +func TestVersionRestartSurfacesOldUpdaterRefusal(t *testing.T) { + checker := selfupdate.New(selfupdate.Config{ + CurrentVersion: "v2.14.0-beta.1", + SocketPath: startFakeSidecar(t, http.StatusBadRequest), + }, newMemStore()) + srv := New(&Deps{SelfUpdate: checker}) + rr := httptest.NewRecorder() + srv.Handler().ServeHTTP(rr, httptest.NewRequest(http.MethodPost, "/api/version/restart", nil)) + if rr.Code != http.StatusBadGateway { + t.Fatalf("status = %d: %s", rr.Code, rr.Body.String()) + } + var body map[string]string + if err := json.Unmarshal(rr.Body.Bytes(), &body); err != nil { + t.Fatal(err) + } + if !strings.Contains(body["error"], "safe restart requires a newer updater") { + t.Fatalf("missing user recovery instruction: %v", body) + } +} diff --git a/go/internal/backup/archive.go b/go/internal/backup/archive.go index 014064b7..85d429ed 100644 --- a/go/internal/backup/archive.go +++ b/go/internal/backup/archive.go @@ -299,6 +299,23 @@ func describeSource(ctx context.Context, dataDir string, source sourceEntry) (Fi if err != nil || !pathInside(dataDir, resolved) { return FileEntry{}, fmt.Errorf("backup: symlink escapes data dir: %s -> %s", source.sourcePath, target) } + // Managed driver activations use absolute paths in the running + // container. Store an internal relative link so restore can relocate + // data without referring back to /app/data or the original machine. + // Do not follow the link; validate the complete archive graph below. + if filepath.IsAbs(target) { + rootPrefix := dataDir + string(filepath.Separator) + if !strings.HasPrefix(target, rootPrefix) { + return FileEntry{}, fmt.Errorf("backup: absolute symlink is outside data dir: %s", source.sourcePath) + } + back, relErr := filepath.Rel(filepath.Dir(source.sourcePath), dataDir) + if relErr != nil { + return FileEntry{}, relErr + } + // Preserve .. until archive graph validation has expanded links. + target = back + string(filepath.Separator) + strings.TrimPrefix(target, rootPrefix) + } + target = filepath.ToSlash(target) entry.Type, entry.LinkTarget = "symlink", target h := sha256.Sum256([]byte(target)) entry.SHA256 = hex.EncodeToString(h[:]) @@ -495,7 +512,7 @@ func validateManifest(manifest Manifest) error { return errors.New("backup: invalid manifest identity or database path") } seen := make(map[string]bool, len(manifest.Files)) - symlinks := make(map[string]bool) + symlinks := make(map[string]string) databaseFound := false for _, entry := range manifest.Files { if !safeDataPath(entry.Path) || entry.Path == manifestPath || seen[entry.Path] { @@ -528,16 +545,24 @@ func validateManifest(manifest Manifest) error { if !safeDataPath(resolved) { return fmt.Errorf("backup: symlink target escapes data root: %s -> %s", entry.Path, entry.LinkTarget) } - symlinks[entry.Path] = true + symlinks[entry.Path] = entry.LinkTarget } } if !databaseFound { return errors.New("backup: compressed database missing from manifest") } + // A lexical path check alone misses `alias/../outside`: .. applies + // after alias is followed. Resolve each link against archive entries, + // never the host filesystem, and reject cycles or escape chains. + for name, target := range symlinks { + if err := validateArchiveLink(name, target, symlinks); err != nil { + return err + } + } for _, entry := range manifest.Files { parent := path.Dir(entry.Path) for parent != "." && parent != "/" { - if symlinks[parent] { + if _, ok := symlinks[parent]; ok { return fmt.Errorf("backup: entry %s is nested below symlink %s", entry.Path, parent) } parent = path.Dir(parent) @@ -546,6 +571,42 @@ func validateManifest(manifest Manifest) error { return nil } +// validateArchiveLink models relative symlink traversal without cleaning away +// .. before symlink expansion. Archive paths start at the data root. +func validateArchiveLink(name, target string, links map[string]string) error { + parts := strings.Split(strings.TrimPrefix(path.Dir(name), "data/"), "/") + if path.Dir(name) == "data" { + parts = nil + } + pending := strings.Split(target, "/") + expansions := 0 + for len(pending) > 0 { + part := pending[0] + pending = pending[1:] + switch part { + case "", ".": + continue + case "..": + if len(parts) == 0 { + return fmt.Errorf("backup: symlink chain escapes data root: %s", name) + } + parts = parts[:len(parts)-1] + default: + candidate := "data/" + strings.Join(append(append([]string{}, parts...), part), "/") + if next, ok := links[candidate]; ok { + expansions++ + if expansions > 40 { + return fmt.Errorf("backup: cyclic or excessive symlink chain: %s", name) + } + pending = append(strings.Split(next, "/"), pending...) + } else { + parts = append(parts, part) + } + } + } + return nil +} + // Restore verifies and extracts the archive into a new directory, then swaps // it into place while retaining the previous data directory beside it. // Callers must stop FTW before invoking this function. diff --git a/go/internal/backup/archive_test.go b/go/internal/backup/archive_test.go index 9561c715..3b0c3f4a 100644 --- a/go/internal/backup/archive_test.go +++ b/go/internal/backup/archive_test.go @@ -233,3 +233,116 @@ func writeTestFile(t *testing.T, filename, body string) { t.Fatal(err) } } + +func TestAbsoluteManagedDriverBackupRestoresAtAnotherPath(t *testing.T) { + root := t.TempDir() + dataDir := filepath.Join(root, "original-data") + installed := filepath.Join(dataDir, "driver-repository", "installed", "ftw-official", "goodwe", "1.0.1", strings.Repeat("a", 64), "goodwe.lua") + active := filepath.Join(dataDir, "driver-repository", "active", "goodwe.lua") + if err := os.MkdirAll(filepath.Dir(installed), 0o755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Dir(active), 0o755); err != nil { + t.Fatal(err) + } + writeTestFile(t, installed, "DRIVER = { id = 'goodwe', version = '1.0.1' }") + if err := os.Symlink(installed, active); err != nil { + t.Fatal(err) + } + statePath := filepath.Join(dataDir, "state.db") + st, err := state.Open(statePath) + if err != nil { + t.Fatal(err) + } + defer st.Close() + archive, err := Create(context.Background(), CreateOptions{State: st, StatePath: statePath, DataDir: dataDir, OutputDir: filepath.Join(root, "backups")}) + if err != nil { + t.Fatal(err) + } + manifest, err := Verify(archive.Path) + if err != nil { + t.Fatal(err) + } + for _, entry := range manifest.Files { + if entry.Type == "symlink" && filepath.IsAbs(entry.LinkTarget) { + t.Fatalf("archive kept host link: %+v", entry) + } + } + // The archive may not silently alter the active installation. + if target, _ := os.Readlink(active); target != installed { + t.Fatalf("source link changed: %q", target) + } + if err := st.Close(); err != nil { + t.Fatal(err) + } + // Remove the source completely before reading the restored link. Otherwise + // a link back to the old host path could make this test pass by accident. + if err := os.RemoveAll(dataDir); err != nil { + t.Fatal(err) + } + restoredDir := filepath.Join(root, "new-installation", "data") + if err := os.MkdirAll(filepath.Dir(restoredDir), 0o755); err != nil { + t.Fatal(err) + } + if _, err := Restore(archive.Path, restoredDir, time.Now()); err != nil { + t.Fatal(err) + } + restoredLink := filepath.Join(restoredDir, "driver-repository", "active", "goodwe.lua") + body, err := os.ReadFile(restoredLink) + if err != nil || !strings.Contains(string(body), "goodwe") { + t.Fatalf("restored driver = %q, %v", body, err) + } + restoredRoot, err := filepath.EvalSymlinks(restoredDir) + if err != nil { + t.Fatal(err) + } + target, err := filepath.EvalSymlinks(restoredLink) + if err != nil || !pathInside(restoredRoot, target) { + t.Fatalf("restored link escapes: %s, %v", target, err) + } +} + +func TestValidateManifestChecksSymlinkChains(t *testing.T) { + for _, tc := range []struct { + name string + links map[string]string + valid bool + }{ + {"internal driver chain", map[string]string{"data/active.lua": "installed.lua", "data/installed.lua": "version/driver.lua"}, true}, + {"cycle", map[string]string{"data/a": "b", "data/b": "a"}, false}, + // Lexically each target stays inside data/. Resolving alias first + // changes the depth, so ../.. then escapes the archive root. + {"dotdot after directory link", map[string]string{"data/dir/alias": "../target", "data/escape": "dir/alias/../../outside"}, false}, + {"absolute host path", map[string]string{"data/active.lua": "/app/data/driver.lua"}, false}, + {"direct escape", map[string]string{"data/active.lua": "../outside"}, false}, + } { + t.Run(tc.name, func(t *testing.T) { + m := Manifest{Format: Format, SchemaVersion: SchemaVersion, CreatedAt: time.Now(), DatabaseFile: "state.db", DatabaseEntry: "data/state.db.gz", Files: []FileEntry{{Path: "data/state.db.gz", Type: "file", SHA256: strings.Repeat("a", 64)}}} + for name, target := range tc.links { + m.Files = append(m.Files, FileEntry{Path: name, Type: "symlink", LinkTarget: target, SHA256: strings.Repeat("b", 64)}) + } + err := validateManifest(m) + if (err == nil) != tc.valid { + t.Fatalf("valid=%v, err=%v", tc.valid, err) + } + }) + } +} + +func TestDescribeSourceRejectsExternalAbsoluteLinkWithoutReadingIt(t *testing.T) { + root := t.TempDir() + dataDir := filepath.Join(root, "data") + if err := os.Mkdir(dataDir, 0o700); err != nil { + t.Fatal(err) + } + outside := filepath.Join(root, "secret") + writeTestFile(t, outside, "must not enter the backup") + link := filepath.Join(dataDir, "driver.lua") + if err := os.Symlink(outside, link); err != nil { + t.Fatal(err) + } + _, err := describeSource(context.Background(), dataDir, sourceEntry{archivePath: "data/driver.lua", sourcePath: link}) + if err == nil || !strings.Contains(err.Error(), "escapes data dir") { + t.Fatalf("external link error = %v", err) + } +} diff --git a/go/internal/selfupdate/selfupdate.go b/go/internal/selfupdate/selfupdate.go index bc9d2243..1ac5feb9 100644 --- a/go/internal/selfupdate/selfupdate.go +++ b/go/internal/selfupdate/selfupdate.go @@ -723,6 +723,13 @@ func (c *Checker) Trigger(ctx context.Context, action, target string) error { return c.TriggerComponentAt(ctx, action, target, "core", time.Time{}) } +// TriggerRestart requests a restart of the existing container. The separate +// wire action fails closed on older sidecars whose restart pulls/recreates an +// image; checker versions (including QA overrides) never select a restart image. +func (c *Checker) TriggerRestart(ctx context.Context) error { + return c.Trigger(ctx, "restart", "") +} + // TriggerComponent requests a selective core or optimizer compose update. func (c *Checker) TriggerComponent(ctx context.Context, action, target, component string) error { return c.TriggerComponentAt(ctx, action, target, component, time.Time{}) @@ -743,10 +750,18 @@ func (c *Checker) TriggerComponentAt(ctx context.Context, action, target, compon if action == "component_rollback" && component != "optimizer" { return errors.New("selfupdate: component rollback is only available for optimizer") } + if action == "restart" { + action, target = "restart_existing", "" + } body, _ := json.Marshal(map[string]any{ "action": action, "target": target, "component": component, "started_at": startedAt, }) - return c.postSidecar(ctx, body) + err := c.postSidecar(ctx, body) + var rejection *sidecarHTTPError + if action == "restart_existing" && errors.As(err, &rejection) && rejection.status == http.StatusBadRequest { + return fmt.Errorf("safe restart requires a newer updater; update Core and updater together: %w", err) + } + return err } // TriggerRollback asks the sidecar to restore a snapshot over the main @@ -775,6 +790,15 @@ func (c *Checker) TriggerRollback(ctx context.Context, snapshotID string, files return c.postSidecar(ctx, body) } +type sidecarHTTPError struct { + status int + message string +} + +func (e *sidecarHTTPError) Error() string { + return fmt.Sprintf("sidecar %d: %s", e.status, e.message) +} + // postSidecar wraps the Unix-socket POST to the sidecar's /update // endpoint. Shared by Trigger and TriggerRollback so the HTTP client // config (socket dialer + timeout) only lives in one place. @@ -797,7 +821,7 @@ func (c *Checker) postSidecar(ctx context.Context, body []byte) error { defer resp.Body.Close() if resp.StatusCode >= 400 { b, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<10)) - return fmt.Errorf("sidecar %d: %s", resp.StatusCode, strings.TrimSpace(string(b))) + return &sidecarHTTPError{status: resp.StatusCode, message: strings.TrimSpace(string(b))} } return nil } diff --git a/go/internal/selfupdate/selfupdate_test.go b/go/internal/selfupdate/selfupdate_test.go index eca97b0e..1d4ea843 100644 --- a/go/internal/selfupdate/selfupdate_test.go +++ b/go/internal/selfupdate/selfupdate_test.go @@ -3,12 +3,14 @@ package selfupdate import ( "context" "encoding/json" + "net" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "sync" + "sync/atomic" "testing" "time" ) @@ -854,6 +856,76 @@ func TestTrigger_NoSocket(t *testing.T) { } } +// Reported versions, including QA overrides and baked stable identities, +// cannot select another image when a user presses Restart. +func TestTriggerRestartPreservesContainerRegardlessOfReportedVersion(t *testing.T) { + for _, current := range []string{"v2.14.0-beta.1", "v2.14.0", "v2.0.0", "dev", "edge-20260101"} { + t.Run(current, func(t *testing.T) { + dir, err := os.MkdirTemp("", "ftw-su-") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { os.RemoveAll(dir) }) + sock := filepath.Join(dir, "sock") + ln, err := net.Listen("unix", sock) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { ln.Close() }) + got := make(chan map[string]any, 1) + srv := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var body map[string]any + _ = json.NewDecoder(r.Body).Decode(&body) + got <- body + w.WriteHeader(202) + })} + go srv.Serve(ln) + t.Cleanup(func() { srv.Close() }) + c := New(Config{SocketPath: sock, CurrentVersion: current}, newMemStore()) + if err := c.TriggerRestart(context.Background()); err != nil { + t.Fatal(err) + } + body := <-got + if body["action"] != "restart_existing" || body["target"] != "" { + t.Fatalf("unsafe restart request: %v", body) + } + }) + } +} + +func TestTriggerRestartFailsClosedOnOldUpdater(t *testing.T) { + dir, err := os.MkdirTemp("", "ftw-su-") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { os.RemoveAll(dir) }) + sock := filepath.Join(dir, "sock") + ln, err := net.Listen("unix", sock) + if err != nil { + t.Fatal(err) + } + var calls atomic.Int32 + srv := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls.Add(1) + var body map[string]any + _ = json.NewDecoder(r.Body).Decode(&body) + if body["action"] != "restart_existing" { + t.Errorf("unsafe fallback: %v", body) + } + http.Error(w, "action must be update, restart, rollback, or component_rollback", http.StatusBadRequest) + })} + go srv.Serve(ln) + t.Cleanup(func() { srv.Close() }) + c := New(Config{SocketPath: sock, CurrentVersion: "v2.14.0-beta.1"}, newMemStore()) + err = c.TriggerRestart(context.Background()) + if err == nil || !strings.Contains(err.Error(), "safe restart requires a newer updater") { + t.Fatalf("restart error = %v", err) + } + if calls.Load() != 1 { + t.Fatalf("restart requests = %d", calls.Load()) + } +} + // Reported from the field: an amber update badge on a site already running the // newest stable. The badge counts Core + optimizer + drivers, and the optimizer // was the one claiming an update — its current version was never learned, so From 2e01f7f820c68c7b6ed1c7ef109cf60b77f87d89 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Sun, 6 Sep 2026 13:02:33 +0200 Subject: [PATCH 21/57] feat(ev): save battery size through a narrow configuration route Signed-off-by: Fredrik Ahlgren --- .changeset/ev-battery-size-api.md | 5 + go/internal/api/api.go | 1 + go/internal/api/loadpoint_vehicle.go | 72 +++++++++++++ go/internal/api/loadpoint_vehicle_test.go | 122 ++++++++++++++++++++++ 4 files changed, 200 insertions(+) create mode 100644 .changeset/ev-battery-size-api.md create mode 100644 go/internal/api/loadpoint_vehicle.go create mode 100644 go/internal/api/loadpoint_vehicle_test.go diff --git a/.changeset/ev-battery-size-api.md b/.changeset/ev-battery-size-api.md new file mode 100644 index 00000000..d6c51d98 --- /dev/null +++ b/.changeset/ev-battery-size-api.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +Let the charging view save the usual car’s usable battery size without replacing other settings. Apply a saved size through the shared config path and keep the current charge level steady. A failed save leaves the previous size in use. diff --git a/go/internal/api/api.go b/go/internal/api/api.go index e4f9349c..600fd97a 100644 --- a/go/internal/api/api.go +++ b/go/internal/api/api.go @@ -525,6 +525,7 @@ func (s *Server) routes() { // actuation: a schedule saved late is the same instruction, only // later, while target/soc/force_start move energy now. The split is // what lets a phone save one through the passthrough. + s.handle("POST /api/loadpoints/{id}/vehicle", Configure, s.handleLoadpointVehicle) s.handle("PUT /api/loadpoints/{id}/schedule", Configure, s.handleLoadpointSchedulePut) s.handle("DELETE /api/loadpoints/{id}/schedule", Configure, s.handleLoadpointScheduleClear) s.handle("POST /api/loadpoints/{id}/soc", Actuate, s.handleLoadpointSoC, Via(appproto.OpLoadpointSoCSet)) diff --git a/go/internal/api/loadpoint_vehicle.go b/go/internal/api/loadpoint_vehicle.go new file mode 100644 index 00000000..721c94b4 --- /dev/null +++ b/go/internal/api/loadpoint_vehicle.go @@ -0,0 +1,72 @@ +package api + +import ( + "math" + "net/http" + "sync" + + "github.com/srcfl/ftw/go/internal/config" + "github.com/srcfl/ftw/go/internal/configreload" +) + +// Keep two capacity edits from saving different copies of the same config. +// FTW runs one API server; the mutex spans the save and shared apply callback. +var loadpointVehicleWrites sync.Mutex + +// handleLoadpointVehicle changes only the usual car's battery capacity. The +// runtime may still prefer a capacity reported by the car for this session. +func (s *Server) handleLoadpointVehicle(w http.ResponseWriter, r *http.Request) { + if s.deps.Loadpoints == nil || s.deps.Cfg == nil || s.deps.CfgMu == nil || s.deps.SaveConfig == nil || s.deps.Ctrl == nil || s.deps.CtrlMu == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "Charging settings are not available yet."}) + return + } + var body struct { + CapacityWh float64 `json:"capacity_wh"` + } + if err := readJSON(r, &body); err != nil || math.IsNaN(body.CapacityWh) || math.IsInf(body.CapacityWh, 0) || body.CapacityWh < 1000 || body.CapacityWh > 300000 { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Enter a usable battery size from 1 to 300 kWh."}) + return + } + id := r.PathValue("id") + loadpointVehicleWrites.Lock() + defer loadpointVehicleWrites.Unlock() + + // Copy only the slice we edit. Hold the read lock through serialization so + // another config writer cannot change referenced fields while they save. + s.deps.CfgMu.RLock() + next := *s.deps.Cfg + next.Loadpoints = append([]config.Loadpoint(nil), s.deps.Cfg.Loadpoints...) + index := -1 + for i := range next.Loadpoints { + if next.Loadpoints[i].ID == id { + index = i + break + } + } + if index < 0 { + s.deps.CfgMu.RUnlock() + writeJSON(w, http.StatusNotFound, map[string]string{"error": "Charger not found in settings."}) + return + } + next.Loadpoints[index].VehicleCapacityWh = body.CapacityWh + err := s.deps.SaveConfig(s.deps.ConfigPath, &next) + s.deps.CfgMu.RUnlock() + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "Battery size could not be saved. The previous size is still in use."}) + return + } + configreload.Apply(s.deps.CfgMu, s.deps.Cfg, s.deps.CtrlMu, s.deps.Ctrl, &next, s.deps.ConfigApplier) + if s.deps.ConfigApplier == nil { + // Minimal embeddings do not wire main's shared callback. Change just + // this capacity in the manager's existing configuration. + points := s.deps.Loadpoints.Configs() + for i := range points { + if points[i].ID == id { + points[i].VehicleCapacityWh = body.CapacityWh + } + } + s.deps.Loadpoints.Load(points) + } + 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/api/loadpoint_vehicle_test.go b/go/internal/api/loadpoint_vehicle_test.go new file mode 100644 index 00000000..2ac4422a --- /dev/null +++ b/go/internal/api/loadpoint_vehicle_test.go @@ -0,0 +1,122 @@ +package api + +import ( + "encoding/json" + "errors" + "math" + "net/http" + "net/http/httptest" + "os" + "reflect" + "strings" + "testing" + + "github.com/srcfl/ftw/go/internal/apiauth" + "github.com/srcfl/ftw/go/internal/config" + "github.com/srcfl/ftw/go/internal/loadpoint" +) + +func vehicleServer(t *testing.T) (*Server, *loadpoint.Manager) { + t.Helper() + srv, _, cfg := postConfigServer(t, nil) + cfg.Site.Name = "Home" + cfg.Drivers = []config.Driver{{Name: "easee", Lua: "/app/drivers/easee.lua", Config: map[string]any{"token": "preserve-me"}}} + cfg.Loadpoints = []config.Loadpoint{{ID: "garage", DriverName: "easee", VehicleCapacityWh: 60000, MaxChargeW: 11000}, {ID: "guest", DriverName: "other", VehicleCapacityWh: 45000}} + mgr := loadpoint.NewManager() + mgr.Load([]loadpoint.Config{{ID: "garage", DriverName: "easee", VehicleCapacityWh: 60000, MaxChargeW: 11000}, {ID: "guest", DriverName: "other", VehicleCapacityWh: 45000}}) + mgr.Observe("garage", true, 7000, 0, true) + mgr.Observe("garage", true, 7000, 6000, true) + mgr.SetCurrentSoC("garage", 0.42) + srv.deps.Loadpoints = mgr + srv.deps.SaveConfig = config.SaveAtomic + return srv, mgr +} + +func postVehicle(srv *Server, id, body string) *httptest.ResponseRecorder { + rr := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/loadpoints/"+id+"/vehicle", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + srv.Handler().ServeHTTP(rr, req) + return rr +} + +func TestVehicleCapacitySaveReadbackAndSession(t *testing.T) { + srv, mgr := vehicleServer(t) + beforeDrivers := srv.deps.Cfg.Drivers + beforeGuest := srv.deps.Cfg.Loadpoints[1] + rr := postVehicle(srv, "garage", `{"capacity_wh":77400}`) + if rr.Code != http.StatusOK { + t.Fatalf("status %d: %s", rr.Code, rr.Body.String()) + } + var result struct { + OK bool `json:"ok"` + Capacity float64 `json:"vehicle_capacity_wh"` + } + if err := json.Unmarshal(rr.Body.Bytes(), &result); err != nil || !result.OK || result.Capacity != 77400 { + t.Fatalf("response %s: %v", rr.Body.String(), err) + } + if !reflect.DeepEqual(beforeDrivers, srv.deps.Cfg.Drivers) || beforeGuest.VehicleCapacityWh != srv.deps.Cfg.Loadpoints[1].VehicleCapacityWh || srv.deps.Cfg.Site.Name != "Home" { + t.Fatal("unrelated settings changed") + } + raw, err := os.ReadFile(srv.deps.ConfigPath) + if err != nil || !strings.Contains(string(raw), "vehicle_capacity_wh: 77400") || !strings.Contains(string(raw), "preserve-me") { + t.Fatalf("saved config lost data: %v", err) + } + state, _ := mgr.State("garage") + if state.VehicleCapacityWh != 77400 || math.Abs(state.CurrentSoC-0.42) > 0.000001 || state.DeliveredWhSession != 6000 { + t.Fatalf("capacity edit changed current session: %+v", state) + } + read := httptest.NewRecorder() + srv.Handler().ServeHTTP(read, httptest.NewRequest(http.MethodGet, "/api/loadpoints", nil)) + if read.Code != 200 || !strings.Contains(read.Body.String(), `"vehicle_capacity_wh":77400`) { + t.Fatalf("readback: %s", read.Body.String()) + } +} + +func TestVehicleCapacityFailedSaveDoesNotApply(t *testing.T) { + srv, mgr := vehicleServer(t) + called := false + srv.deps.ConfigApplier = func(_, _ *config.Config) { called = true } + srv.deps.SaveConfig = func(string, *config.Config) error { return errors.New("disk full") } + rr := postVehicle(srv, "garage", `{"capacity_wh":80000}`) + state, _ := mgr.State("garage") + if rr.Code != 500 || called || state.VehicleCapacityWh != 60000 || srv.deps.Cfg.Loadpoints[0].VehicleCapacityWh != 60000 { + t.Fatalf("failed save reached runtime: status %d, callback %v, capacity %v", rr.Code, called, state.VehicleCapacityWh) + } +} + +func TestVehicleCapacityUsesSharedApplier(t *testing.T) { + srv, _ := vehicleServer(t) + var oldCapacity, nextCapacity float64 + srv.deps.ConfigApplier = func(next, old *config.Config) { + oldCapacity = old.Loadpoints[0].VehicleCapacityWh + nextCapacity = next.Loadpoints[0].VehicleCapacityWh + } + if rr := postVehicle(srv, "garage", `{"capacity_wh":80000}`); rr.Code != 200 { + t.Fatal(rr.Body.String()) + } + if oldCapacity != 60000 || nextCapacity != 80000 { + t.Fatalf("wrong apply snapshots: %v -> %v", oldCapacity, nextCapacity) + } +} + +func TestVehicleCapacityValidatesAndIsConfigure(t *testing.T) { + srv, _ := vehicleServer(t) + for _, body := range []string{``, `{`, `{}`, `{"capacity_wh":0}`, `{"capacity_wh":999}`, `{"capacity_wh":300001}`, `{"capacity_wh":1e1000}`, `{"capacity_wh":"80000"}`} { + if rr := postVehicle(srv, "garage", body); rr.Code != 400 { + t.Fatalf("body %q gave %d", body, rr.Code) + } + } + if rr := postVehicle(srv, "unknown", `{"capacity_wh":80000}`); rr.Code != 404 { + t.Fatalf("unknown id gave %d", rr.Code) + } + for _, body := range []string{`{"capacity_wh":1000}`, `{"capacity_wh":300000}`} { + if rr := postVehicle(srv, "garage", body); rr.Code != 200 { + t.Fatalf("boundary %q gave %d", body, rr.Code) + } + } + facts := srv.Route(httptest.NewRequest(http.MethodPost, "/api/loadpoints/garage/vehicle", nil)) + if facts.Tier != apiauth.TierConfigure || facts.ReplacesAll { + t.Fatalf("route price: %+v", facts) + } +} From 008ba8cfc86a1c781bd1e84eb75e2cde50b313ed Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Sun, 6 Sep 2026 12:57:16 +0200 Subject: [PATCH 22/57] fix(ev): name charger limits separately from the main fuse Signed-off-by: Fredrik Ahlgren --- .changeset/ev-charger-limit-words.md | 5 +++++ web/app.js | 1 + web/ev-manual-feedback.test.mjs | 7 +++++++ 3 files changed, 13 insertions(+) create mode 100644 .changeset/ev-charger-limit-words.md diff --git a/.changeset/ev-charger-limit-words.md b/.changeset/ev-charger-limit-words.md new file mode 100644 index 00000000..f1510d7d --- /dev/null +++ b/.changeset/ev-charger-limit-words.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +Explain when the charger’s own current limit reduces a request, without blaming the main fuse. diff --git a/web/app.js b/web/app.js index a6e2f5af..0825dbbd 100644 --- a/web/app.js +++ b/web/app.js @@ -2775,6 +2775,7 @@ return "Nothing is charging" + (since ? " after " + since : "") + ": the charger has not acted on " + reqA + "." + (reason || " Check the car's own charge limit or schedule, then the charger's app."); case "limited": + if (m.limit_reason === "charger_limit") return "The charger limits this request to " + cmdA + " (" + reqA + " requested)."; if (m.limit_reason === "fuse_cooldown") { return "Paused: main-fuse protection — " + reqA + " requested; charging resumes on its own." + sinceP; } diff --git a/web/ev-manual-feedback.test.mjs b/web/ev-manual-feedback.test.mjs index d4154165..0d43dd5e 100644 --- a/web/ev-manual-feedback.test.mjs +++ b/web/ev-manual-feedback.test.mjs @@ -88,3 +88,10 @@ test('pause status waits for the charger before claiming it stopped', () => { assert.match(paused, /Paused by you/); assert.match(paused, /until you resume the plan/); }); + + +test('a charger limit does not blame the main fuse', () => { + const words = describeManual({ ...lp, manual: { ...lp.manual, state: 'limited', limit_reason: 'charger_limit', commanded_a: 10 } }); + assert.match(words, /The charger limits this request to 10 A/); + assert.doesNotMatch(words, /Main fuse/); +}); From e115b78319fa509f7d0f7c8907ab6650c6acb7c5 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Sun, 6 Sep 2026 12:56:08 +0200 Subject: [PATCH 23/57] fix(ev): clear prior vehicle state after an unseen reconnect Signed-off-by: Fredrik Ahlgren --- go/internal/loadpoint/session_state.go | 18 ++++++++++++++-- go/internal/loadpoint/session_state_test.go | 24 +++++++++++++++++++++ 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/go/internal/loadpoint/session_state.go b/go/internal/loadpoint/session_state.go index d905e702..f61ec223 100644 --- a/go/internal/loadpoint/session_state.go +++ b/go/internal/loadpoint/session_state.go @@ -6,6 +6,7 @@ import ( "encoding/json" "math" "strings" + "time" "github.com/srcfl/ftw/go/internal/events" ) @@ -80,6 +81,15 @@ func (m *Manager) ObserveSession(id string, pluggedIn bool, powerW, deliveredWh // offline. Run the ordinary plug-in reset even if connected stayed true. if changed || regressed { lp.pluggedIn = false + lp.chargingSteadySince = time.Time{} + lp.stoppedSince = time.Time{} + lp.steadyRunArmed = false + if lp.vehicleName != "" || lp.capacityFromCar { + lp.VehicleCapacityWh = lp.baseCapacityWh + lp.vehicleName = "" + lp.capacityFromCar = false + lp.baseCapacityWh = 0 + } } lp.sessionDeviceID, lp.sessionID = deviceID, sessionID confirmed := lp.socConfirmed && lp.pluggedIn @@ -88,8 +98,12 @@ func (m *Manager) ObserveSession(id string, pluggedIn bool, powerW, deliveredWh if !pluggedIn || 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 && previousDevice != "" { - _ = m.sessionStore.SaveConfig(sessionKey(previousDevice), "{}") + if m.sessionStore != nil { + for _, knownDevice := range []string{previousDevice, deviceID} { + if knownDevice != "" { + _ = m.sessionStore.SaveConfig(sessionKey(knownDevice), "{}") + } + } } } var restore *savedSession diff --git a/go/internal/loadpoint/session_state_test.go b/go/internal/loadpoint/session_state_test.go index 620c52ad..71aca0a4 100644 --- a/go/internal/loadpoint/session_state_test.go +++ b/go/internal/loadpoint/session_state_test.go @@ -126,3 +126,27 @@ 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.ObserveSession("garage", true, 4300, 2000, true, "easee:ABC", "session-2") + if s, _ := m.State("garage"); s.VehicleName != "" || s.VehicleCapacityWh != 60000 { + t.Fatalf("prior car leaked after an unseen reconnect: %+v", s) + } +} + +func TestFirstReadingUnpluggedTombstonesStoredSession(t *testing.T) { + store := &sessionMemory{data: map[string]string{}} + m := sessionManager(store, "garage", "charger") + m.ObserveSession("garage", true, 4300, 1000, true, "easee:ABC", "session-1") + m.SetCurrentSoC("garage", .84) + m = sessionManager(store, "garage", "charger") + m.ObserveSession("garage", false, 0, 0, false, "easee:ABC", "") + m = sessionManager(store, "garage", "charger") + m.ObserveSession("garage", true, 0, 1000, true, "easee:ABC", "session-1") + if s, _ := m.State("garage"); s.SoCSource != "assumed" { + t.Fatalf("cold unplug failed to clear stored session: %+v", s) + } +} From 51024e6a827ed6b4c613f3f051bcc630777e11d8 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Sun, 6 Sep 2026 13:07:24 +0200 Subject: [PATCH 24/57] fix(ev): enforce pause and limits with honest command confirmation Signed-off-by: Fredrik Ahlgren --- .changeset/ev-pause-and-limits.md | 5 ++ go/cmd/ftw/ev_observation.go | 36 ++++++++ go/cmd/ftw/ev_observation_test.go | 34 +++++++ go/cmd/ftw/main.go | 52 +++++------ go/internal/api/api.go | 12 ++- go/internal/api/api_loadpoint_manual.go | 10 ++- go/internal/appproto/ev.go | 31 +++++-- go/internal/appproto/ev_test.go | 5 ++ go/internal/loadpoint/controller.go | 22 ++++- .../controller_charge_now_release_test.go | 4 +- .../controller_commanded_reason_test.go | 4 +- .../loadpoint/controller_manual_hold_test.go | 14 +-- go/internal/loadpoint/manual_limits.go | 90 +++++++++++++++++++ go/internal/loadpoint/manual_limits_test.go | 65 ++++++++++++++ .../loadpoint/manual_pause_status_test.go | 56 ++++++++++++ go/internal/loadpoint/manual_status.go | 37 ++++++-- go/internal/loadpoint/snap.go | 15 ++-- 17 files changed, 419 insertions(+), 73 deletions(-) create mode 100644 .changeset/ev-pause-and-limits.md create mode 100644 go/cmd/ftw/ev_observation.go create mode 100644 go/cmd/ftw/ev_observation_test.go create mode 100644 go/internal/loadpoint/manual_limits.go create mode 100644 go/internal/loadpoint/manual_limits_test.go create mode 100644 go/internal/loadpoint/manual_pause_status_test.go diff --git a/.changeset/ev-pause-and-limits.md b/.changeset/ev-pause-and-limits.md new file mode 100644 index 00000000..50da7d13 --- /dev/null +++ b/.changeset/ev-pause-and-limits.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +Keep a manual pause until the user resumes or unplugs. Show when a pause is waiting for the charger and when it is confirmed. Keep manual charging available without a planner and enforce charger and installation limits on every manual request. diff --git a/go/cmd/ftw/ev_observation.go b/go/cmd/ftw/ev_observation.go new file mode 100644 index 00000000..eed344c6 --- /dev/null +++ b/go/cmd/ftw/ev_observation.go @@ -0,0 +1,36 @@ +package main + +import ( + "encoding/json" + "time" + + "github.com/srcfl/ftw/go/internal/loadpoint" + "github.com/srcfl/ftw/go/internal/telemetry" +) + +// A missing or stale cloud response is not an unplug. Keep the last session +// until a fresh, valid reading reports the connection has ended. +func currentEVSample(r *telemetry.DerReading, health *telemetry.DriverHealth, watchdog time.Duration, now time.Time, ocppOnline bool, deviceID string) (loadpoint.EVSample, bool) { + if watchdog <= 0 { + watchdog = time.Minute + } + if r == nil || r.UpdatedAt.IsZero() || now.Sub(r.UpdatedAt) > watchdog || + (!ocppOnline && (health == nil || !health.TelemetryLive())) { + return loadpoint.EVSample{}, false + } + var d struct { + Connected *bool `json:"connected"` + SessionWh float64 `json:"session_wh"` + RequestActive *bool `json:"request_active"` + SessionID string `json:"session_id"` + } + if json.Unmarshal(r.Data, &d) != nil || d.Connected == nil || d.SessionWh < 0 { + return loadpoint.EVSample{}, false + } + active := true + if d.RequestActive != nil { + active = *d.RequestActive + } + return loadpoint.EVSample{PowerW: r.SmoothedW, SessionWh: d.SessionWh, + Connected: *d.Connected, RequestActive: active, DeviceID: deviceID, SessionID: d.SessionID}, true +} diff --git a/go/cmd/ftw/ev_observation_test.go b/go/cmd/ftw/ev_observation_test.go new file mode 100644 index 00000000..9f630d1d --- /dev/null +++ b/go/cmd/ftw/ev_observation_test.go @@ -0,0 +1,34 @@ +package main + +import ( + "encoding/json" + "testing" + "time" + + "github.com/srcfl/ftw/go/internal/telemetry" +) + +func TestEVObservationPreservesSessionWhenCloudIsStale(t *testing.T) { + now := time.Now() + health := &telemetry.DriverHealth{Status: telemetry.StatusOk} + r := &telemetry.DerReading{UpdatedAt: now, SmoothedW: 4140, Data: json.RawMessage(`{"connected":true,"session_wh":600,"session_id":"728"}`)} + s, ok := currentEVSample(r, health, time.Minute, now, false, "sn:charger") + if !ok || !s.Connected || !s.RequestActive || s.SessionID != "728" || s.DeviceID != "sn:charger" { + t.Fatalf("valid session lost: %+v %v", s, ok) + } + r.UpdatedAt = now.Add(-2 * time.Minute) + if _, ok := currentEVSample(r, health, time.Minute, now, false, "sn:charger"); ok { + t.Fatal("stale reading accepted") + } + r.UpdatedAt = now + for _, body := range []string{`{`, `{}`, `{"connected":true,"session_wh":-1}`} { + r.Data = json.RawMessage(body) + if _, ok := currentEVSample(r, health, time.Minute, now, false, "sn:charger"); ok { + t.Fatalf("bad response became a session reading: %s", body) + } + } + r.Data = json.RawMessage(`{"connected":false}`) + if s, ok := currentEVSample(r, nil, time.Minute, now, true, ""); !ok || s.Connected { + t.Fatalf("fresh OCPP unplug lost: %+v %v", s, ok) + } +} diff --git a/go/cmd/ftw/main.go b/go/cmd/ftw/main.go index ae130ec5..00acf082 100644 --- a/go/cmd/ftw/main.go +++ b/go/cmd/ftw/main.go @@ -748,6 +748,7 @@ func main() { // The planner consumes loadpoint state so battery and EV can be // co-optimized in one DP. lpMgr := loadpoint.NewManager() + lpMgr.SetSessionStore(st) if len(cfg.Loadpoints) > 0 { lpMgr.Load(buildLoadpointConfigs(cfg.Loadpoints)) slog.Info("loadpoints configured", "count", len(cfg.Loadpoints)) @@ -1518,7 +1519,10 @@ func main() { mpcSvc.Loadpoints = func(slotLenMin int) []*mpc.LoadpointSpec { specs := make([]*mpc.LoadpointSpec, 0) for _, st := range lpMgr.States() { - if !st.PluggedIn { + if lpController != nil { + lpController.SetGridDeferred(st.ID, false) + } + if !st.PluggedIn || st.ChargingDeclined { continue } // An active boost lease may carry a session-local EV target and @@ -1828,8 +1832,11 @@ func main() { // (mpc already imports loadpoint — the cycle must go this way). // lpController is forward-declared earlier so the MPC spec builder // closure can push grid-deferred state into it. - if mpcSvc != nil { + { planAdapter := func(now time.Time) (loadpoint.Directive, bool) { + if mpcSvc == nil { + return loadpoint.Directive{}, false + } d, ok := mpcSvc.SlotDirectiveAt(now) if !ok { return loadpoint.Directive{}, false @@ -1837,31 +1844,16 @@ func main() { return d.LoadpointDirective(), true } telAdapter := func(driver string) (loadpoint.EVSample, bool) { - r := tel.Get(driver, telemetry.DerEV) - if r == nil { - return loadpoint.EVSample{}, false - } - // RequestActive defaults to true so drivers that - // don't emit the field keep their pre-existing - // behaviour — only drivers that explicitly emit - // request_active=false will trip the - // session-completion detector. - d := struct { - Connected bool `json:"connected"` - SessionWh float64 `json:"session_wh"` - RequestActive *bool `json:"request_active"` - }{} - _ = json.Unmarshal(r.Data, &d) - reqActive := true - if d.RequestActive != nil { - reqActive = *d.RequestActive + cfgMu.RLock() + watchdog := time.Duration(cfg.Site.WatchdogTimeoutS) * time.Second + cfgMu.RUnlock() + health := tel.DriverHealth(driver) + if health != nil && health.WatchdogTimeoutOverride > 0 { + watchdog = health.WatchdogTimeoutOverride } - return loadpoint.EVSample{ - PowerW: r.SmoothedW, - SessionWh: d.SessionWh, - Connected: d.Connected, - RequestActive: reqActive, - }, true + deviceID, _ := runningDeviceID(reg, driver) + ocppOnline := ocppSrv != nil && ocppSrv.Handler().IsOnline(driver) + return currentEVSample(tel.Get(driver, telemetry.DerEV), health, watchdog, time.Now(), ocppOnline, deviceID) } // evSend routes OCPP chargers past the driver registry; loadpoints // stay unaware of the difference. @@ -2482,12 +2474,10 @@ func main() { SelfUpdate: selfUpdater, OptimizerUpdate: optimizerUpdater, Restart: func(reqCtx context.Context) error { - // Prefer the docker-compose sidecar path when wired up: the - // updater container does docker compose up -d --force-recreate, - // which is the same code path post-update restarts use, so - // there's only one battle-tested escape hatch in production. + // Restart the existing container through the updater. + // An old updater refuses this action before touching Docker. if selfUpdater != nil { - if err := selfUpdater.Trigger(reqCtx, "restart", ""); err == nil { + if err := selfUpdater.TriggerRestart(reqCtx); err == nil { slog.Info("restart: dispatched via updater sidecar") return nil } else { diff --git a/go/internal/api/api.go b/go/internal/api/api.go index 600fd97a..98d3ef63 100644 --- a/go/internal/api/api.go +++ b/go/internal/api/api.go @@ -3457,19 +3457,17 @@ func applyManualEVHold(deps *Deps, driverName string, action string) { return } if action == "ev_pause" { - deps.LoadpointCtrl.ClearManualHold(lpID) - slog.Info("ev manual pause — cleared manual hold, reverting to plan", "lp", lpID) + deps.LoadpointCtrl.SetManualHold(lpID, loadpoint.ManualHold{PowerW: 0, Persistent: true}) + slog.Info("ev manual pause — held at zero power", "lp", lpID) return } if maxW <= 0 { maxW = 11000 // 16 A × 3φ × 230 V fallback when the LP config didn't set it } - // 100-year expiry serves as "sticky until the operator cancels". - // Using time.Now() + a long delta rather than time.Time{} because - // SetManualHold treats zero ExpiresAt as "delete" (controller.go:653). + // Keep an explicit start active until the operator changes it or unplugs. deps.LoadpointCtrl.SetManualHold(lpID, loadpoint.ManualHold{ - PowerW: maxW, - ExpiresAt: time.Now().Add(100 * 365 * 24 * time.Hour), + PowerW: maxW, + Persistent: true, }) slog.Info("ev manual start/resume — installed sticky hold", "lp", lpID, "action", action, "hold_w", maxW) diff --git a/go/internal/api/api_loadpoint_manual.go b/go/internal/api/api_loadpoint_manual.go index f974ce06..fa490155 100644 --- a/go/internal/api/api_loadpoint_manual.go +++ b/go/internal/api/api_loadpoint_manual.go @@ -50,7 +50,7 @@ type manualHoldRequest struct { // confirm what's installed. Returned by POST and GET. type manualHoldResponse struct { Active bool `json:"active"` - PowerW float64 `json:"power_w,omitempty"` + PowerW float64 `json:"power_w"` PhaseMode string `json:"phase_mode,omitempty"` PhaseSplitW float64 `json:"phase_split_w,omitempty"` MinPhaseHoldS int `json:"min_phase_hold_s,omitempty"` @@ -110,8 +110,12 @@ func (s *Server) handleLoadpointManualHold(w http.ResponseWriter, r *http.Reques }) return } - if req.PowerW < 0 { - writeJSON(w, 400, map[string]string{"error": "power_w must be >= 0"}) + if req.PowerW < 0 || req.PhaseSplitW < 0 || req.Voltage < 0 || req.MaxAmpsPerPhase < 0 || req.MinPhaseHoldS < 0 { + writeJSON(w, 400, map[string]string{"error": "power and optional phase limits must be >= 0"}) + return + } + if req.SitePhases != 0 && req.SitePhases != 1 && req.SitePhases != 3 { + writeJSON(w, 400, map[string]string{"error": "site_phases must be 1 or 3 when set"}) return } switch req.PhaseMode { diff --git a/go/internal/appproto/ev.go b/go/internal/appproto/ev.go index ac1f6e87..9621c0ab 100644 --- a/go/internal/appproto/ev.go +++ b/go/internal/appproto/ev.go @@ -27,7 +27,7 @@ func argNum(args map[string]any, key string) (float64, bool) { case uint64: return float64(v), true case float64: - return v, true + return v, !math.IsNaN(v) && !math.IsInf(v, 0) } return 0, false } @@ -36,7 +36,7 @@ func argNum(args map[string]any, key string) (float64, bool) { // millisecond is a client bug worth refusing rather than rounding. func argInt(args map[string]any, key string) (int64, bool) { f, ok := argNum(args, key) - if !ok || f != math.Trunc(f) { + if !ok || f != math.Trunc(f) || f < math.MinInt64 || f >= math.MaxInt64 { return 0, false } return int64(f), true @@ -98,9 +98,25 @@ func (h *Handler) loadpointHold(cmd Cmd, uptimeMs int64) error { // absent means 0, a valid "pause" hold, exactly as an omitted JSON field // would; hold_s of 0 or absent is the persistent operator hold that only // clear or an unplug releases. - powerW, _ := argNum(cmd.Args, "power_w") - if powerW < 0 { - return h.rejectArg(cmd, "power_w", powerW) + powerW, powerOK := argNum(cmd.Args, "power_w") + if _, present := cmd.Args["power_w"]; (present && !powerOK) || powerW < 0 { + return h.rejectArg(cmd, "power_w", cmd.Args["power_w"]) + } + for _, key := range []string{"phase_split_w", "voltage", "max_amps_per_phase"} { + if value, present := cmd.Args[key]; present { + n, ok := argNum(cmd.Args, key) + if !ok || n < 0 { + return h.rejectArg(cmd, key, value) + } + } + } + for _, key := range []string{"min_phase_hold_s", "site_phases"} { + if value, present := cmd.Args[key]; present { + n, ok := argInt(cmd.Args, key) + if !ok || n < 0 || (key == "site_phases" && n != 0 && n != 1 && n != 3) { + return h.rejectArg(cmd, key, value) + } + } } var holdS int64 if _, present := cmd.Args["hold_s"]; present { @@ -113,7 +129,10 @@ func (h *Handler) loadpointHold(cmd Cmd, uptimeMs int64) error { if holdS > int64(loadpoint.MaxManualHold/time.Second) { return h.rejectArg(cmd, "hold_s", holdS) } - phaseMode, _ := cmd.Args["phase_mode"].(string) + phaseMode, phaseOK := cmd.Args["phase_mode"].(string) + if _, present := cmd.Args["phase_mode"]; present && !phaseOK { + return h.rejectArg(cmd, "phase_mode", cmd.Args["phase_mode"]) + } switch phaseMode { case "", "auto", "1p", "3p": default: diff --git a/go/internal/appproto/ev_test.go b/go/internal/appproto/ev_test.go index 818c4fa0..4ec0aaf8 100644 --- a/go/internal/appproto/ev_test.go +++ b/go/internal/appproto/ev_test.go @@ -552,6 +552,11 @@ func TestAMalformedLoadpointCommandIsRefused(t *testing.T) { args map[string]any arg string }{ + {"text power", OpLoadpointHold, map[string]any{"id": "lp1", "power_w": "6000"}, "power_w"}, + {"text limit", OpLoadpointHold, map[string]any{"id": "lp1", "power_w": 4140, "max_amps_per_phase": "32"}, "max_amps_per_phase"}, + {"negative voltage", OpLoadpointHold, map[string]any{"id": "lp1", "power_w": 4140, "voltage": -230}, "voltage"}, + {"fractional phase hold", OpLoadpointHold, map[string]any{"id": "lp1", "power_w": 4140, "min_phase_hold_s": 1.5}, "min_phase_hold_s"}, + {"invalid phases", OpLoadpointHold, map[string]any{"id": "lp1", "power_w": 4140, "site_phases": 2}, "site_phases"}, {"negative power", OpLoadpointHold, map[string]any{"id": "lp1", "power_w": -100, "hold_s": 0}, "power_w"}, {"hold beyond the cap", OpLoadpointHold, diff --git a/go/internal/loadpoint/controller.go b/go/internal/loadpoint/controller.go index fb667151..998df2f2 100644 --- a/go/internal/loadpoint/controller.go +++ b/go/internal/loadpoint/controller.go @@ -408,6 +408,7 @@ type ManualHold struct { ReleaseAtSoC float64 // StartedAt is when the operator installed the hold. The API keeps it + UpdatedAt time.Time `json:"updated_at,omitempty"` // across an Update of the amps, so the manual tab can say how long the // charge has been asked for. SetManualHold fills a zero value. StartedAt time.Time @@ -439,6 +440,8 @@ type EVSample struct { SessionWh float64 Connected bool RequestActive bool + DeviceID string + SessionID string } // PlanFunc returns the current-slot directive for now, or (_, false) @@ -1307,6 +1310,9 @@ func (c *Controller) SetManualHold(id string, h ManualHold) { if c == nil { return } + if h.PowerW > 0 && c.manager != nil { + c.manager.RetryCharging(id) + } c.holdMu.Lock() if c.holds == nil { c.holds = map[string]ManualHold{} @@ -1319,6 +1325,7 @@ func (c *Controller) SetManualHold(id string, h ManualHold) { if h.StartedAt.IsZero() { h.StartedAt = time.Now() } + h.UpdatedAt = time.Now() c.holds[id] = h } saver := c.manualHoldSaver @@ -1506,7 +1513,7 @@ func (c *Controller) tickOne(ctx context.Context, now time.Time, lpCfg Config, d enteringSurplusPaused, _ := c.getSurplusPause(lpCfg.ID) selfWithheld := surplusOn && enteringSurplusPaused c.manager.SetSurplusWithheld(lpCfg.ID, selfWithheld) - c.manager.Observe(lpCfg.ID, sample.Connected, sample.PowerW, sample.SessionWh, sample.RequestActive) + c.manager.ObserveSession(lpCfg.ID, sample.Connected, sample.PowerW, sample.SessionWh, sample.RequestActive, sample.DeviceID, sample.SessionID) c.evaluateBatteryBoost(lpCfg.ID, now, sample.Connected, dispatchAllowed) if !sample.Connected { c.resetSurplusSession(lpCfg.ID) @@ -1574,7 +1581,7 @@ func (c *Controller) tickOne(ctx context.Context, now time.Time, lpCfg Config, d // "throttled to 0" (RequestActive); others leave it true and never // auto-release. Done before the dispatch branch below so the freed // tick falls straight through to automatic (surplus/plan) dispatch. - if _, held := c.GetManualHold(lpCfg.ID, now); held { + if hold, held := c.GetManualHold(lpCfg.ID, now); held && hold.PowerW > 0 { if !sample.RequestActive { if c.manualHoldIdleFor(lpCfg.ID, now) >= SessionCompletionTimeout { slog.Info("loadpoint manual hold auto-released — vehicle stopped requesting current (full/declined)", @@ -1611,7 +1618,10 @@ func (c *Controller) tickOne(ctx context.Context, now time.Time, lpCfg Config, d // zero/empty fields fall through to the normal defaults so a // minimal hold (just `power_w`) still carries the per-phase // fuse clamp inputs the driver needs to stay safe. - holdW := hold.PowerW + holdW := clampManualPower(lpCfg, hold, c.siteFuse()) + if holdW != hold.PowerW { + cmdReason = "charger_limit" + } // An explicit manual hold ("Start" / amp slider) takes priority // over surplus_only: when the operator deliberately pins a charge // rate we honour it even if that means importing from the grid. @@ -1833,7 +1843,13 @@ func (c *Controller) tickOne(ctx context.Context, now time.Time, lpCfg Config, d } } + // A manual diagnostic may lower a site limit, but never replace the + // installation's voltage, phase count or fuse with a larger offer. + c.applyInstallationLimits(cmd) c.applyPerPhaseFuseClamp(lpCfg, cmd) + if applyCurrentCeiling(cmd) { + cmdReason = "fuse_limit" + } // Tell the manager what was ordered, after every clamp has spoken. // The interruption latch reads this to keep a pause the box chose — // plan slot, Stop hold, surplus clamp — from ever reading as a diff --git a/go/internal/loadpoint/controller_charge_now_release_test.go b/go/internal/loadpoint/controller_charge_now_release_test.go index 4bb36fbd..d997f94e 100644 --- a/go/internal/loadpoint/controller_charge_now_release_test.go +++ b/go/internal/loadpoint/controller_charge_now_release_test.go @@ -49,8 +49,8 @@ func TestChargeNowHoldReleasesAtTargetSoC(t *testing.T) { if _, active := c.GetManualHold(cfg.ID, base); !active { t.Fatalf("hold released below its target SoC") } - if n := len(sender.calls); n == 0 || sender.calls[n-1].power != 11040 { - t.Fatalf("below target: want the 11040 W hold dispatched, got %+v", sender.calls) + if n := len(sender.calls); n == 0 || sender.calls[n-1].power != 11000 { + t.Fatalf("below target: want the 11000 W charger limit dispatched, got %+v", sender.calls) } // Session energy grows past the target: SoC = 0.5 + 18300/60000 = diff --git a/go/internal/loadpoint/controller_commanded_reason_test.go b/go/internal/loadpoint/controller_commanded_reason_test.go index 68c13432..c2ec3d97 100644 --- a/go/internal/loadpoint/controller_commanded_reason_test.go +++ b/go/internal/loadpoint/controller_commanded_reason_test.go @@ -144,7 +144,7 @@ func TestCommandedReasonManualHold(t *testing.T) { c.Tick(context.Background(), base) - if w, r := commandedReason(t, c, cfg.ID); w != 11040 || r != "manual_hold" { - t.Errorf("hold: want (11040, manual_hold), got (%.0f, %q)", w, r) + if w, r := commandedReason(t, c, cfg.ID); w != 11000 || r != "charger_limit" { + t.Errorf("hold: want (11000, charger_limit), got (%.0f, %q)", w, r) } } diff --git a/go/internal/loadpoint/controller_manual_hold_test.go b/go/internal/loadpoint/controller_manual_hold_test.go index 5a660fe4..c706e04f 100644 --- a/go/internal/loadpoint/controller_manual_hold_test.go +++ b/go/internal/loadpoint/controller_manual_hold_test.go @@ -63,7 +63,7 @@ func TestManualHoldOverridesPlannerBudget(t *testing.T) { } } -func TestManualHoldPropagatesSiteFuseFields(t *testing.T) { +func TestManualHoldCannotReplaceInstallationLimits(t *testing.T) { now := time.Date(2026, 4, 26, 18, 0, 0, 0, time.UTC) cfg := holdLoadpoint() cmd := runHoldTick(t, cfg, ManualHold{ @@ -74,11 +74,11 @@ func TestManualHoldPropagatesSiteFuseFields(t *testing.T) { SitePhases: 3, ExpiresAt: now.Add(60 * time.Second), }, now) - if cmd.voltage != 240 { - t.Errorf("voltage = %.0f, want 240", cmd.voltage) + if cmd.voltage != 230 { + t.Errorf("voltage = %.0f, want configured 230", cmd.voltage) } - if cmd.maxAmpsPerPhase != 20 { - t.Errorf("max_amps_per_phase = %.0f, want 20", cmd.maxAmpsPerPhase) + if cmd.maxAmpsPerPhase != 16 { + t.Errorf("max_amps_per_phase = %.0f, want configured 16", cmd.maxAmpsPerPhase) } if cmd.sitePhases != 3 { t.Errorf("site_phases = %d, want 3", cmd.sitePhases) @@ -255,7 +255,7 @@ func TestManualHoldExplicitFieldsOverrideDefaults(t *testing.T) { if cmd.phaseMode != "1p" { t.Errorf("phase_mode = %q, want \"1p\" (operator override)", cmd.phaseMode) } - if cmd.voltage != 220 { - t.Errorf("voltage = %.0f, want 220 (operator override)", cmd.voltage) + if cmd.voltage != 230 { + t.Errorf("voltage = %.0f, want configured 230", cmd.voltage) } } diff --git a/go/internal/loadpoint/manual_limits.go b/go/internal/loadpoint/manual_limits.go new file mode 100644 index 00000000..7c67c04e --- /dev/null +++ b/go/internal/loadpoint/manual_limits.go @@ -0,0 +1,90 @@ +package loadpoint + +import "math" + +// A manual selection is a ceiling. Never round it up to a larger step or +// let it bypass the configured charger's rating. +func clampManualPower(cfg Config, hold ManualHold, site SiteFuse) float64 { + want := hold.PowerW + if math.IsNaN(want) || math.IsInf(want, 0) || want <= 0 { + return 0 + } + if cfg.MaxChargeW > 0 && want > cfg.MaxChargeW { + want = cfg.MaxChargeW + } + floor := cfg.MinChargeW + mode := hold.PhaseMode + if mode == "" { + mode = cfg.PhaseMode + } + if (mode == "1p" || mode == "auto") && site.Phases() == 3 { + floor /= 3 + } + if want < floor { + return 0 + } + if len(cfg.AllowedStepsW) == 0 { + return want + } + best := 0.0 + for _, step := range cfg.AllowedStepsW { + if step >= floor && step <= want && step > best { + best = step + } + } + return best +} + +func (c *Controller) applyInstallationLimits(cmd map[string]any) { + site := c.siteFuse() + if site.Voltage > 0 { + cmd["voltage"] = site.Voltage + } + if site.PhaseCnt > 0 { + cmd["site_phases"] = site.Phases() + } + if site.Phases() == 1 { + cmd["phase_mode"] = "1p" + } + if site.MaxAmps > 0 { + limit, _ := cmd["max_amps_per_phase"].(float64) + if limit <= 0 || math.IsNaN(limit) || math.IsInf(limit, 0) || limit > site.MaxAmps { + cmd["max_amps_per_phase"] = site.MaxAmps + } + } +} + +// The watt order must reflect the final current ceiling too. In particular, +// drivers often interpret max_amps_per_phase=0 as an absent override; send an +// explicit zero-power order when the fuse leaves less than the 6 A minimum. +func applyCurrentCeiling(cmd map[string]any) bool { + w, _ := cmd["power_w"].(float64) + a, known := cmd["max_amps_per_phase"].(float64) + if w <= 0 || !known { + return false + } + if a < 6 { + cmd["power_w"] = float64(0) + return true + } + v, _ := cmd["voltage"].(float64) + if v <= 0 { + return false + } + mode, _ := cmd["phase_mode"].(string) + split, _ := cmd["phase_split_w"].(float64) + if split <= 0 { + split = v * a + } + // A three-phase switch cannot work below 6 A on every phase. + if split < 18*v { + split = 18 * v + } + phases := PhaseFor(mode, w, split) + ceiling := v * float64(phases) * math.Floor(a) + if w > ceiling { + cmd["power_w"] = ceiling + return true + } + return false +} diff --git a/go/internal/loadpoint/manual_limits_test.go b/go/internal/loadpoint/manual_limits_test.go new file mode 100644 index 00000000..e232b904 --- /dev/null +++ b/go/internal/loadpoint/manual_limits_test.go @@ -0,0 +1,65 @@ +package loadpoint + +import ( + "context" + "math" + "testing" + "time" +) + +func TestManualSelectionIsAPowerCeiling(t *testing.T) { + cfg := holdLoadpoint() + for _, tc := range []struct{ request, max, want float64 }{ + {12000, 11000, 11000}, {5500, 11000, 4830}, {11000, 7000, 6900}, + {1000, 11000, 0}, {0, 11000, 0}, {math.NaN(), 11000, 0}, + } { + cfg.MaxChargeW = tc.max + got := clampManualPower(cfg, ManualHold{PowerW: tc.request, PhaseMode: "3p"}, SiteFuse{MaxAmps: 16, Voltage: 230, PhaseCnt: 3}) + if got != tc.want { + t.Errorf("request %v max %v: got %v want %v", tc.request, tc.max, got, tc.want) + } + } +} + +func TestPauseHoldsThroughCarRefusalAndResumesOnlyOnRequest(t *testing.T) { + now := time.Now() + cfg := overrideLoadpoint() + sender := &fakeSender{} + samples := map[string]EVSample{cfg.DriverName: {Connected: true, RequestActive: false}} + c := newTestController(t, []Config{cfg}, nil, samples, sender) + c.SetSiteFuse(SiteFuse{MaxAmps: 16, Voltage: 230, PhaseCnt: 3}) + c.SetManualHold(cfg.ID, ManualHold{PowerW: 0, Persistent: true}) + for _, dt := range []time.Duration{0, time.Minute, 3 * time.Minute, time.Hour} { + c.Tick(context.Background(), now.Add(dt)) + if h, ok := c.GetManualHold(cfg.ID, now.Add(dt)); !ok || h.PowerW != 0 { + t.Fatalf("pause was released at %v: %+v %v", dt, h, ok) + } + if len(sender.calls) == 0 || sender.calls[len(sender.calls)-1].power != 0 { + t.Fatal("pause ordered nonzero power") + } + } + c.SetManualHold(cfg.ID, ManualHold{PowerW: 4140, PhaseMode: "3p", Persistent: true}) + c.Tick(context.Background(), now.Add(time.Hour+time.Second)) + if got := sender.calls[len(sender.calls)-1].power; got != 4140 { + t.Fatalf("explicit start failed without planner: %v", got) + } +} + +func TestSnapStepsCannotExceedChargerRating(t *testing.T) { + if got := SnapChargeW(5000, 1380, 5000, []float64{0, 4140, 5520}); got != 4140 { + t.Fatalf("unsafe step above maximum: %v", got) + } + if got := SnapChargeW(2000, 1380, 2000, []float64{0, 4140}); got != 0 { + t.Fatalf("no valid step must stop: %v", got) + } +} + +func TestCurrentCeilingCannotTurnZeroFuseBudgetIntoDefaultAmps(t *testing.T) { + for _, tc := range []struct{ amps, want float64 }{{0, 0}, {5.5, 0}, {8.9, 5520}, {16, 11000}} { + cmd := map[string]any{"power_w": float64(11000), "voltage": float64(230), "phase_mode": "3p", "max_amps_per_phase": tc.amps} + applyCurrentCeiling(cmd) + if got := cmd["power_w"].(float64); got != tc.want { + t.Fatalf("ceiling %vA sent %vW, want %vW", tc.amps, got, tc.want) + } + } +} diff --git a/go/internal/loadpoint/manual_pause_status_test.go b/go/internal/loadpoint/manual_pause_status_test.go new file mode 100644 index 00000000..0c7bc172 --- /dev/null +++ b/go/internal/loadpoint/manual_pause_status_test.go @@ -0,0 +1,56 @@ +package loadpoint + +import ( + "encoding/json" + "strings" + "testing" + "time" +) + +func TestPauseNeedsFreshStoppedCharger(t *testing.T) { + now := time.Now() + h := ManualHold{PowerW: 0, Persistent: true, StartedAt: now} + st := State{Phases: 3, VoltageV: 230, CommandedKnown: true, CommandedW: 0, CommandedReason: "manual_hold", CommandedSinceMs: now.UnixMilli()} + for _, tc := range []struct { + name string + reading ChargerReading + power float64 + want string + }{ + {"still drawing", ChargerReading{Known: true, UpdatedAt: now, Charging: true, LimitKnown: true, LimitA: 16}, 11000, ManualPausing}, + {"old zero", ChargerReading{Known: true, UpdatedAt: now.Add(-time.Minute), LimitKnown: true, LimitA: 0}, 0, ManualPausing}, + {"car stopped but charger still offers", ChargerReading{Known: true, UpdatedAt: now, LimitKnown: true, LimitA: 16}, 0, ManualPausing}, + {"fresh pause", ChargerReading{Known: true, UpdatedAt: now, LimitKnown: true, LimitA: 0}, 0, ManualPaused}, + {"cloud lost", ChargerReading{Known: true, Unavailable: true, UpdatedAt: now, LimitKnown: true, LimitA: 0}, 0, ManualUnavailable}, + } { + t.Run(tc.name, func(t *testing.T) { + st.CurrentPowerW = tc.power + got := ManualStatusFrom(h, true, st, tc.reading, now.Add(time.Second)) + if got.State != tc.want { + t.Fatalf("got %+v want %s", got, tc.want) + } + b, _ := json.Marshal(got) + if !strings.Contains(string(b), `"requested_w":0`) { + t.Fatalf("pause omitted zero: %s", b) + } + }) + } +} + +func TestLowerCurrentWaitsForChargerEvenWhilePowerFlows(t *testing.T) { + now := time.Now() + h := ManualHold{PowerW: 4140, Persistent: true, StartedAt: now.Add(-time.Hour), UpdatedAt: now} + st := State{Phases: 3, VoltageV: 230, CurrentPowerW: 11000, CommandedKnown: true, CommandedW: 4140, CommandedReason: "manual_hold", CommandedSinceMs: now.UnixMilli()} + ch := ChargerReading{Known: true, Charging: true, LimitKnown: true, LimitA: 16, UpdatedAt: now} + if got := ManualStatusFrom(h, true, st, ch, now.Add(5*time.Second)); got.State != ManualSent { + t.Fatalf("old11kW falsely confirmed6A: %+v", got) + } + if got := ManualStatusFrom(h, true, st, ch, now.Add(4*time.Minute)); got.State != ManualStalled { + t.Fatalf("unconfirmed reduction did not time out: %+v", got) + } + ch.LimitA = 6 + ch.UpdatedAt = now.Add(10 * time.Second) + if got := ManualStatusFrom(h, true, st, ch, now.Add(12*time.Second)); got.State != ManualCharging { + t.Fatalf("confirmed reduction not shown: %+v", got) + } +} diff --git a/go/internal/loadpoint/manual_status.go b/go/internal/loadpoint/manual_status.go index a66cfe51..11d3f3ab 100644 --- a/go/internal/loadpoint/manual_status.go +++ b/go/internal/loadpoint/manual_status.go @@ -27,9 +27,9 @@ type ManualStatus struct { SinceMs int64 `json:"since_ms,omitempty"` // RequestedW is what the operator asked for; CommandedW is what the box // ordered after every clamp. They differ while the main fuse limits. - RequestedW float64 `json:"requested_w,omitempty"` + RequestedW float64 `json:"requested_w"` CommandedW float64 `json:"commanded_w,omitempty"` - RequestedA float64 `json:"requested_a,omitempty"` + RequestedA float64 `json:"requested_a"` CommandedA float64 `json:"commanded_a,omitempty"` // ChargerLimitA is the current limit the charger itself reports, when // its driver exposes one (Easee: max_a). ChargerLimitKnown separates a @@ -63,6 +63,8 @@ const ( // meter) holds the order below what was asked. ManualLimited = "limited" ManualUnavailable = "unavailable" + ManualPausing = "pausing" + ManualPaused = "paused" ) // ChargerStatus separates a current report from a cached reading. @@ -106,7 +108,7 @@ const ( // before the hold was installed and says nothing about it. func holdClampReason(reason string) bool { switch reason { - case "fuse_limit", "fuse_cooldown", "site_meter_stale": + case "fuse_limit", "fuse_cooldown", "site_meter_stale", "charger_limit": return true } return false @@ -150,6 +152,9 @@ func ManualStatusFrom(h ManualHold, held bool, st State, ch ChargerReading, now m.CommandedA = toA(ordered) since := h.StartedAt + if h.UpdatedAt.After(since) { + since = h.UpdatedAt + } if !h.StartedAt.IsZero() { m.StartedAtMs = h.StartedAt.UnixMilli() } @@ -164,17 +169,37 @@ func ManualStatusFrom(h ManualHold, held bool, st State, ch ChargerReading, now elapsed = now.Sub(since) } - limitMatches := m.ChargerLimitKnown && m.CommandedA > 0 && math.Abs(ch.LimitA-m.CommandedA) < 1 + if h.PowerW == 0 { + m.State = ManualPausing + switch { + case ch.Unavailable: + m.State = ManualUnavailable + case st.CommandedKnown && st.CommandedReason == "manual_hold" && st.CommandedW == 0 && + !ch.UpdatedAt.IsZero() && !ch.UpdatedAt.Before(since) && + ch.Known && !ch.Charging && st.CurrentPowerW < manualChargingFloorW && + (!ch.LimitKnown || ch.LimitA < 0.1): + m.State = ManualPaused + case elapsed >= manualConfirmTimeout: + m.State = ManualStalled + } + return m + } + + limitMatches := m.ChargerLimitKnown && m.CommandedA >= 0 && math.Abs(ch.LimitA-m.CommandedA) < 1 switch { case ch.Unavailable: m.State = ManualUnavailable + case ch.Known && ch.Stalled: + m.State = ManualStalled + case m.ChargerLimitKnown && !limitMatches && elapsed >= manualConfirmTimeout: + m.State = ManualStalled + case (m.ChargerLimitKnown && !limitMatches) || (!ch.UpdatedAt.IsZero() && ch.UpdatedAt.Before(since)): + m.State = ManualSent case st.CurrentPowerW >= manualChargingFloorW || (ch.Known && ch.Charging): m.State = ManualCharging if clamp { m.LimitReason = st.CommandedReason } - case ch.Known && ch.Stalled: - m.State = ManualStalled case clamp: m.State = ManualLimited m.LimitReason = st.CommandedReason diff --git a/go/internal/loadpoint/snap.go b/go/internal/loadpoint/snap.go index 0c27546b..5e4c0406 100644 --- a/go/internal/loadpoint/snap.go +++ b/go/internal/loadpoint/snap.go @@ -21,7 +21,7 @@ import "math" // on a {0, 4.1, 7.4, 11} step set should hit 4.1 exactly even when // floating-point math puts it at 4099 W. func SnapChargeW(want, min, max float64, steps []float64) float64 { - if want <= 0 { + if math.IsNaN(want) || math.IsInf(want, 0) || want <= 0 { return 0 } if want < min { @@ -33,11 +33,14 @@ func SnapChargeW(want, min, max float64, steps []float64) float64 { if len(steps) == 0 { return want } - best := steps[0] - bestDiff := math.Abs(want - best) - for _, s := range steps[1:] { - if d := math.Abs(want - s); d < bestDiff { - best = s + best := 0.0 + bestDiff := math.Inf(1) + for _, step := range steps { + if math.IsNaN(step) || math.IsInf(step, 0) || step < min || (max > 0 && step > max) { + continue + } + if d := math.Abs(want - step); d < bestDiff { + best = step bestDiff = d } } From 27bf915e18fb9ced224c834effc3b253f64be5b5 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Sun, 6 Sep 2026 13:06:40 +0200 Subject: [PATCH 25/57] fix(ev): bind restored manual charging to hardware and session Signed-off-by: Fredrik Ahlgren --- .changeset/ev-manual-hold-session-restore.md | 5 + go/internal/loadpoint/controller.go | 6 +- .../loadpoint/controller_hold_restore.go | 53 +++++++ go/internal/loadpoint/loadpoint.go | 61 ++++---- go/internal/loadpoint/manual_hold_state.go | 147 ++++++++++++++++++ .../loadpoint/manual_hold_state_test.go | 95 +++++++++++ go/internal/loadpoint/session_state.go | 1 + 7 files changed, 338 insertions(+), 30 deletions(-) create mode 100644 .changeset/ev-manual-hold-session-restore.md create mode 100644 go/internal/loadpoint/controller_hold_restore.go create mode 100644 go/internal/loadpoint/manual_hold_state.go create mode 100644 go/internal/loadpoint/manual_hold_state_test.go diff --git a/.changeset/ev-manual-hold-session-restore.md b/.changeset/ev-manual-hold-session-restore.md new file mode 100644 index 00000000..8fbebab2 --- /dev/null +++ b/.changeset/ev-manual-hold-session-restore.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +Bind a saved manual charging request to its charger hardware and verified charging session. Keep a saved pause on the same charger. When a prior positive request cannot be verified, pause and ask the owner to confirm instead of resuming automatic charging. Preserve explicit Start or Clear actions that arrive before the first charger reading. diff --git a/go/internal/loadpoint/controller.go b/go/internal/loadpoint/controller.go index 998df2f2..fe018d4a 100644 --- a/go/internal/loadpoint/controller.go +++ b/go/internal/loadpoint/controller.go @@ -102,8 +102,10 @@ type Controller struct { // 5-second control loop. Missing entries (or expired holds, which // `GetManualHold` lazily evicts) fall through to the normal // compute-from-plan path. - holdMu sync.Mutex - holds map[string]ManualHold + holdMu sync.Mutex + holds map[string]ManualHold + manualRestored map[string]bool + manualPersistMu sync.Mutex // manualIdleSince[id] is when a loadpoint with an active manual hold // first observed the vehicle "not requesting current" this idle spell. // Once it has stayed not-requesting for SessionCompletionTimeout the diff --git a/go/internal/loadpoint/controller_hold_restore.go b/go/internal/loadpoint/controller_hold_restore.go new file mode 100644 index 00000000..9a4603ea --- /dev/null +++ b/go/internal/loadpoint/controller_hold_restore.go @@ -0,0 +1,53 @@ +package loadpoint + +// markManualExplicit prevents a delayed first telemetry sample from replacing +// an action the user already took. Call outside holdMu in Set/ClearManualHold. +func (c *Controller) markManualExplicit(id string) { + c.holdMu.Lock() + defer c.holdMu.Unlock() + if c.manualRestored == nil { + c.manualRestored = map[string]bool{} + } + c.manualRestored[id] = true + if c.manager != nil { + c.manager.SetManualRestoreUnconfirmed(id, false) + } +} + +// restoreManualHoldForSession runs after ObserveSession, before dispatch. The +// disk read stays outside holdMu; recheck explicit actions before installing. +func (c *Controller) restoreManualHoldForSession(id string) { + if c.manager == nil { + return + } + c.holdMu.Lock() + done := c.manualRestored[id] + c.holdMu.Unlock() + if done { + return + } + h, status := c.manager.RestoreManualHold(id) + if status == "pending" && !h.Persistent { + return + } + c.holdMu.Lock() + defer c.holdMu.Unlock() + if c.manualRestored[id] { + return + } + if c.manualRestored == nil { + c.manualRestored = map[string]bool{} + } + _, hadPending := c.manualRestored[id] + c.manualRestored[id] = status != "pending" + if status == "pending" || status == "restored" || status == "unconfirmed" { + if c.holds == nil { + c.holds = map[string]ManualHold{} + } + c.holds[id] = h + c.manager.SetManualRestoreUnconfirmed(id, status == "unconfirmed" || status == "pending") + } else if hadPending { + delete(c.holds, id) + c.manager.SetManualRestoreUnconfirmed(id, false) + } +} diff --git a/go/internal/loadpoint/loadpoint.go b/go/internal/loadpoint/loadpoint.go index 8249329f..eed804ef 100644 --- a/go/internal/loadpoint/loadpoint.go +++ b/go/internal/loadpoint/loadpoint.go @@ -121,8 +121,9 @@ func (f SiteFuse) Phases() int { // Read-only for consumers — only the Manager or dispatch paths mutate // it under lock. type State struct { - VehicleCapacityWh float64 `json:"vehicle_capacity_wh"` - CapacitySource string `json:"capacity_source"` + ManualRestoreUnconfirmed bool `json:"manual_restore_unconfirmed"` + VehicleCapacityWh float64 `json:"vehicle_capacity_wh"` + CapacitySource string `json:"capacity_source"` // 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. @@ -253,6 +254,7 @@ type PlanWindow struct { type Manager struct { sessionMu sync.Mutex sessionStore SessionStore + pendingManual map[string]pendingManualHold connectionHealth map[string]bool connectionEdges map[string]connectionEdge mu sync.RWMutex @@ -320,10 +322,11 @@ const ( // union of configured parameters and observed state. Lives behind // Manager so consumers access it via the public State snapshot. type loadpointRuntime struct { - sessionDeviceID string - sessionID string - socRetention string - completionNotified bool + manualRestoreUnconfirmed bool + sessionDeviceID string + sessionID string + socRetention string + completionNotified bool Config pluggedIn bool @@ -523,6 +526,7 @@ func (m *Manager) Load(cfgs []Config) { lp.sessionID = existing.sessionID lp.socRetention = existing.socRetention lp.completionNotified = existing.completionNotified + lp.manualRestoreUnconfirmed = existing.manualRestoreUnconfirmed } else { lp.pluggedIn = false lp.currentSoC = 0 @@ -1060,28 +1064,29 @@ func (lp *loadpointRuntime) snapshot() State { copy(steps, lp.AllowedStepsW) sort.Float64s(steps) st := State{ - VehicleCapacityWh: lp.VehicleCapacityWh, - CapacitySource: "configured", - ID: lp.ID, - DriverName: lp.DriverName, - PluggedIn: lp.pluggedIn, - CurrentSoC: lp.currentSoC, - CurrentPowerW: lp.currentPowerW, - DeliveredWhSession: lp.deliveredWhSession, - TargetSoC: lp.targetSoC, - TargetTime: lp.targetTime, - UpdatedAtMs: lp.updatedAtMs, - MinChargeW: lp.MinChargeW, - MaxChargeW: lp.MaxChargeW, - AllowedStepsW: steps, - SurplusOnly: lp.Config.SurplusOnly, - Schedule: lp.schedule, - ChargingDeclined: lp.chargingDeclined, - SoCRetention: lp.socRetention, - VehicleName: lp.vehicleName, - CommandedW: lp.commandedW, - CommandedReason: lp.commandedReason, - CommandedKnown: lp.commandedKnown, + ManualRestoreUnconfirmed: lp.manualRestoreUnconfirmed, + VehicleCapacityWh: lp.VehicleCapacityWh, + CapacitySource: "configured", + ID: lp.ID, + DriverName: lp.DriverName, + PluggedIn: lp.pluggedIn, + CurrentSoC: lp.currentSoC, + CurrentPowerW: lp.currentPowerW, + DeliveredWhSession: lp.deliveredWhSession, + TargetSoC: lp.targetSoC, + TargetTime: lp.targetTime, + UpdatedAtMs: lp.updatedAtMs, + MinChargeW: lp.MinChargeW, + MaxChargeW: lp.MaxChargeW, + AllowedStepsW: steps, + SurplusOnly: lp.Config.SurplusOnly, + Schedule: lp.schedule, + ChargingDeclined: lp.chargingDeclined, + SoCRetention: lp.socRetention, + VehicleName: lp.vehicleName, + CommandedW: lp.commandedW, + CommandedReason: lp.commandedReason, + CommandedKnown: lp.commandedKnown, } if lp.VehicleCapacityWh <= 0 { st.VehicleCapacityWh = 60000 diff --git a/go/internal/loadpoint/manual_hold_state.go b/go/internal/loadpoint/manual_hold_state.go new file mode 100644 index 00000000..cad90db1 --- /dev/null +++ b/go/internal/loadpoint/manual_hold_state.go @@ -0,0 +1,147 @@ +package loadpoint + +import ( + "encoding/json" + "fmt" + "strings" +) + +type storedManualHold struct { + Version int `json:"version"` + DeviceID string `json:"device_id"` + SessionID string `json:"session_id,omitempty"` + Hold ManualHold `json:"hold"` +} + +type pendingManualHold struct { + hold ManualHold + cleared bool +} + +func manualHoldKey(deviceID string) string { + return "ev_manual_hold:" + strings.TrimPrefix(sessionKey(deviceID), "ev_session:") +} + +// PersistManualHold records an explicit operator action. Before first hardware +// telemetry, retain the action in memory and bind it to the first fresh device +// reading. Such a Start still works immediately; it does not silently gain +// permission to charge another car after a later process restart. +func (m *Manager) PersistManualHold(id string, h ManualHold, cleared bool) error { + m.sessionMu.Lock() + defer m.sessionMu.Unlock() + if m.pendingManual == nil { + m.pendingManual = map[string]pendingManualHold{} + } + m.pendingManual[id] = pendingManualHold{h, cleared} + return m.flushManualHold(id) +} + +func (m *Manager) flushManualHold(id string) error { + pending, ok := m.pendingManual[id] + if !ok || m.sessionStore == nil { + return nil + } + m.mu.RLock() + lp := m.byID[id] + var deviceID, sessionID string + if lp != nil { + deviceID, sessionID = lp.sessionDeviceID, lp.sessionID + } + m.mu.RUnlock() + if deviceID == "" { + return nil + } + body := "{}" + if !pending.cleared && pending.hold.Persistent { + h := pending.hold + if !finite(h.PowerW) || h.PowerW < 0 { + return fmt.Errorf("invalid stored manual power") + } + b, err := json.Marshal(storedManualHold{Version: 1, DeviceID: deviceID, SessionID: sessionID, Hold: h}) + if err != nil { + return err + } + body = string(b) + } + if err := m.sessionStore.SaveConfig(manualHoldKey(deviceID), body); err != nil { + return err + } + // This name-keyed index is only a hint to pause while identity is unknown; + // it never grants positive power or bypasses the hardware/session match. + if err := m.sessionStore.SaveConfig("ev_manual_binding:"+id, deviceID); err != nil { + return err + } + // Retire the legacy name key too: a cleared new record must not fall back + // to an earlier unbound hold on the next boot. + if err := m.sessionStore.SaveConfig("loadpoint_manual_hold:"+id, "{}"); err != nil { + return err + } + delete(m.pendingManual, id) + return nil +} + +// RestoreManualHold returns pending until hardware identity is known. A +// verified same-session positive hold or a hardware-bound pause is restored. +// Unknown/changed sessions and legacy records return a persistent zero-W hold +// plus unconfirmed: failed restoration must not start automatic charging. +func (m *Manager) RestoreManualHold(id string) (ManualHold, string) { + m.sessionMu.Lock() + defer m.sessionMu.Unlock() + m.mu.RLock() + lp := m.byID[id] + var deviceID, sessionID string + if lp != nil { + deviceID, sessionID = lp.sessionDeviceID, lp.sessionID + } + m.mu.RUnlock() + if m.sessionStore == nil { + return ManualHold{}, "none" + } + if deviceID == "" { + binding, bound := m.sessionStore.LoadConfig("ev_manual_binding:" + id) + if bound && binding != "" { + if raw, found := m.sessionStore.LoadConfig(manualHoldKey(binding)); !found || (raw != "" && raw != "{}") { + return ManualHold{Persistent: true}, "pending" + } + } + legacy, found := m.sessionStore.LoadConfig("loadpoint_manual_hold:" + id) + if found && legacy != "" && legacy != "{}" { + return ManualHold{Persistent: true}, "pending" + } + return ManualHold{}, "pending" + } + raw, found := m.sessionStore.LoadConfig(manualHoldKey(deviceID)) + if !found { + if binding, ok := m.sessionStore.LoadConfig("ev_manual_binding:" + id); ok && binding != "" { + if old, present := m.sessionStore.LoadConfig(manualHoldKey(binding)); !present || (old != "" && old != "{}") { + return ManualHold{Persistent: true}, "unconfirmed" + } + } + legacy, ok := m.sessionStore.LoadConfig("loadpoint_manual_hold:" + id) + if ok && legacy != "" && legacy != "{}" { + return ManualHold{Persistent: true}, "unconfirmed" + } + return ManualHold{}, "none" + } + if raw == "" || raw == "{}" { + return ManualHold{}, "none" + } + var record storedManualHold + if json.Unmarshal([]byte(raw), &record) != nil || record.Version != 1 || + record.DeviceID != deviceID || !record.Hold.Persistent || + !record.Hold.ExpiresAt.IsZero() || !finite(record.Hold.PowerW) || record.Hold.PowerW < 0 { + return ManualHold{Persistent: true}, "unconfirmed" + } + if record.Hold.PowerW == 0 || (sessionID != "" && sessionID == record.SessionID) { + return record.Hold, "restored" + } + return ManualHold{Persistent: true}, "unconfirmed" +} + +func (m *Manager) SetManualRestoreUnconfirmed(id string, value bool) { + m.mu.Lock() + defer m.mu.Unlock() + if lp := m.byID[id]; lp != nil { + lp.manualRestoreUnconfirmed = value + } +} diff --git a/go/internal/loadpoint/manual_hold_state_test.go b/go/internal/loadpoint/manual_hold_state_test.go new file mode 100644 index 00000000..650d9d5c --- /dev/null +++ b/go/internal/loadpoint/manual_hold_state_test.go @@ -0,0 +1,95 @@ +package loadpoint + +import ( + "testing" + "time" +) + +func TestManualHoldRestoreRequiresHardwareAndActiveSession(t *testing.T) { + store := &sessionMemory{data: map[string]string{}} + m := sessionManager(store, "garage", "charger") + m.ObserveSession("garage", true, 4300, 1000, true, "easee:ABC", "session-1") + if err := m.PersistManualHold("garage", ManualHold{PowerW: 4140, Persistent: true}, false); err != nil { + t.Fatal(err) + } + for _, tc := range []struct { + device, session, status string + power float64 + }{ + {"easee:ABC", "session-1", "restored", 4140}, + {"easee:ABC", "session-2", "unconfirmed", 0}, + {"easee:ABC", "", "unconfirmed", 0}, + {"easee:OTHER", "session-1", "unconfirmed", 0}, + } { + m := sessionManager(store, "garage", "charger") + m.ObserveSession("garage", true, 0, 1000, true, tc.device, tc.session) + h, status := m.RestoreManualHold("garage") + if status != tc.status || h.PowerW != tc.power || !h.Persistent { + t.Fatalf("%+v: got %+v %s", tc, h, status) + } + } + if err := m.PersistManualHold("garage", ManualHold{PowerW: 0, Persistent: true}, false); err != nil { + t.Fatal(err) + } + m = sessionManager(store, "garage", "charger") + m.ObserveSession("garage", true, 0, 1000, true, "easee:ABC", "") + if h, status := m.RestoreManualHold("garage"); status != "restored" || h.PowerW != 0 { + t.Fatalf("pause failed: %+v %s", h, status) + } +} + +func TestManualHoldBeforeIdentityBindsOnlyAfterFreshReading(t *testing.T) { + store := &sessionMemory{data: map[string]string{}} + m := sessionManager(store, "garage", "charger") + m.PersistManualHold("garage", ManualHold{PowerW: 4140, Persistent: true}, false) + if len(store.data) != 0 { + t.Fatal("unidentified device persisted") + } + m.ObserveSession("garage", true, 4300, 1000, true, "easee:ABC", "session-1") + m = sessionManager(store, "garage", "charger") + m.ObserveSession("garage", true, 4300, 1100, true, "easee:ABC", "session-1") + if h, status := m.RestoreManualHold("garage"); status != "restored" || h.PowerW != 4140 { + t.Fatalf("explicit start was lost: %+v %s", h, status) + } +} + +func TestControllerPausesPendingRestoreAndHonoursExplicitOverride(t *testing.T) { + store := &sessionMemory{data: map[string]string{}} + m := sessionManager(store, "garage", "charger") + m.ObserveSession("garage", true, 4300, 1000, true, "easee:ABC", "session-1") + m.PersistManualHold("garage", ManualHold{PowerW: 4140, Persistent: true}, false) + m = sessionManager(store, "garage", "charger") + c := NewController(m, nil, nil, nil) + c.restoreManualHoldForSession("garage") + if h, ok := c.GetManualHold("garage", time.Now()); !ok || h.PowerW != 0 { + t.Fatalf("unknown session could use automatic charging: %+v %v", h, ok) + } + if s, _ := m.State("garage"); !s.ManualRestoreUnconfirmed { + t.Fatal("pending restore not visible") + } + c.markManualExplicit("garage") + c.SetManualHold("garage", ManualHold{PowerW: 5520, Persistent: true}) + m.ObserveSession("garage", true, 4300, 1100, true, "easee:ABC", "session-1") + c.restoreManualHoldForSession("garage") + if h, ok := c.GetManualHold("garage", time.Now()); !ok || h.PowerW != 5520 { + t.Fatalf("recovery overwrote explicit choice: %+v %v", h, ok) + } + if s, _ := m.State("garage"); s.ManualRestoreUnconfirmed { + t.Fatal("explicit action did not confirm restore") + } +} + +func TestLegacyManualHoldNeedsConfirmationAndClearCannotResurrectIt(t *testing.T) { + store := &sessionMemory{data: map[string]string{"loadpoint_manual_hold:garage": `{"PowerW":11000,"Persistent":true}`}} + m := sessionManager(store, "garage", "charger") + m.ObserveSession("garage", true, 4300, 1000, true, "easee:ABC", "session-1") + if h, status := m.RestoreManualHold("garage"); status != "unconfirmed" || h.PowerW != 0 { + t.Fatalf("legacy hold resumed: %+v %s", h, status) + } + m.PersistManualHold("garage", ManualHold{}, true) + m = sessionManager(store, "garage", "charger") + m.ObserveSession("garage", true, 4300, 1100, true, "easee:ABC", "session-1") + if _, status := m.RestoreManualHold("garage"); status != "none" { + t.Fatalf("cleared hold resurrected: %s", status) + } +} diff --git a/go/internal/loadpoint/session_state.go b/go/internal/loadpoint/session_state.go index f61ec223..c90990e6 100644 --- a/go/internal/loadpoint/session_state.go +++ b/go/internal/loadpoint/session_state.go @@ -134,6 +134,7 @@ func (m *Manager) ObserveSession(id string, pluggedIn bool, powerW, deliveredWh lp.socRetention = "unavailable" } m.mu.Unlock() + _ = m.flushManualHold(id) } // persistSession runs outside Manager.mu, but sessionMu serializes it with From 87a7e80b0097f774f87baac9a28a4610547db048 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Sun, 6 Sep 2026 13:03:55 +0200 Subject: [PATCH 26/57] fix(ev): distinguish stored battery size from the active car Signed-off-by: Fredrik Ahlgren --- .changeset/ev-effective-capacity.md | 5 +++++ web/app.js | 15 +++++++++++++-- 2 files changed, 18 insertions(+), 2 deletions(-) create mode 100644 .changeset/ev-effective-capacity.md diff --git a/.changeset/ev-effective-capacity.md b/.changeset/ev-effective-capacity.md new file mode 100644 index 00000000..4a397e6c --- /dev/null +++ b/.changeset/ev-effective-capacity.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +Show when the current car uses a different battery size from the usual size just saved. diff --git a/web/app.js b/web/app.js index 0825dbbd..0767e90d 100644 --- a/web/app.js +++ b/web/app.js @@ -3912,7 +3912,7 @@ var retry = document.createElement("button"); retry.type = "button"; retry.textContent = "Try battery size again"; retry.hidden = true; wrap.appendChild(retry); - var busy = false, dirty = false; + var busy = false, dirty = false, currentCapacity = null, available = true; input.addEventListener("input", function () { dirty = true; }); function save() { if (busy) return; @@ -3923,13 +3923,24 @@ evWrite("/api/loadpoints/" + encodeURIComponent(lp.id) + "/vehicle", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ capacity_wh: Math.round(value * 1000) }), }).then(function (r) { return r.json().then(function (j) { if (!r.ok || !j.ok) throw new Error(j.error || "FTW refused the change."); return j; }); }) - .then(function () { dirty = false; note.textContent = "Battery size saved. The plan uses this size for its estimates."; return refreshEvModalAfterWrite(); }) + .then(function () { + dirty = false; + note.textContent = "Battery size saved. Reading charging status…"; + return refreshEvModalAfterWrite().then(function () { + note.textContent = !available ? "Battery size saved. Current charging status is unavailable." + : currentCapacity !== null && currentCapacity !== Math.round(value * 1000) + ? "Saved as the usual battery size. This session uses " + (currentCapacity / 1000) + " kWh." + : "Battery size saved. The plan uses this size for its estimates."; + }); + }) .catch(function (err) { note.textContent = "Battery size not confirmed: " + err.message; note.setAttribute("role", "alert"); retry.hidden = false; }) .finally(function () { busy = false; input.disabled = false; }); } input.addEventListener("change", save); retry.addEventListener("click", save); function update(next) { var capacity = Number(next.vehicle_capacity_wh); + currentCapacity = isFinite(capacity) && capacity > 0 ? capacity : null; + available = !next.charger || next.charger.available !== false; wrap.hidden = !next.plugged_in || !(capacity > 0); summary.textContent = "Car battery · " + (capacity / 1000) + " kWh"; hint.textContent = next.capacity_source === "default" ? "FTW is using a default size. Check it against your car." : "Used for estimates. Check this size if you use another car."; From 21943ceb45c701c2a77c9d87925ade47be460c9f Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Sun, 6 Sep 2026 13:09:46 +0200 Subject: [PATCH 27/57] fix(ev): restore manual choices only after session identity is known Signed-off-by: Fredrik Ahlgren --- go/cmd/ftw/main.go | 46 +++-------------------------- go/internal/loadpoint/controller.go | 7 +++++ 2 files changed, 11 insertions(+), 42 deletions(-) diff --git a/go/cmd/ftw/main.go b/go/cmd/ftw/main.go index 00acf082..003c2ceb 100644 --- a/go/cmd/ftw/main.go +++ b/go/cmd/ftw/main.go @@ -1972,49 +1972,11 @@ func main() { } return "" }) - // Persist operator manual holds (the amp-slider "Start") so they - // survive reboot / firmware update and the EV keeps charging across - // the restart — the in-memory hold would otherwise be lost (Stefan - // 2026-06-11: a binary deploy dropped the live manual charge). Mirrors - // the loadpoint_schedule k/v pattern: one row per LP keyed - // `loadpoint_manual_hold:`, "{}" = cleared. - const lpManualHoldKeyPrefix = "loadpoint_manual_hold:" - // Restore FIRST (before wiring the saver) so re-applying a persisted - // hold doesn't immediately re-write what we just read. A stale hold - // for a car unplugged during downtime self-clears on the first tick - // (tickOne unplug → ClearManualHold). - for _, lpState := range lpMgr.States() { - v, ok := st.LoadConfig(lpManualHoldKeyPrefix + lpState.ID) - if !ok || v == "" || v == "{}" { - continue - } - var h loadpoint.ManualHold - if err := json.Unmarshal([]byte(v), &h); err != nil { - slog.Warn("failed to parse persisted manual hold", "lp", lpState.ID, "err", err) - continue - } - if !h.Persistent { - continue // only operator (never-expiring) holds persist - } - lpController.SetManualHold(lpState.ID, h) - slog.Info("restored persistent manual hold across restart", - "lp", lpState.ID, "power_w", h.PowerW, "phase_mode", h.PhaseMode) - } + // The manager binds saved holds to charger hardware and session. The + // controller restores only after fresh telemetry supplies those IDs. lpController.SetManualHoldSaver(func(id string, h loadpoint.ManualHold, cleared bool) { - key := lpManualHoldKeyPrefix + id - if cleared { - if err := st.SaveConfig(key, "{}"); err != nil { - slog.Warn("failed to clear persisted manual hold", "lp", id, "err", err) - } - return - } - b, err := json.Marshal(h) - if err != nil { - slog.Warn("failed to marshal manual hold", "lp", id, "err", err) - return - } - if err := st.SaveConfig(key, string(b)); err != nil { - slog.Warn("failed to persist manual hold", "lp", id, "err", err) + if err := lpMgr.PersistManualHold(id, h, cleared); err != nil { + slog.Warn("failed to persist manual charging choice", "lp", id, "err", err) } }) const lpBatteryBoostKeyPrefix = "loadpoint_battery_boost:" diff --git a/go/internal/loadpoint/controller.go b/go/internal/loadpoint/controller.go index fe018d4a..b945f3ff 100644 --- a/go/internal/loadpoint/controller.go +++ b/go/internal/loadpoint/controller.go @@ -1312,6 +1312,9 @@ func (c *Controller) SetManualHold(id string, h ManualHold) { if c == nil { return } + c.manualPersistMu.Lock() + defer c.manualPersistMu.Unlock() + c.markManualExplicit(id) if h.PowerW > 0 && c.manager != nil { c.manager.RetryCharging(id) } @@ -1350,6 +1353,9 @@ func (c *Controller) ClearManualHold(id string) { if c == nil { return } + c.manualPersistMu.Lock() + defer c.manualPersistMu.Unlock() + c.markManualExplicit(id) c.holdMu.Lock() _, existed := c.holds[id] delete(c.holds, id) @@ -1516,6 +1522,7 @@ func (c *Controller) tickOne(ctx context.Context, now time.Time, lpCfg Config, d selfWithheld := surplusOn && enteringSurplusPaused c.manager.SetSurplusWithheld(lpCfg.ID, selfWithheld) c.manager.ObserveSession(lpCfg.ID, sample.Connected, sample.PowerW, sample.SessionWh, sample.RequestActive, sample.DeviceID, sample.SessionID) + c.restoreManualHoldForSession(lpCfg.ID) c.evaluateBatteryBoost(lpCfg.ID, now, sample.Connected, dispatchAllowed) if !sample.Connected { c.resetSurplusSession(lpCfg.ID) From a8eb0672385c96e187ff35a01116a3bba2b5559a Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Sun, 6 Sep 2026 13:11:12 +0200 Subject: [PATCH 28/57] fix(ev): explain unmatched restart and pending current changes Signed-off-by: Fredrik Ahlgren --- .changeset/ev-restart-choice.md | 5 ++++ web/app.js | 41 ++++++++++++++++++--------------- web/ev-manual-feedback.test.mjs | 14 ++++++++++- 3 files changed, 41 insertions(+), 19 deletions(-) create mode 100644 .changeset/ev-restart-choice.md diff --git a/.changeset/ev-restart-choice.md b/.changeset/ev-restart-choice.md new file mode 100644 index 00000000..ca819546 --- /dev/null +++ b/.changeset/ev-restart-choice.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +Ask how to continue when an earlier charge request cannot be matched after restart. Offer Charge now, Resume plan and Pause charging without calling it a user pause. Keep actual power visible until a changed current limit reaches the charger. diff --git a/web/app.js b/web/app.js index 0767e90d..68117e22 100644 --- a/web/app.js +++ b/web/app.js @@ -2742,6 +2742,7 @@ // state (loadpoint.manual); this only puts words on it. Returns null // when no hold is active. function manualStatusText(lp, d) { + if (lp && lp.manual_restore_unconfirmed) return "Confirm how to continue after restart. FTW could not match the earlier charge request to this connection."; var m = lp && lp.manual; if (!lp || !lp.manual_active || !m || !m.active) return null; var reqA = m.requested_a > 0 ? Math.round(m.requested_a) + " A" : formatW(m.requested_w || lp.manual_charge_w || 0); @@ -2764,7 +2765,7 @@ ? "Charging at " + formatW(lp.current_power_w) + " — " + reqA + " requested." : "The charger reports charging. Waiting for a power reading.") + end; case "sent": - return "FTW received " + reqA + ". Waiting for the charger…" + sinceP + end; + return "FTW received " + reqA + ". Waiting for the charger to confirm the new limit." + sinceP + ((lp.current_power_w || 0) >= 100 ? " Still charging at " + formatW(lp.current_power_w) + "." : "") + end; case "accepted": return "Charger reports a " + cmdA + " limit. Waiting for the car to start drawing…" + sinceP + reason + end; case "not_drawing": @@ -2772,7 +2773,8 @@ (reason || " It may be full, or held by its own charge limit or schedule."); case "stalled": if (evIsPaused(lp)) return "The charger has not stopped after your pause request. Check the charger’s app."; - return "Nothing is charging" + (since ? " after " + since : "") + ": the charger has not acted on " + reqA + "." + + return "The charger has not acted on " + reqA + (since ? " after " + since : "") + "." + + ((lp.current_power_w || 0) >= 100 ? " Still charging at " + formatW(lp.current_power_w) + "." : "") + (reason || " Check the car's own charge limit or schedule, then the charger's app."); case "limited": if (m.limit_reason === "charger_limit") return "The charger limits this request to " + cmdA + " (" + reqA + " requested)."; @@ -2796,14 +2798,16 @@ // there is nothing worth saying (no loadpoint, or unplugged — the // schedule note covers that case). function renderEvPlanStatus(lp, d) { - if (!lp || !lp.plugged_in) return null; + if (!lp || (!lp.plugged_in && !lp.manual_restore_unconfirmed)) return null; var text = null; var tone = "var(--text-dim)"; var kwPlanned = lp.plan_total_wh > 0 ? " ~" + (lp.plan_total_wh / 1000).toFixed(1) + " kWh planned." : ""; var winActive = lp.plan_next_start_ms > 0 && lp.plan_next_start_ms <= Date.now() && Date.now() < lp.plan_next_end_ms; var charging = (lp.current_power_w || 0) >= 100; var hasSchedule = lp.schedule && lp.schedule.soc > 0; - if (lp.charger && !lp.charger.available) { + if (lp.manual_restore_unconfirmed) { + text = manualStatusText(lp, d); + } else if (lp.charger && !lp.charger.available) { text = lp.charger.known ? "Charger status is out of date. FTW cannot confirm whether the car is charging." : "Waiting for the charger's first status report."; @@ -3371,7 +3375,7 @@ // is sent as watts (power_w = A × phases × voltage); the driver // converts back to amps given the wallbox it's talking to. function evIsPaused(lp) { - return !!(lp && lp.manual_active && (lp.manual_charge_w === 0 || + return !!(lp && !lp.manual_restore_unconfirmed && lp.manual_active && (lp.manual_charge_w === 0 || lp.manual && (lp.manual.requested_w === 0 || lp.manual.state === "paused" || lp.manual.state === "pausing"))); } @@ -3516,23 +3520,24 @@ function renderStatus() { var on = !!(lastLp && lastLp.manual_active); var paused = evIsPaused(lastLp); + var restore = !!lastLp.manual_restore_unconfirmed; if (!busy && paused && lastLp.manual && lastLp.manual.state === "paused") holdLineUntil = 0; if (!busy) { - stopBtn.hidden = !on; - stopBtn.disabled = !on; - eyebrow.hidden = !on || paused; - row.hidden = !on || paused; - row.style.display = on && !paused ? "flex" : "none"; + stopBtn.hidden = !on && !restore; + stopBtn.disabled = !on && !restore; + eyebrow.hidden = !on || paused || restore; + row.hidden = !on || paused || restore; + row.style.display = on && !paused && !restore ? "flex" : "none"; pauseBtn.hidden = paused; pauseBtn.disabled = false; - stopBtn.textContent = paused ? "Resume plan" : "Return to plan"; - stopBtn.style.opacity = on ? "1" : "0.5"; - startBtn.hidden = on && !paused; - if (!on || paused) { slider.value = String(maxA); renderReadout(); } + stopBtn.textContent = paused || restore ? "Resume plan" : "Return to plan"; + stopBtn.style.opacity = on || restore ? "1" : "0.5"; + startBtn.hidden = on && !paused && !restore; + if (!on || paused || restore) { slider.value = String(maxA); renderReadout(); } startBtn.disabled = false; } if (busy || Date.now() < holdLineUntil) return; - status.textContent = paused ? "The goal and solar rule wait until you resume the plan. Charge now starts immediately." : on ? "Changes apply when you release the slider." : idleText; + status.textContent = restore ? "Choose Charge now to start immediately, Resume plan to use your goal, or Pause charging to keep charging off." : paused ? "The goal and solar rule wait until you resume the plan. Charge now starts immediately." : on ? "Changes apply when you release the slider." : idleText; } function update(nextLp, d) { if (nextLp) lastLp = nextLp; @@ -3972,8 +3977,8 @@ latest = next; if (busy) return; soCb.checked = !!latest.surplus_only; - soCb.disabled = !!latest.manual_active; - soStatus.textContent = failure || (evIsPaused(latest) + soCb.disabled = !!latest.manual_active || !!latest.manual_restore_unconfirmed; + soStatus.textContent = failure || (evIsPaused(latest) || latest.manual_restore_unconfirmed ? "This rule resumes with the plan." : latest.manual_active ? "Charge now overrides this rule. It resumes when you return to the plan." @@ -4460,7 +4465,7 @@ (s.recurring ? " · repeats" : " · once") : "No ready time set."; editLabel.textContent = hasGoal ? "Change goal" : "Set a ready time"; - suspended.textContent = evIsPaused(nextLp) + suspended.textContent = evIsPaused(nextLp) || nextLp.manual_restore_unconfirmed ? "Resume the plan to use this goal. Edits apply then." : nextLp.manual_active ? "Charge now overrides this goal. Edits apply when you return to the plan." diff --git a/web/ev-manual-feedback.test.mjs b/web/ev-manual-feedback.test.mjs index 0d43dd5e..1e42e896 100644 --- a/web/ev-manual-feedback.test.mjs +++ b/web/ev-manual-feedback.test.mjs @@ -24,7 +24,7 @@ test('the status line follows the charger through every state', () => { assert.match(statusText, /Waiting for the charger/); assert.match(statusText, /Waiting for the car to start drawing/); assert.match(statusText, /but the car is not drawing/); - assert.match(statusText, /the charger has not acted on/); + assert.match(statusText, /The charger has not acted on/); assert.match(statusText, /Main fuse limits this charge/); // The charger's own words are part of the sentence. assert.match(statusText, /Charger reports: " \+ m\.charger_reason/); @@ -95,3 +95,15 @@ test('a charger limit does not blame the main fuse', () => { assert.match(words, /The charger limits this request to 10 A/); assert.doesNotMatch(words, /Main fuse/); }); + + +test('an unmatched restart asks for a choice without claiming an operator pause', () => { + const words = describeManual({ manual_restore_unconfirmed: true, manual_active: false }); + assert.match(words, /Confirm how to continue after restart/); + assert.doesNotMatch(words, /Paused by you/); +}); +test('a lower requested current keeps actual old power visible until it arrives', () => { + const words = describeManual({ ...lp, current_power_w: 11000, manual: { ...lp.manual, state: 'sent', requested_a: 6 } }); + assert.match(words, /confirm the new limit/); + assert.match(words, /Still charging at 11000 W/); +}); From 488c91dee3c5967780efaa0794500043d4f8c9aa Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Sun, 6 Sep 2026 13:15:38 +0200 Subject: [PATCH 29/57] fix(ev): stop old manual power when a session binding changes Signed-off-by: Fredrik Ahlgren --- .changeset/ev-manual-hold-session-restore.md | 2 + go/internal/loadpoint/controller.go | 5 +- .../loadpoint/controller_hold_restore.go | 56 ++++++++- go/internal/loadpoint/loadpoint.go | 27 ++-- go/internal/loadpoint/manual_hold_state.go | 21 ++++ .../loadpoint/manual_hold_state_test.go | 115 ++++++++++++++++++ go/internal/loadpoint/session_state.go | 2 + 7 files changed, 217 insertions(+), 11 deletions(-) diff --git a/.changeset/ev-manual-hold-session-restore.md b/.changeset/ev-manual-hold-session-restore.md index 8fbebab2..dc78adbc 100644 --- a/.changeset/ev-manual-hold-session-restore.md +++ b/.changeset/ev-manual-hold-session-restore.md @@ -3,3 +3,5 @@ --- Bind a saved manual charging request to its charger hardware and verified charging session. Keep a saved pause on the same charger. When a prior positive request cannot be verified, pause and ask the owner to confirm instead of resuming automatic charging. Preserve explicit Start or Clear actions that arrive before the first charger reading. + +A running request also stops if the charger, session or loadpoint binding changes. A clear issued before telemetry survives another immediate restart, and concurrent Set/Clear writes preserve the order shown by the controller. diff --git a/go/internal/loadpoint/controller.go b/go/internal/loadpoint/controller.go index b945f3ff..dfed0e38 100644 --- a/go/internal/loadpoint/controller.go +++ b/go/internal/loadpoint/controller.go @@ -105,6 +105,7 @@ type Controller struct { holdMu sync.Mutex holds map[string]ManualHold manualRestored map[string]bool + manualBindings map[string]manualSessionBinding manualPersistMu sync.Mutex // manualIdleSince[id] is when a loadpoint with an active manual hold // first observed the vehicle "not requesting current" this idle spell. @@ -1355,7 +1356,7 @@ func (c *Controller) ClearManualHold(id string) { } c.manualPersistMu.Lock() defer c.manualPersistMu.Unlock() - c.markManualExplicit(id) + first := c.markManualExplicit(id) c.holdMu.Lock() _, existed := c.holds[id] delete(c.holds, id) @@ -1363,7 +1364,7 @@ func (c *Controller) ClearManualHold(id string) { c.holdMu.Unlock() // Only persist the clear if a hold actually existed — ClearManualHold is // called on every unplugged tick, and we must not hammer the store. - if saver != nil && existed { + if saver != nil && (existed || first) { saver(id, ManualHold{}, true) } // The auto-release idle timer is meaningless without a hold. diff --git a/go/internal/loadpoint/controller_hold_restore.go b/go/internal/loadpoint/controller_hold_restore.go index 9a4603ea..700b3217 100644 --- a/go/internal/loadpoint/controller_hold_restore.go +++ b/go/internal/loadpoint/controller_hold_restore.go @@ -1,17 +1,38 @@ package loadpoint +type manualSessionBinding struct { + deviceID, sessionID string + generation uint64 + configuration uint64 +} + +func (m *Manager) manualSessionBinding(id string) manualSessionBinding { + m.mu.RLock() + defer m.mu.RUnlock() + if lp := m.byID[id]; lp != nil { + return manualSessionBinding{lp.sessionDeviceID, lp.sessionID, lp.sessionGeneration, lp.configGeneration} + } + return manualSessionBinding{} +} + // markManualExplicit prevents a delayed first telemetry sample from replacing // an action the user already took. Call outside holdMu in Set/ClearManualHold. -func (c *Controller) markManualExplicit(id string) { +func (c *Controller) markManualExplicit(id string) bool { c.holdMu.Lock() defer c.holdMu.Unlock() if c.manualRestored == nil { c.manualRestored = map[string]bool{} } + first := !c.manualRestored[id] c.manualRestored[id] = true if c.manager != nil { + if c.manualBindings == nil { + c.manualBindings = map[string]manualSessionBinding{} + } + c.manualBindings[id] = c.manager.manualSessionBinding(id) c.manager.SetManualRestoreUnconfirmed(id, false) } + return first } // restoreManualHoldForSession runs after ObserveSession, before dispatch. The @@ -20,7 +41,40 @@ func (c *Controller) restoreManualHoldForSession(id string) { if c.manager == nil { return } + c.manualPersistMu.Lock() + defer c.manualPersistMu.Unlock() + current := c.manager.manualSessionBinding(id) c.holdMu.Lock() + if c.manualBindings == nil { + c.manualBindings = map[string]manualSessionBinding{} + } + previous, bound := c.manualBindings[id] + changed := bound && previous.configuration != current.configuration + if bound && previous.deviceID != "" && current.deviceID != "" { + changed = changed || previous.deviceID != current.deviceID + // An explicit Start from a paused/unconfirmed session may acquire its + // first session ID as charging starts. It already names this hardware. + firstSessionProof := previous.sessionID == "" && current.sessionID != "" + changed = changed || (!firstSessionProof && previous.generation != current.generation) + } + // A Start before the first hardware reading binds here. Once bound, a + // different hardware/session/config generation must not inherit its power. + if current.deviceID != "" { + c.manualBindings[id] = current + } + if changed { + if _, held := c.holds[id]; held { + c.holds[id] = ManualHold{Persistent: true} + c.manualRestored[id] = true + c.manager.SetManualRestoreUnconfirmed(id, true) + c.holdMu.Unlock() + // Keep the safe pause across another restart. The next explicit + // Start or Return to plan decides how this new session proceeds. + _ = c.manager.PersistManualHold(id, ManualHold{Persistent: true}, false) + c.resetManualIdle(id) + return + } + } done := c.manualRestored[id] c.holdMu.Unlock() if done { diff --git a/go/internal/loadpoint/loadpoint.go b/go/internal/loadpoint/loadpoint.go index eed804ef..a2b2b8ad 100644 --- a/go/internal/loadpoint/loadpoint.go +++ b/go/internal/loadpoint/loadpoint.go @@ -252,14 +252,15 @@ type PlanWindow struct { // Manager holds the running set of loadpoints. Thread-safe. type Manager struct { - sessionMu sync.Mutex - sessionStore SessionStore - pendingManual map[string]pendingManualHold - connectionHealth map[string]bool - connectionEdges map[string]connectionEdge - mu sync.RWMutex - byID map[string]*loadpointRuntime - order []string // insertion-preserving id list for deterministic listing + nextSessionGeneration uint64 + sessionMu sync.Mutex + sessionStore SessionStore + pendingManual map[string]pendingManualHold + connectionHealth map[string]bool + connectionEdges map[string]connectionEdge + mu sync.RWMutex + byID map[string]*loadpointRuntime + order []string // insertion-preserving id list for deterministic listing // scheduleSaver, if non-nil, is invoked synchronously whenever a // schedule is set or cleared. Wired by main.go to persist via @@ -322,6 +323,8 @@ const ( // union of configured parameters and observed state. Lives behind // Manager so consumers access it via the public State snapshot. type loadpointRuntime struct { + configGeneration uint64 + sessionGeneration uint64 manualRestoreUnconfirmed bool sessionDeviceID string sessionID string @@ -502,6 +505,14 @@ func (m *Manager) Load(cfgs []Config) { continue } lp := &loadpointRuntime{Config: c} + if existing := m.byID[c.ID]; existing != nil && existing.DriverName == c.DriverName { + lp.sessionGeneration = existing.sessionGeneration + lp.configGeneration = existing.configGeneration + } else { + m.nextSessionGeneration++ + lp.sessionGeneration = m.nextSessionGeneration + lp.configGeneration = m.nextSessionGeneration + } if existing, ok := m.byID[c.ID]; ok { // Preserve observed state across reload. The session // plug-in anchor is carried too — otherwise a config diff --git a/go/internal/loadpoint/manual_hold_state.go b/go/internal/loadpoint/manual_hold_state.go index cad90db1..45fc6c12 100644 --- a/go/internal/loadpoint/manual_hold_state.go +++ b/go/internal/loadpoint/manual_hold_state.go @@ -33,6 +33,16 @@ func (m *Manager) PersistManualHold(id string, h ManualHold, cleared bool) error m.pendingManual = map[string]pendingManualHold{} } m.pendingManual[id] = pendingManualHold{h, cleared} + if cleared && m.sessionStore != nil { + // A clear before telemetry must survive another immediate reboot. + // This barrier can only remove an old request; it grants no power. + if err := m.sessionStore.SaveConfig("ev_manual_clear:"+id, "pending"); err != nil { + return err + } + if err := m.sessionStore.SaveConfig("loadpoint_manual_hold:"+id, "{}"); err != nil { + return err + } + } return m.flushManualHold(id) } @@ -76,6 +86,9 @@ func (m *Manager) flushManualHold(id string) error { if err := m.sessionStore.SaveConfig("loadpoint_manual_hold:"+id, "{}"); err != nil { return err } + if err := m.sessionStore.SaveConfig("ev_manual_clear:"+id, ""); err != nil { + return err + } delete(m.pendingManual, id) return nil } @@ -97,6 +110,14 @@ func (m *Manager) RestoreManualHold(id string) (ManualHold, string) { if m.sessionStore == nil { return ManualHold{}, "none" } + if clear, _ := m.sessionStore.LoadConfig("ev_manual_clear:" + id); clear == "pending" { + if m.pendingManual == nil { + m.pendingManual = map[string]pendingManualHold{} + } + m.pendingManual[id] = pendingManualHold{cleared: true} + _ = m.flushManualHold(id) + return ManualHold{}, "none" + } if deviceID == "" { binding, bound := m.sessionStore.LoadConfig("ev_manual_binding:" + id) if bound && binding != "" { diff --git a/go/internal/loadpoint/manual_hold_state_test.go b/go/internal/loadpoint/manual_hold_state_test.go index 650d9d5c..a6ad1934 100644 --- a/go/internal/loadpoint/manual_hold_state_test.go +++ b/go/internal/loadpoint/manual_hold_state_test.go @@ -93,3 +93,118 @@ func TestLegacyManualHoldNeedsConfirmationAndClearCannotResurrectIt(t *testing.T t.Fatalf("cleared hold resurrected: %s", status) } } + +func TestRuntimeManualHoldCannotFollowChangedDeviceOrSession(t *testing.T) { + for _, change := range []string{"hardware", "session", "remove_readd"} { + t.Run(change, func(t *testing.T) { + store := &sessionMemory{data: map[string]string{}} + m := sessionManager(store, "garage", "charger") + m.ObserveSession("garage", true, 4300, 1000, true, "easee:A", "session-1") + c := NewController(m, nil, nil, nil) + c.SetManualHold("garage", ManualHold{PowerW: 4140, Persistent: true}) + device, session := "easee:A", "session-1" + switch change { + case "hardware": + device = "easee:B" + case "session": + session = "session-2" + case "remove_readd": + m.Load(nil) + m.Load([]Config{{ID: "garage", DriverName: "charger", VehicleCapacityWh: 60000}}) + } + m.ObserveSession("garage", true, 4300, 1100, true, device, session) + c.restoreManualHoldForSession("garage") + if h, ok := c.GetManualHold("garage", time.Now()); !ok || h.PowerW != 0 { + t.Fatalf("old power followed %s: %+v %v", change, h, ok) + } + if s, _ := m.State("garage"); !s.ManualRestoreUnconfirmed { + t.Fatal("changed session was not shown") + } + // The safe pause also survives another restart on that hardware. + m = sessionManager(store, "garage", "charger") + m.ObserveSession("garage", true, 0, 1100, true, device, session) + if h, status := m.RestoreManualHold("garage"); status != "restored" || h.PowerW != 0 { + t.Fatalf("pause was not durable: %+v %s", h, status) + } + }) + } +} + +func TestExplicitStartCanAcquireFirstSessionProof(t *testing.T) { + for _, initialDevice := range []string{"", "easee:A"} { + m := sessionManager(nil, "garage", "charger") + if initialDevice != "" { + m.ObserveSession("garage", true, 0, 0, true, initialDevice, "") + } + c := NewController(m, nil, nil, nil) + c.SetManualHold("garage", ManualHold{PowerW: 4140, Persistent: true}) + m.ObserveSession("garage", true, 4300, 100, true, "easee:A", "session-1") + c.restoreManualHoldForSession("garage") + if h, ok := c.GetManualHold("garage", time.Now()); !ok || h.PowerW != 4140 { + t.Fatalf("explicit start before proof was lost: %+v %v", h, ok) + } + } +} + +func TestClearBeforeTelemetrySurvivesAnotherImmediateRestart(t *testing.T) { + store := &sessionMemory{data: map[string]string{}} + m := sessionManager(store, "garage", "charger") + m.ObserveSession("garage", true, 4300, 1000, true, "easee:A", "session-1") + m.PersistManualHold("garage", ManualHold{PowerW: 4140, Persistent: true}, false) + store.data["loadpoint_manual_hold:garage"] = `{"PowerW":11000,"Persistent":true}` + m = sessionManager(store, "garage", "charger") + c := NewController(m, nil, nil, nil) + c.SetManualHoldSaver(func(id string, h ManualHold, cleared bool) { + if err := m.PersistManualHold(id, h, cleared); err != nil { + t.Error(err) + } + }) + c.ClearManualHold("garage") + m = sessionManager(store, "garage", "charger") + c = NewController(m, nil, nil, nil) + c.restoreManualHoldForSession("garage") + m.ObserveSession("garage", true, 4300, 1000, true, "easee:A", "session-1") + c.restoreManualHoldForSession("garage") + if _, ok := c.GetManualHold("garage", time.Now()); ok { + t.Fatal("cleared hold returned after reboot before telemetry") + } + if _, status := m.RestoreManualHold("garage"); status != "none" { + t.Fatalf("record survived explicit clear: %s", status) + } +} + +func TestConcurrentSetAndClearPersistInControllerOrder(t *testing.T) { + m := sessionManager(nil, "garage", "charger") + c := NewController(m, nil, nil, nil) + entered, release, setDone, clearDone := make(chan struct{}), make(chan struct{}), make(chan struct{}), make(chan struct{}) + var order []float64 + c.SetManualHoldSaver(func(_ string, h ManualHold, cleared bool) { + if !cleared { + close(entered) + <-release + order = append(order, h.PowerW) + } else { + order = append(order, 0) + } + }) + go func() { c.SetManualHold("garage", ManualHold{PowerW: 4140, Persistent: true}); close(setDone) }() + <-entered + go func() { c.ClearManualHold("garage"); close(clearDone) }() + select { + case <-clearDone: + t.Fatal("Clear overtook an earlier pending disk write") + case <-time.After(20 * time.Millisecond): + } + if h, ok := c.GetManualHold("garage", time.Now()); !ok || h.PowerW != 4140 { + t.Fatalf("read was blocked or changed before ordered clear: %+v %v", h, ok) + } + close(release) + <-setDone + <-clearDone + if len(order) != 2 || order[0] != 4140 || order[1] != 0 { + t.Fatalf("persist order: %v", order) + } + if _, ok := c.GetManualHold("garage", time.Now()); ok { + t.Fatal("clear lost to earlier save") + } +} diff --git a/go/internal/loadpoint/session_state.go b/go/internal/loadpoint/session_state.go index c90990e6..0c2ef9f0 100644 --- a/go/internal/loadpoint/session_state.go +++ b/go/internal/loadpoint/session_state.go @@ -80,6 +80,8 @@ func (m *Manager) ObserveSession(id string, pluggedIn bool, powerW, deliveredWh // 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 { + m.nextSessionGeneration++ + lp.sessionGeneration = m.nextSessionGeneration lp.pluggedIn = false lp.chargingSteadySince = time.Time{} lp.stoppedSince = time.Time{} From 8241ca1f9e9fa673ddcb6640d6d7f79cfe0a1e21 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Sun, 6 Sep 2026 13:17:47 +0200 Subject: [PATCH 30/57] fix(ev): pin the verified Easee session driver Signed-off-by: Fredrik Ahlgren --- .changeset/easee-session-driver.md | 5 +++++ drivers/BUNDLED_SOURCE.json | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) create mode 100644 .changeset/easee-session-driver.md diff --git a/.changeset/easee-session-driver.md b/.changeset/easee-session-driver.md new file mode 100644 index 00000000..3c557a9b --- /dev/null +++ b/.changeset/easee-session-driver.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +Use Easee session evidence to retain a confirmed battery level across a restart only when the same active session is verified. diff --git a/drivers/BUNDLED_SOURCE.json b/drivers/BUNDLED_SOURCE.json index 57ab29e7..bf3109c1 100644 --- a/drivers/BUNDLED_SOURCE.json +++ b/drivers/BUNDLED_SOURCE.json @@ -17,7 +17,7 @@ "for coverage. Run scripts/sync-bundled-drivers.sh to update." ], "repository": "srcfl/device-drivers", - "commit": "f98d5c5f3519fa90f010a69573aa8fcf8595c8b0", + "commit": "f92e70501608fd3a6567b507a00493dbf6e85b84", "source_dir": "drivers/lua", "drivers": [ "ambibox_v2x", "ctek", "ctek_hybrid", "ctek_v2", "deye", "easee_cloud", From 3b66fafc8a9a562573b4d90ec3b9ff7690cdc8e5 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Sun, 6 Sep 2026 13:21:48 +0200 Subject: [PATCH 31/57] fix(updater): persist optimizer update and rollback pins Signed-off-by: Fredrik Ahlgren --- .changeset/quiet-optimizer-pins.md | 5 + docs/self-update.md | 8 +- go/cmd/ftw-updater/env_pin.go | 2 +- go/cmd/ftw-updater/main.go | 39 +++- go/cmd/ftw-updater/main_test.go | 16 +- go/cmd/ftw-updater/optimizer_pin.go | 87 +++++++++ go/cmd/ftw-updater/optimizer_pin_test.go | 228 +++++++++++++++++++++++ 7 files changed, 373 insertions(+), 12 deletions(-) create mode 100644 .changeset/quiet-optimizer-pins.md create mode 100644 go/cmd/ftw-updater/optimizer_pin.go create mode 100644 go/cmd/ftw-updater/optimizer_pin_test.go diff --git a/.changeset/quiet-optimizer-pins.md b/.changeset/quiet-optimizer-pins.md new file mode 100644 index 00000000..6a6ad4a6 --- /dev/null +++ b/.changeset/quiet-optimizer-pins.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +Keep the selected optimizer image after updates and rollbacks by saving and checking its Compose pin. Report a failed pin write instead of a successful update, preserve other host settings and file permissions, and keep shell payloads containing credentials out of updater logs. diff --git a/docs/self-update.md b/docs/self-update.md index fece6d00..21458e59 100644 --- a/docs/self-update.md +++ b/docs/self-update.md @@ -90,7 +90,13 @@ restored Core fails health. See [backup-and-restore.md](backup-and-restore.md). Optimizer-only updates use `optimizer-vX.Y.Z[-beta.N]`, recreate and health-check only `ftw-optimizer`, and never replace Core. Failure restores the -previous Optimizer image while Core continues on its Go fallback. +previous Optimizer image while Core continues on its Go fallback. After health +succeeds, both update and rollback save `FTW_OPTIMIZER_IMAGE_TAG` in the host +project's `.env` and check it before reporting success. Other settings, file +owner and mode stay intact. A pin write failure is reported as a failed +operation even if the optimizer is healthy; repair the host project and retry. +The host Compose image must use `${FTW_OPTIMIZER_IMAGE_TAG}` (an optional default +is allowed), or the operation stops before replacing the optimizer. A Driver update downloads one signed artifact, verifies hash, metadata and host API compatibility, then atomically activates exactly that version. Core puts diff --git a/go/cmd/ftw-updater/env_pin.go b/go/cmd/ftw-updater/env_pin.go index 455b898c..46ce1f87 100644 --- a/go/cmd/ftw-updater/env_pin.go +++ b/go/cmd/ftw-updater/env_pin.go @@ -88,7 +88,7 @@ func mergeEnvFile(existing string, tags map[string]string) string { // Appending in a fixed order keeps the file stable across runs; map order // would otherwise reshuffle it and make every update look like a change. - for _, key := range []string{mainTagEnv, updaterTagEnv} { + for _, key := range []string{mainTagEnv, updaterTagEnv, optimizerTagEnv} { if value, ok := remaining[key]; ok { out = append(out, key+"="+value) } diff --git a/go/cmd/ftw-updater/main.go b/go/cmd/ftw-updater/main.go index 94989482..f988f984 100644 --- a/go/cmd/ftw-updater/main.go +++ b/go/cmd/ftw-updater/main.go @@ -126,6 +126,7 @@ type server struct { // Injectable so the ordering — only after a verified Core update, never able // to fail one — is testable without Docker. See self_replace.go. selfReplace func(target string) error + optimizerPin func(target string) error chownFile func(string, int, int) error checkSnapshotFile func(context.Context, string, string, string) error stageSnapshotFile func(context.Context, string, string, string, string) error @@ -286,6 +287,7 @@ func main() { defer cancel() return srv.replaceUpdater(ctx, target) } + srv.optimizerPin = srv.persistOptimizerPin srv.chownFile = os.Chown srv.checkSnapshotFile = func(ctx context.Context, containerID, snapshotID, file string) error { return srv.runner(ctx, nil, "exec", containerID, "test", "-f", "/app/data/snapshots/"+snapshotID+"/"+file) @@ -508,6 +510,12 @@ func (s *server) runComponentJob(action, target, component string, startedAt tim s.restartExisting(spec, now) return } + if action == "update" && spec.name == "optimizer" { + if err := s.validateOptimizerPinLayout(); err != nil { + s.writeState(State{State: "failed", Action: action, Component: component, Target: target, StartedAt: now, UpdatedAt: time.Now(), Message: "optimizer update blocked: " + err.Error()}) + return + } + } if action == "update" && spec.name == "core" { if err := s.requireHealthyOptimizer(); err != nil { msg := "core update blocked: " + err.Error() @@ -654,6 +662,13 @@ func (s *server) runComponentJob(action, target, component string, startedAt tim } } + if spec.name == "optimizer" { + if err := s.saveOptimizerPin(target); err != nil { + s.writeState(State{State: "failed", Action: action, Component: component, Target: target, StartedAt: now, UpdatedAt: time.Now(), Message: "optimizer is ready, but its image pin was not saved: " + err.Error(), PreviousImageID: previousImageID}) + return + } + } + // The main container is now being recreated. The brand-new replica // will read this "done" state on startup and serve it to the UI that's // still polling in the browser. @@ -762,6 +777,11 @@ func (s *server) restorePreviousComponentImage(imageID string, spec componentSpe } func (s *server) restorePreviousComponentImageWithTag(imageID, previousTag string, spec componentSpec) error { + if spec.name == "optimizer" { + if err := s.validateOptimizerPinLayout(); err != nil { + return err + } + } image, ok, err := serviceImageFromComposeFiles(s.composeFiles(), spec.service) if err != nil { return err @@ -796,6 +816,11 @@ func (s *server) restorePreviousComponentImageWithTag(imageID, previousTag strin return fmt.Errorf("previous image health check: %w", err) } } + if spec.name == "optimizer" { + if err := s.saveOptimizerPin(rollbackTag); err != nil { + return fmt.Errorf("previous optimizer is ready, but its image pin was not saved: %w", err) + } + } return nil } @@ -1611,10 +1636,22 @@ func dockerCompose(ctx context.Context, extraEnv []string, args ...string) error if err != nil { return fmt.Errorf("%w: %s", err, truncate(string(out), 400)) } - slog.Info("docker compose ok", "args", args, "env", extraEnv, "out", truncate(string(out), 200)) + slog.Info("docker compose ok", "args", loggedDockerArgs(args), "env", extraEnv, "out", truncate(string(out), 200)) return nil } +// Shell payloads can contain the base64-encoded .env. Keep command shape in +// logs without publishing credentials in support bundles. +func loggedDockerArgs(args []string) []string { + redacted := append([]string(nil), args...) + for i, arg := range redacted { + if arg == "-c" && i+1 < len(redacted) { + redacted[i+1] = "[shell payload hidden]" + } + } + return redacted +} + func dockerOutput(ctx context.Context, args ...string) (string, error) { cmd := exec.CommandContext(ctx, "docker", args...) var stdout, stderr bytes.Buffer diff --git a/go/cmd/ftw-updater/main_test.go b/go/cmd/ftw-updater/main_test.go index a57f33de..5e68bb7b 100644 --- a/go/cmd/ftw-updater/main_test.go +++ b/go/cmd/ftw-updater/main_test.go @@ -78,6 +78,7 @@ func newTestServer(t *testing.T) (*server, *fakeRunner) { imageID: func(context.Context, string) (string, error) { return "sha256:current", nil }, containerID: func(context.Context, string) (string, error) { return "ftw-container", nil }, chownFile: func(string, int, int) error { return nil }, + optimizerPin: func(string) error { return nil }, } s.checkSnapshotFile = func(_ context.Context, _ string, snapshotID, file string) error { _, err := os.Stat(filepath.Join(dir, "data", "snapshots", snapshotID, file)) @@ -696,7 +697,7 @@ func TestValidateComponentImagePinRequiresExactVariable(t *testing.T) { } } -func TestComponentRollbackPinsUnsupportedOptimizerImages(t *testing.T) { +func TestComponentRollbackRejectsNonPersistentOptimizerImages(t *testing.T) { for _, image := range []string{ "ghcr.io/srcfl/ftw-optimizer:latest", "ghcr.io/srcfl/ftw-optimizer:${MY_TAG:-latest}", @@ -708,16 +709,13 @@ func TestComponentRollbackPinsUnsupportedOptimizerImages(t *testing.T) { s.runComponentRollback("optimizer", time.Now()) state := s.readState() - if state.State != "done" || state.Action != "component_rollback" { - t.Fatalf("rollback state = %+v", state) + if state.State != "failed" || !strings.Contains(state.Message, "must use ${FTW_OPTIMIZER_IMAGE_TAG}") { + t.Fatalf("rollback must reject a pin Compose cannot use: %+v", state) } - calls, envs := runner.snapshot(), runner.envSnapshot() - if len(calls) != 2 || !strings.Contains(strings.Join(calls[0], " "), "image tag sha256:optimizer-old "+canonicalOptimizerImage+":ftw-rollback-") { - t.Fatalf("rollback calls = %v", calls) - } - if len(envs) != 2 || len(envs[1]) != 1 || !strings.HasPrefix(envs[1][0], "FTW_OPTIMIZER_IMAGE_TAG=ftw-rollback-") { - t.Fatalf("rollback env = %v", envs) + if len(runner.snapshot()) != 0 { + t.Fatalf("unsupported rollback changed an image: %v", runner.snapshot()) } + }) } } diff --git a/go/cmd/ftw-updater/optimizer_pin.go b/go/cmd/ftw-updater/optimizer_pin.go new file mode 100644 index 00000000..ebeec456 --- /dev/null +++ b/go/cmd/ftw-updater/optimizer_pin.go @@ -0,0 +1,87 @@ +package main + +import ( + "context" + "crypto/sha256" + "fmt" + "os" + "path/filepath" + "strings" + "time" +) + +const optimizerTagEnv = "FTW_OPTIMIZER_IMAGE_TAG" + +func (s *server) validateOptimizerPinLayout() error { + image, ok, err := serviceImageFromComposeFiles(s.hostComposeFiles(), optimizerServiceName) + if err != nil { + return err + } + if !ok { + return fmt.Errorf("service %s has no host Compose image", optimizerServiceName) + } + if _, ok := composeImageRepositoryForTag(image, optimizerTagEnv); !ok { + return fmt.Errorf("service %s must use ${%s} in its image to preserve an update; change the host Compose image before updating", optimizerServiceName, optimizerTagEnv) + } + _, err = readEnvFile(filepath.Dir(s.composeFile)) + return err +} + +// persistOptimizerPin runs only after optimizer health succeeds. The updater's +// project mount is read-only, so a short helper uses the current updater image +// to write the pin, preserving the operator's other keys, owner and mode. +// Unlike best-effort updater replacement, this runs synchronously and verifies +// readback before the component operation may report done. +func (s *server) persistOptimizerPin(target string) error { + if err := s.validateOptimizerPinLayout(); err != nil { + return err + } + projectDir := filepath.Dir(s.composeFile) + existing, err := readEnvFile(projectDir) + if err != nil { + return err + } + content := mergeEnvFile(existing, map[string]string{optimizerTagEnv: target}) + service, err := s.updaterServiceName() + if err != nil { + return err + } + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + if s.imageID == nil { + return fmt.Errorf("cannot identify updater image for persisting optimizer pin") + } + helperImage, err := s.imageID(ctx, service) + if err != nil { + return err + } + envPath := filepath.Join(projectDir, ".env") + tmpPath := envPath + ".ftw-optimizer-pin-tmp" + sum := fmt.Sprintf("%x", sha256.Sum256([]byte(existing))) + // Reject a file changed since read/merge; never overwrite a new operator + // setting with an old copy. Payload and paths remain shell-quoted. + check := fmt.Sprintf("if [ -e %s ]; then test \"$(sha256sum %s | cut -d ' ' -f 1)\" = %s; else test %s = %s; fi", shellQuote(envPath), shellQuote(envPath), shellQuote(sum), shellQuote(sum), shellQuote(fmt.Sprintf("%x", sha256.Sum256(nil)))) + script := "set -eu; " + check + "; test ! -L " + shellQuote(tmpPath) + "; " + envTempWriteScript(envPath, tmpPath, content) + " && { " + check + "; } && mv " + shellQuote(tmpPath) + " " + shellQuote(envPath) + args := []string{"run", "--rm", "--network", "none", "--user", "0:0", "-v", projectDir + ":" + projectDir, "--entrypoint", "sh", helperImage, "-c", script} + if err := s.runner(ctx, nil, args...); err != nil { + return fmt.Errorf("write optimizer image pin: %w", err) + } + got, err := os.ReadFile(envPath) + if err != nil { + return fmt.Errorf("read optimizer image pin: %w", err) + } + if string(got) != content { + return fmt.Errorf("optimizer image pin did not persist; retry after repairing %s", envPath) + } + return nil +} + +func (s *server) saveOptimizerPin(target string) error { + if s.optimizerPin == nil { + return fmt.Errorf("optimizer image pin persistence is unavailable") + } + if target == "" || strings.ContainsAny(target, "\r\n") { + return fmt.Errorf("invalid optimizer image pin") + } + return s.optimizerPin(target) +} diff --git a/go/cmd/ftw-updater/optimizer_pin_test.go b/go/cmd/ftw-updater/optimizer_pin_test.go new file mode 100644 index 00000000..a57000cf --- /dev/null +++ b/go/cmd/ftw-updater/optimizer_pin_test.go @@ -0,0 +1,228 @@ +package main + +import ( + "context" + "errors" + "os" + "os/exec" + "path/filepath" + "strings" + "syscall" + "testing" + "time" +) + +func TestOptimizerPinPreservesEnvMetadataAndOtherTags(t *testing.T) { + s, _ := newTestServer(t) + writeCompose(t, s.composeFile, composeWithUpdater) + file := filepath.Join(filepath.Dir(s.composeFile), ".env") + original := "# keep this\nFTW_IMAGE_TAG=v2.14.0-beta.1\nFTW_UPDATER_IMAGE_TAG=v2.14.0-beta.1\nFTW_OPTIMIZER_IMAGE_TAG=old\nSECRET=a=b=c\nexport FTW_OPTIMIZER_IMAGE_TAG=v1.4.0-beta.3\n" + if err := os.WriteFile(file, []byte(original), 0o640); err != nil { + t.Fatal(err) + } + before, _ := os.Stat(file) + calls := 0 + s.runner = func(ctx context.Context, _ []string, args ...string) error { + calls++ + joined := strings.Join(args, " ") + if !strings.Contains(joined, "run --rm --network none --user 0:0") || strings.Contains(joined, "docker.sock") { + t.Fatalf("pin helper has unnecessary privileges: %v", args) + } + if args[len(args)-2] != "-c" { + t.Fatal("missing shell script") + } + out, err := exec.CommandContext(ctx, "sh", "-c", args[len(args)-1]).CombinedOutput() + if err != nil { + t.Fatalf("pin script: %v %s", err, out) + } + return nil + } + if err := s.persistOptimizerPin("v1.4.0-beta.4"); err != nil { + t.Fatal(err) + } + got, _ := os.ReadFile(file) + want := mergeEnvFile(original, map[string]string{optimizerTagEnv: "v1.4.0-beta.4"}) + if string(got) != want || strings.Count(string(got), optimizerTagEnv+"=") != 1 { + t.Fatalf("merged pin = %q", got) + } + after, _ := os.Stat(file) + b, a := before.Sys().(*syscall.Stat_t), after.Sys().(*syscall.Stat_t) + if before.Mode() != after.Mode() || b.Uid != a.Uid || b.Gid != a.Gid { + t.Fatal("pin changed owner or mode") + } + if calls != 1 { + t.Fatalf("helper calls=%d", calls) + } +} + +func TestOptimizerPinCreatesNewPrivateEnvAndChecksReadback(t *testing.T) { + for _, write := range []bool{true, false} { + t.Run(map[bool]string{true: "new file", false: "helper did not write"}[write], func(t *testing.T) { + s, _ := newTestServer(t) + writeCompose(t, s.composeFile, composeWithUpdater) + s.runner = func(ctx context.Context, _ []string, args ...string) error { + if !write { + return nil + } + return exec.CommandContext(ctx, "sh", "-c", args[len(args)-1]).Run() + } + err := s.persistOptimizerPin("v1.4.0-beta.4") + if !write { + if err == nil { + t.Fatal("missing readback accepted") + } + return + } + if err != nil { + t.Fatal(err) + } + st, err := os.Stat(filepath.Join(filepath.Dir(s.composeFile), ".env")) + if err != nil { + t.Fatal(err) + } + if st.Mode().Perm() != 0o600 { + t.Fatalf("new env mode=%o", st.Mode().Perm()) + } + }) + } +} + +func TestOptimizerPinDoesNotOverwriteConcurrentOperatorEdit(t *testing.T) { + s, _ := newTestServer(t) + writeCompose(t, s.composeFile, composeWithUpdater) + file := filepath.Join(filepath.Dir(s.composeFile), ".env") + if err := os.WriteFile(file, []byte("SITE=before\n"), 0o600); err != nil { + t.Fatal(err) + } + s.runner = func(ctx context.Context, _ []string, args ...string) error { + if err := os.WriteFile(file, []byte("SITE=operator-edit\n"), 0o600); err != nil { + return err + } + return exec.CommandContext(ctx, "sh", "-c", args[len(args)-1]).Run() + } + if err := s.persistOptimizerPin("v1.4.0-beta.4"); err == nil { + t.Fatal("concurrent edit overwritten") + } + got, _ := os.ReadFile(file) + if string(got) != "SITE=operator-edit\n" { + t.Fatalf("operator edit lost: %q", got) + } +} + +func TestOptimizerPinDoesNotReplaceEnvWhenMetadataCopyFails(t *testing.T) { + s, _ := newTestServer(t) + writeCompose(t, s.composeFile, composeWithUpdater) + file := filepath.Join(filepath.Dir(s.composeFile), ".env") + if err := os.WriteFile(file, []byte("SITE=current\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(file+".ftw-optimizer-pin-tmp", []byte("SITE=stale\n"), 0o600); err != nil { + t.Fatal(err) + } + s.runner = func(ctx context.Context, _ []string, args ...string) error { + // A failed metadata copy must not rename a leftover temporary file. + return exec.CommandContext(ctx, "sh", "-c", "cp() { return 1; }; "+args[len(args)-1]).Run() + } + if err := s.persistOptimizerPin("v1.4.0-beta.4"); err == nil { + t.Fatal("failed copy accepted") + } + got, _ := os.ReadFile(file) + if string(got) != "SITE=current\n" { + t.Fatalf("original env replaced after failed copy: %q", got) + } +} + +func TestOptimizerUpdateAndRollbackPersistBeforeDone(t *testing.T) { + for _, action := range []string{"update", "rollback"} { + for _, fail := range []bool{false, true} { + t.Run(action+map[bool]string{false: " success", true: " pin failure"}[fail], func(t *testing.T) { + s, _ := newTestServer(t) + healthy := false + s.healthCheck = func(context.Context, string) error { healthy = true; return nil } + var saved string + s.optimizerPin = func(target string) error { + if !healthy || s.readState().State == "done" { + t.Fatal("pin must follow health and precede done") + } + saved = target + if fail { + return errors.New("read-only project") + } + return nil + } + if action == "update" { + s.runComponentJob("update", "v1.4.0-beta.4", "optimizer", time.Now()) + } else { + s.writeState(State{State: "done", Component: "optimizer", PreviousImageID: "sha256:previous"}) + s.runComponentRollback("optimizer", time.Now()) + } + st := s.readState() + if fail { + if st.State != "failed" || !strings.Contains(st.Message, "image pin was not saved") { + t.Fatalf("pin failure hidden: %+v", st) + } + } else if st.State != "done" { + t.Fatalf("state=%+v", st) + } + if action == "update" && saved != "v1.4.0-beta.4" { + t.Fatalf("saved=%q", saved) + } + if action == "rollback" && !strings.HasPrefix(saved, "ftw-rollback-") { + t.Fatalf("rollback pin=%q", saved) + } + }) + } + } +} + +func TestDockerLogHidesEnvShellPayloadWithoutChangingCommand(t *testing.T) { + args := []string{"run", "--entrypoint", "sh", "image", "-c", "echo SECRET=base64-payload"} + logged := loggedDockerArgs(args) + if strings.Contains(strings.Join(logged, " "), "SECRET") { + t.Fatal("shell secret logged") + } + if args[5] != "echo SECRET=base64-payload" { + t.Fatal("log redaction changed executed arguments") + } +} + +func TestOptimizerUpdateRejectsNonPersistentLayoutBeforeDocker(t *testing.T) { + s, runner := newTestServer(t) + writeCompose(t, s.composeFile, "services:\n ftw-optimizer:\n image: ghcr.io/srcfl/ftw-optimizer:latest\n") + s.runComponentJob("update", "v1.4.0-beta.4", "optimizer", time.Now()) + st := s.readState() + if st.State != "failed" || !strings.Contains(st.Message, "must use ${FTW_OPTIMIZER_IMAGE_TAG}") { + t.Fatalf("unusable pin accepted: %+v", st) + } + if len(runner.snapshot()) != 0 { + t.Fatal("blocked update called Docker") + } +} + +func TestOptimizerFailedHealthRestoresPreviousPersistentTag(t *testing.T) { + s, _ := newTestServer(t) + s.imageRef = func(context.Context, string) (string, error) { + return canonicalOptimizerImage + ":v1.4.0-beta.3", nil + } + checks := 0 + s.healthCheck = func(context.Context, string) error { + checks++ + if checks == 1 { + return errors.New("new optimizer unhealthy") + } + return nil + } + var saved string + s.optimizerPin = func(target string) error { + if checks != 2 { + t.Fatal("previous tag saved before recovery health") + } + saved = target + return nil + } + s.runComponentJob("update", "v1.4.0-beta.4", "optimizer", time.Now()) + st := s.readState() + if st.State != "failed" || !strings.Contains(st.Message, "previous image restored") || saved != "v1.4.0-beta.3" { + t.Fatalf("recovery did not persist previous image: %+v saved=%q", st, saved) + } +} From 7085724c36b0b839308904b61a3c544a5d1e1452 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Sun, 6 Sep 2026 13:21:33 +0200 Subject: [PATCH 32/57] fix(ev): show when charging intent could not be saved --- .changeset/ev-manual-save-feedback.md | 5 +++++ web/app.js | 1 + web/ev-manual-feedback.test.mjs | 15 +++++++++++++++ 3 files changed, 21 insertions(+) create mode 100644 .changeset/ev-manual-save-feedback.md diff --git a/.changeset/ev-manual-save-feedback.md b/.changeset/ev-manual-save-feedback.md new file mode 100644 index 00000000..368f3e5e --- /dev/null +++ b/.changeset/ev-manual-save-feedback.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +Show when a charging choice is active but could not be saved for restart. The notice clears when the box confirms its retry succeeded. diff --git a/web/app.js b/web/app.js index 68117e22..8509f47d 100644 --- a/web/app.js +++ b/web/app.js @@ -2863,6 +2863,7 @@ } else { text = "No charge window yet for this goal. Choose Charge now if you need to charge immediately."; } + if (lp.manual_save_error) text = (text ? text + " " : "") + "This choice is active now, but could not be saved for restart. FTW is retrying."; if (!text) return null; var p = document.createElement("p"); p.style.color = tone; diff --git a/web/ev-manual-feedback.test.mjs b/web/ev-manual-feedback.test.mjs index 1e42e896..6b6eb134 100644 --- a/web/ev-manual-feedback.test.mjs +++ b/web/ev-manual-feedback.test.mjs @@ -107,3 +107,18 @@ test('a lower requested current keeps actual old power visible until it arrives' assert.match(words, /confirm the new limit/); assert.match(words, /Still charging at 11000 W/); }); + + +test('failed hold persistence stays visible until the box reports recovery', () => { + const strip = source.slice(source.indexOf('function renderEvPlanStatus'), source.indexOf('// Keep controls mounted while polling')); + const render = new Function('document', 'manualStatusText', strip + '; return renderEvPlanStatus;')( + { createElement: () => ({ style: {} }) }, describeManual, + ); + const paused = { ...lp, plugged_in: true, manual_save_error: true, manual: { ...lp.manual, state: 'paused', requested_a: 0, requested_w: 0 } }; + assert.match(render(paused).textContent, /Paused by you/); + assert.match(render(paused).textContent, /This choice is active now, but could not be saved for restart. FTW is retrying/); + assert.doesNotMatch(render({ ...paused, manual_save_error: false }).textContent, /could not be saved/); + // Returning to the plan also changes the persisted choice: a failed clear + // must remain visible even though there is no active hold anymore. + assert.match(render({ ...paused, manual_active: false }).textContent, /could not be saved for restart/); +}); From 324c6c52e10c30b77176bb22a0604a03e0dc4349 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Sun, 6 Sep 2026 13:23:46 +0200 Subject: [PATCH 33/57] docs(update): use the configured Core service name Signed-off-by: Fredrik Ahlgren --- docs/self-update.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/docs/self-update.md b/docs/self-update.md index 21458e59..b9d42e9c 100644 --- a/docs/self-update.md +++ b/docs/self-update.md @@ -137,8 +137,7 @@ after Core passes its health check. For manual Core + updater operation, first set `FTW_IMAGE_TAG` and `FTW_UPDATER_IMAGE_TAG` in the project's `.env` to the same published immutable -tag. Then run the commands below, using `forty-two-watts` instead of `ftw` on a -legacy installation: +tag. Run these commands with the Core service name from your Compose file: ```bash cd ~/ftw From c9c07c250002ab2665d8243d980cc2c22858e9f3 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Sun, 6 Sep 2026 13:24:58 +0200 Subject: [PATCH 34/57] fix(ev): report failed manual saves and retry on fresh telemetry --- .changeset/ev-manual-save-feedback.md | 2 +- go/internal/loadpoint/loadpoint.go | 4 ++ go/internal/loadpoint/manual_hold_state.go | 23 ++++++- .../loadpoint/manual_hold_state_test.go | 64 +++++++++++++++++++ 4 files changed, 90 insertions(+), 3 deletions(-) diff --git a/.changeset/ev-manual-save-feedback.md b/.changeset/ev-manual-save-feedback.md index 368f3e5e..03e15e10 100644 --- a/.changeset/ev-manual-save-feedback.md +++ b/.changeset/ev-manual-save-feedback.md @@ -2,4 +2,4 @@ "ftw": patch --- -Show when a charging choice is active but could not be saved for restart. The notice clears when the box confirms its retry succeeded. +Show when a charging choice applies now but could not be saved for restart. Retry its save when fresh charger data arrives and clear the message only after storage confirms it. diff --git a/go/internal/loadpoint/loadpoint.go b/go/internal/loadpoint/loadpoint.go index a2b2b8ad..daa449e9 100644 --- a/go/internal/loadpoint/loadpoint.go +++ b/go/internal/loadpoint/loadpoint.go @@ -122,6 +122,7 @@ func (f SiteFuse) Phases() int { // it under lock. type State struct { ManualRestoreUnconfirmed bool `json:"manual_restore_unconfirmed"` + ManualSaveError bool `json:"manual_save_error"` VehicleCapacityWh float64 `json:"vehicle_capacity_wh"` CapacitySource string `json:"capacity_source"` // ChargingDeclined is a sustained vehicle-side refusal, not a battery level. @@ -326,6 +327,7 @@ type loadpointRuntime struct { configGeneration uint64 sessionGeneration uint64 manualRestoreUnconfirmed bool + manualSaveError bool sessionDeviceID string sessionID string socRetention string @@ -538,6 +540,7 @@ func (m *Manager) Load(cfgs []Config) { lp.socRetention = existing.socRetention lp.completionNotified = existing.completionNotified lp.manualRestoreUnconfirmed = existing.manualRestoreUnconfirmed + lp.manualSaveError = existing.manualSaveError } else { lp.pluggedIn = false lp.currentSoC = 0 @@ -1076,6 +1079,7 @@ func (lp *loadpointRuntime) snapshot() State { sort.Float64s(steps) st := State{ ManualRestoreUnconfirmed: lp.manualRestoreUnconfirmed, + ManualSaveError: lp.manualSaveError, VehicleCapacityWh: lp.VehicleCapacityWh, CapacitySource: "configured", ID: lp.ID, diff --git a/go/internal/loadpoint/manual_hold_state.go b/go/internal/loadpoint/manual_hold_state.go index 45fc6c12..b33e6ef7 100644 --- a/go/internal/loadpoint/manual_hold_state.go +++ b/go/internal/loadpoint/manual_hold_state.go @@ -26,9 +26,14 @@ func manualHoldKey(deviceID string) string { // telemetry, retain the action in memory and bind it to the first fresh device // reading. Such a Start still works immediately; it does not silently gain // permission to charge another car after a later process restart. -func (m *Manager) PersistManualHold(id string, h ManualHold, cleared bool) error { +func (m *Manager) PersistManualHold(id string, h ManualHold, cleared bool) (err error) { m.sessionMu.Lock() defer m.sessionMu.Unlock() + defer func() { + if err != nil { + m.setManualSaveError(id, true) + } + }() if m.pendingManual == nil { m.pendingManual = map[string]pendingManualHold{} } @@ -46,7 +51,12 @@ func (m *Manager) PersistManualHold(id string, h ManualHold, cleared bool) error return m.flushManualHold(id) } -func (m *Manager) flushManualHold(id string) error { +func (m *Manager) flushManualHold(id string) (err error) { + defer func() { + if err != nil { + m.setManualSaveError(id, true) + } + }() pending, ok := m.pendingManual[id] if !ok || m.sessionStore == nil { return nil @@ -90,9 +100,18 @@ func (m *Manager) flushManualHold(id string) error { return err } delete(m.pendingManual, id) + m.setManualSaveError(id, false) return nil } +func (m *Manager) setManualSaveError(id string, value bool) { + m.mu.Lock() + defer m.mu.Unlock() + if lp := m.byID[id]; lp != nil { + lp.manualSaveError = value + } +} + // RestoreManualHold returns pending until hardware identity is known. A // verified same-session positive hold or a hardware-bound pause is restored. // Unknown/changed sessions and legacy records return a persistent zero-W hold diff --git a/go/internal/loadpoint/manual_hold_state_test.go b/go/internal/loadpoint/manual_hold_state_test.go index a6ad1934..f8cc329e 100644 --- a/go/internal/loadpoint/manual_hold_state_test.go +++ b/go/internal/loadpoint/manual_hold_state_test.go @@ -5,6 +5,70 @@ import ( "time" ) +func TestManualSaveErrorKeepsCommandAndRetriesOnFreshReading(t *testing.T) { + for _, action := range []string{"start", "clear", "before_identity"} { + t.Run(action, func(t *testing.T) { + store := &sessionMemory{data: map[string]string{}} + m := sessionManager(store, "garage", "charger") + if action != "before_identity" { + m.ObserveSession("garage", true, 4300, 1000, true, "easee:A", "session-1") + } + c := NewController(m, nil, nil, nil) + var saveErr error + c.SetManualHoldSaver(func(id string, h ManualHold, cleared bool) { + saveErr = m.PersistManualHold(id, h, cleared) + }) + if action == "clear" { + c.SetManualHold("garage", ManualHold{PowerW: 4140, Persistent: true}) + if saveErr != nil { + t.Fatal(saveErr) + } + } + store.fail = true + if action == "clear" { + c.ClearManualHold("garage") + } else { + c.SetManualHold("garage", ManualHold{PowerW: 5520, Persistent: true}) + } + attempted := action != "before_identity" + if (saveErr != nil) != attempted { + t.Fatalf("save error = %v, attempted = %v", saveErr, attempted) + } + if s, _ := m.State("garage"); s.ManualSaveError != attempted { + t.Fatalf("save failure not reported: %+v", s) + } + // A later telemetry flush can fail too, including the first write + // of an explicit request received before hardware was known. + m.ObserveSession("garage", true, 4300, 1100, true, "easee:A", "session-1") + if s, _ := m.State("garage"); !s.ManualSaveError { + t.Fatal("retry failure not reported") + } + if h, ok := c.GetManualHold("garage", time.Now()); (action == "clear" && ok) || + (action != "clear" && (!ok || h.PowerW != 5520)) { + t.Fatalf("disk failure changed the current command: %+v %v", h, ok) + } + // Reloading settings cannot hide an outstanding failed save. + m.Load([]Config{{ID: "garage", DriverName: "charger", VehicleCapacityWh: 60000}}) + if s, _ := m.State("garage"); !s.ManualSaveError { + t.Fatal("config reload hid the failed save") + } + store.fail = false + m.ObserveSession("garage", true, 4300, 1200, true, "easee:A", "session-1") + if s, _ := m.State("garage"); s.ManualSaveError { + t.Fatal("successful retry did not clear the warning") + } + // Prove the retry reached storage rather than only clearing a flag. + restarted := sessionManager(store, "garage", "charger") + restarted.ObserveSession("garage", true, 4300, 1200, true, "easee:A", "session-1") + h, status := restarted.RestoreManualHold("garage") + if (action == "clear" && status != "none") || + (action != "clear" && (status != "restored" || h.PowerW != 5520)) { + t.Fatalf("retried command did not survive restart: %+v %s", h, status) + } + }) + } +} + func TestManualHoldRestoreRequiresHardwareAndActiveSession(t *testing.T) { store := &sessionMemory{data: map[string]string{}} m := sessionManager(store, "garage", "charger") From 7a314115622bbc1fd21632f43841e5a2124f2dce Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Sun, 6 Sep 2026 13:27:50 +0200 Subject: [PATCH 35/57] fix(ev): retain entered battery level when charging verifies session --- .changeset/ev-first-session-level.md | 5 +++ go/internal/loadpoint/session_state.go | 9 ++++- go/internal/loadpoint/session_state_test.go | 43 +++++++++++++++++++++ 3 files changed, 56 insertions(+), 1 deletion(-) create mode 100644 .changeset/ev-first-session-level.md diff --git a/.changeset/ev-first-session-level.md b/.changeset/ev-first-session-level.md new file mode 100644 index 00000000..7de59c03 --- /dev/null +++ b/.changeset/ev-first-session-level.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +Keep the battery level entered while waiting when the same charger first verifies its session at charging start. Count newly delivered energy from that level and save it once the session is verified. diff --git a/go/internal/loadpoint/session_state.go b/go/internal/loadpoint/session_state.go index 0c2ef9f0..13962105 100644 --- a/go/internal/loadpoint/session_state.go +++ b/go/internal/loadpoint/session_state.go @@ -75,8 +75,10 @@ func (m *Manager) ObserveSession(id string, pluggedIn bool, powerW, deliveredWh return } previousDevice, previousSession := lp.sessionDeviceID, lp.sessionID - changed := previousDevice != deviceID || previousSession != sessionID regressed := pluggedIn && lp.pluggedIn && deliveredWh < lp.deliveredWhSession + firstSessionProof := deviceID != "" && previousDevice == deviceID && previousSession == "" && sessionID != "" && + lp.pluggedIn && pluggedIn && !regressed + changed := previousDevice != deviceID || (previousSession != sessionID && !firstSessionProof) // 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 { @@ -136,6 +138,11 @@ func (m *Manager) ObserveSession(id string, pluggedIn bool, powerW, deliveredWh lp.socRetention = "unavailable" } m.mu.Unlock() + if firstSessionProof && confirmed { + // The driver can first verify a session when charging starts. Preserve + // the level the owner entered while waiting and now make it durable. + m.persistSession(id) + } _ = m.flushManualHold(id) } diff --git a/go/internal/loadpoint/session_state_test.go b/go/internal/loadpoint/session_state_test.go index 71aca0a4..3af052e4 100644 --- a/go/internal/loadpoint/session_state_test.go +++ b/go/internal/loadpoint/session_state_test.go @@ -58,6 +58,49 @@ func TestConfirmedSoCSurvivesRestartOnlyForSameHardwareSession(t *testing.T) { }) } } + +func TestConfirmedSoCSurvivesFirstSessionProofWhenChargingStarts(t *testing.T) { + store := &sessionMemory{data: map[string]string{}} + m := sessionManager(store, "garage", "charger") + m.ObserveSession("garage", true, 0, 0, true, "easee:A", "") + m.SetCurrentSoC("garage", .12) + if s, _ := m.State("garage"); s.SoCRetention != "unavailable" { + t.Fatalf("unverified session was saved: %+v", s) + } + m.ObserveSession("garage", true, 4300, 600, true, "easee:A", "session-1") + if s, _ := m.State("garage"); math.Abs(s.CurrentSoC-.13) > 1e-9 || s.SoCSource == "assumed" || s.SoCRetention != "session" { + t.Fatalf("charging lost the level entered while waiting: %+v", s) + } + m = sessionManager(store, "garage", "charger") + m.ObserveSession("garage", true, 4300, 600, true, "easee:A", "session-1") + if s, _ := m.State("garage"); math.Abs(s.CurrentSoC-.13) > 1e-9 || s.SoCSource == "assumed" { + t.Fatalf("verified level did not survive restart: %+v", s) + } +} + +func TestFirstSessionProofDoesNotCrossConnectionChanges(t *testing.T) { + for _, change := range []string{"unplug", "hardware", "counter_regression"} { + t.Run(change, func(t *testing.T) { + m := sessionManager(&sessionMemory{data: map[string]string{}}, "garage", "charger") + m.ObserveSession("garage", true, 0, 1000, true, "easee:A", "") + m.SetCurrentSoC("garage", .12) + device, wh := "easee:A", 1600.0 + switch change { + case "unplug": + m.ObserveSession("garage", false, 0, 1000, false, "easee:A", "") + case "hardware": + device = "easee:B" + case "counter_regression": + wh = 500 + } + m.ObserveSession("garage", true, 4300, wh, true, device, "session-1") + if s, _ := m.State("garage"); s.SoCSource != "assumed" { + t.Fatalf("level followed %s: %+v", change, s) + } + }) + } +} + func TestUnseenReconnectResetsConfirmedSoC(t *testing.T) { store := &sessionMemory{data: map[string]string{}} m := sessionManager(store, "garage", "charger") From cca809c92ea0754817642ab35dfc45e90cc79694 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Sun, 6 Sep 2026 13:30:50 +0200 Subject: [PATCH 36/57] fix(ev): keep previous charging goal when storage fails --- .changeset/ev-goal-save-errors.md | 5 ++ go/cmd/ftw/main.go | 14 +-- go/internal/api/api.go | 23 +++-- .../api/api_loadpoint_schedule_test.go | 78 +++++++++++++++++ go/internal/loadpoint/loadpoint.go | 60 +++++++++---- .../loadpoint/schedule_remove_goal_test.go | 2 +- go/internal/loadpoint/schedule_save_test.go | 85 +++++++++++++++++++ 7 files changed, 232 insertions(+), 35 deletions(-) create mode 100644 .changeset/ev-goal-save-errors.md create mode 100644 go/internal/loadpoint/schedule_save_test.go diff --git a/.changeset/ev-goal-save-errors.md b/.changeset/ev-goal-save-errors.md new file mode 100644 index 00000000..b3c076e2 --- /dev/null +++ b/.changeset/ev-goal-save-errors.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +Save charging goals before applying them. If storage fails, keep the previous goal and return a clear error for both goal edits and removals. A successful retry applies and saves the new goal together. diff --git a/go/cmd/ftw/main.go b/go/cmd/ftw/main.go index 003c2ceb..86555d73 100644 --- a/go/cmd/ftw/main.go +++ b/go/cmd/ftw/main.go @@ -758,22 +758,16 @@ 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:" - lpMgr.SetScheduleSaver(func(id string, s loadpoint.Schedule) { + lpMgr.SetScheduleSaver(func(id string, s loadpoint.Schedule) error { key := lpSchedKeyPrefix + id if s.Empty() { - if err := st.SaveConfig(key, "{}"); err != nil { - slog.Warn("failed to clear loadpoint schedule", "lp", id, "err", err) - } - return + return st.SaveConfig(key, "{}") } b, err := json.Marshal(s) if err != nil { - slog.Warn("failed to marshal loadpoint schedule", "lp", id, "err", err) - return - } - if err := st.SaveConfig(key, string(b)); err != nil { - slog.Warn("failed to persist loadpoint schedule", "lp", id, "err", err) + return err } + return st.SaveConfig(key, string(b)) }) lpMgr.HydrateSchedules(func(id string) (loadpoint.Schedule, bool) { v, ok := st.LoadConfig(lpSchedKeyPrefix + id) diff --git a/go/internal/api/api.go b/go/internal/api/api.go index 98d3ef63..288f3cba 100644 --- a/go/internal/api/api.go +++ b/go/internal/api/api.go @@ -3754,7 +3754,12 @@ func (s *Server) handleLoadpointTarget(w http.ResponseWriter, r *http.Request) { // schedule-only route. func (s *Server) applyLoadpointSchedule(id string, raw json.RawMessage) (int, string) { if bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { - if !s.deps.Loadpoints.ClearSchedule(id) { + ok, err := s.deps.Loadpoints.ClearScheduleChecked(id) + if err != nil { + slog.Warn("failed to clear loadpoint schedule", "lp", id, "err", err) + return 500, "Could not remove charging goal. Your previous goal is unchanged. Try again." + } + if !ok { return 404, "loadpoint not found" } return 0, "" @@ -3769,7 +3774,12 @@ func (s *Server) applyLoadpointSchedule(id string, raw json.RawMessage) (int, st if sched.Days > 0x7F { return 400, "days must be a 7-bit weekday mask (0..127, bit 0 = Monday)" } - if !s.deps.Loadpoints.SetSchedule(id, sched) { + ok, err := s.deps.Loadpoints.SetScheduleChecked(id, sched) + if err != nil { + slog.Warn("failed to save loadpoint schedule", "lp", id, "err", err) + return 500, "Could not save charging goal. Your previous goal is unchanged. Try again." + } + if !ok { return 404, "loadpoint not found" } // Roll immediately so a read-modify-write on the heels of this set @@ -3848,9 +3858,8 @@ func (s *Server) handleLoadpointSchedulePut(w http.ResponseWriter, r *http.Reque } // DELETE /api/loadpoints/{id}/schedule clears the schedule. Same price -// as PUT: removing the standing instruction is configuration too. It also -// removes the target derived from that schedule. Manual charging has its -// own release action and continues unchanged. +// as PUT: removing the standing instruction is configuration too. Its derived +// target clears after storage succeeds. Manual charging remains active. func (s *Server) handleLoadpointScheduleClear(w http.ResponseWriter, r *http.Request) { if s.deps.Loadpoints == nil { writeJSON(w, 404, map[string]string{"error": "loadpoints not configured"}) @@ -3861,8 +3870,8 @@ func (s *Server) handleLoadpointScheduleClear(w http.ResponseWriter, r *http.Req writeJSON(w, 400, map[string]string{"error": "id required"}) return } - if !s.deps.Loadpoints.ClearSchedule(id) { - writeJSON(w, 404, map[string]string{"error": "loadpoint not found"}) + if status, msg := s.applyLoadpointSchedule(id, json.RawMessage("null")); status != 0 { + writeJSON(w, status, map[string]string{"error": msg}) return } s.refreshVehicleForSchedule(id) diff --git a/go/internal/api/api_loadpoint_schedule_test.go b/go/internal/api/api_loadpoint_schedule_test.go index 0dce9576..eff3a007 100644 --- a/go/internal/api/api_loadpoint_schedule_test.go +++ b/go/internal/api/api_loadpoint_schedule_test.go @@ -1,6 +1,7 @@ package api import ( + "encoding/json" "net/http" "net/http/httptest" "path/filepath" @@ -13,6 +14,83 @@ import ( "github.com/srcfl/ftw/go/internal/state" ) +func TestScheduleStorageFailureKeepsGoalAndRetrySaves(t *testing.T) { + for _, tc := range []struct { + name, method, path, body string + clear bool + }{ + {"put", http.MethodPut, "/schedule", `{"soc_pct":90,"time_of_day_min_utc":420}`, false}, + {"delete", http.MethodDelete, "/schedule", "", true}, + {"put_null", http.MethodPut, "/schedule", "null", true}, + {"target_set", http.MethodPost, "/target", `{"schedule":{"soc_pct":90,"time_of_day_min_utc":420}}`, false}, + {"target_clear", http.MethodPost, "/target", `{"schedule":null}`, true}, + } { + t.Run(tc.name, func(t *testing.T) { + srv, mgr, svc := newScheduleServer(t) + path := filepath.Join(t.TempDir(), "goals.db") + disk, err := state.Open(path) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { disk.Close() }) + mgr.SetScheduleSaver(func(_ string, s loadpoint.Schedule) error { + b, err := json.Marshal(s) + if err != nil { + return err + } + return disk.SaveConfig("goal", string(b)) + }) + old := loadpoint.Schedule{SoC: .8, TimeOfDayMinUTC: 360, Recurring: true} + if !mgr.SetSchedule("garage", old) { + t.Fatal("initial save failed") + } + mgr.RollSchedules(time.Now()) + before, _ := mgr.State("garage") + if err := disk.Close(); err != nil { + t.Fatal(err) + } + request := func() *httptest.ResponseRecorder { + r := httptest.NewRequest(tc.method, "/api/loadpoints/garage"+tc.path, strings.NewReader(tc.body)) + r.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + srv.Handler().ServeHTTP(rr, r) + return rr + } + rr := request() + if rr.Code != http.StatusInternalServerError || !strings.Contains(rr.Body.String(), "previous goal is unchanged") { + t.Fatalf("failed storage returned %d: %s", rr.Code, rr.Body.String()) + } + after, _ := mgr.State("garage") + if after.Schedule != old || after.TargetSoC != before.TargetSoC || after.TargetTime != before.TargetTime { + t.Fatalf("failed request changed the running goal: %+v", after) + } + if _, reason := svc.LastReplanInfo(); reason != "" { + t.Fatalf("failed save triggered replan: %s", reason) + } + disk, err = state.Open(path) + if err != nil { + t.Fatal(err) + } + if rr = request(); rr.Code != http.StatusOK { + t.Fatalf("retry returned %d: %s", rr.Code, rr.Body.String()) + } + raw, found := disk.LoadConfig("goal") + var saved loadpoint.Schedule + if !found || json.Unmarshal([]byte(raw), &saved) != nil { + t.Fatalf("retry did not persist a goal: %q", raw) + } + after, _ = mgr.State("garage") + if tc.clear { + if !saved.Empty() || !after.Schedule.Empty() || after.TargetSoC != 0 || !after.TargetTime.IsZero() { + t.Fatalf("retry failed to remove goal: saved=%+v state=%+v", saved, after) + } + } else if saved.SoC != .9 || after.Schedule != saved || after.TargetSoC != .9 || after.TargetTime.IsZero() { + t.Fatalf("retry did not apply the saved goal: saved=%+v state=%+v", saved, after) + } + }) + } +} + // The schedule-only route. Its tier is pinned alongside the other // verb-blind cases in TestRouteTierIgnoresTheMethod; these tests cover // what the handlers do: PUT stores and rolls, DELETE clears, and both diff --git a/go/internal/loadpoint/loadpoint.go b/go/internal/loadpoint/loadpoint.go index daa449e9..382d77d6 100644 --- a/go/internal/loadpoint/loadpoint.go +++ b/go/internal/loadpoint/loadpoint.go @@ -266,7 +266,8 @@ type Manager struct { // 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. - scheduleSaver func(id string, s Schedule) + scheduleMu sync.Mutex + scheduleSaver func(id string, s Schedule) error // surplusOnlySaver, if non-nil, persists the runtime surplus_only // flag whenever an operator toggles it. Without this the flag @@ -489,6 +490,8 @@ func (m *Manager) SetCommanded(id string, w float64, reason string) { // Load replaces the configured set. Idempotent: existing state is // carried across when the ID is kept; removed IDs are dropped. func (m *Manager) Load(cfgs []Config) { + m.scheduleMu.Lock() + defer m.scheduleMu.Unlock() m.sessionMu.Lock() defer m.sessionMu.Unlock() var changedCapacity []string @@ -1118,27 +1121,47 @@ func (lp *loadpointRuntime) snapshot() State { // SetScheduleSaver wires the persistence callback. Pass nil to disable. // Safe to call before or after Load(). -func (m *Manager) SetScheduleSaver(saver func(id string, s Schedule)) { +func (m *Manager) SetScheduleSaver(saver func(id string, s Schedule) error) { + m.scheduleMu.Lock() + defer m.scheduleMu.Unlock() m.mu.Lock() defer m.mu.Unlock() m.scheduleSaver = saver } -// SetSchedule stores the operator's intent for a loadpoint. Empty -// schedules clear (equivalent to ClearSchedule). Returns false for -// unknown IDs. The persistence callback (if wired) is invoked outside -// the lock so a slow disk doesn't block other readers. +// SetSchedule stores the operator's intent. It returns false for an unknown +// loadpoint or a failed save. Use SetScheduleChecked to distinguish them. func (m *Manager) SetSchedule(id string, s Schedule) bool { - m.mu.Lock() + ok, err := m.SetScheduleChecked(id, s) + return ok && err == nil +} + +// SetScheduleChecked saves before changing the active goal. On storage +// 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) { + m.scheduleMu.Lock() + defer m.scheduleMu.Unlock() + m.mu.RLock() lp, ok := m.byID[id] + saver := m.scheduleSaver + m.mu.RUnlock() if !ok { - m.mu.Unlock() - return false + return false, nil } s.Normalize() // 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 err := saver(id, s); err != nil { + return true, err + } + } + m.mu.Lock() + defer m.mu.Unlock() + // Load shares scheduleMu, so the configured loadpoint cannot change + // between the save and the in-memory update. if s.SoC > lp.schedule.SoC { lp.chargingDeclined = false lp.notRequestingSince = time.Time{} @@ -1158,12 +1181,7 @@ func (m *Manager) SetSchedule(id string, s Schedule) bool { // non-recurring saves. lp.targetTime = time.Time{} lp.targetSoC = 0 - saver := m.scheduleSaver - m.mu.Unlock() - if saver != nil { - saver(id, s) - } - return true + return true, nil } // GetSchedule returns the current schedule and a found flag. The flag @@ -1184,12 +1202,18 @@ func (m *Manager) GetSchedule(id string) (Schedule, bool) { // ClearSchedule removes the operator's intent. Persists Empty so a // reload doesn't resurrect the old schedule from disk. Returns false -// for unknown IDs. +// for unknown IDs or a failed save. func (m *Manager) ClearSchedule(id string) bool { + ok, err := m.ClearScheduleChecked(id) + return ok && err == nil +} + +// ClearScheduleChecked keeps the previous goal when its removal cannot save. +func (m *Manager) ClearScheduleChecked(id string) (bool, error) { // Removing the goal also removes its active derived deadline. Leaving // that target behind would keep planning a charge the UI says was removed. // Manual holds belong to the controller and are unaffected. - return m.SetSchedule(id, Schedule{}) + return m.SetScheduleChecked(id, Schedule{}) } // HydrateSchedules loads persisted schedules at boot. `loader(id)` @@ -1201,6 +1225,8 @@ func (m *Manager) ClearSchedule(id string) bool { // Does NOT invoke the saver — this is a load path. Does NOT call // RollSchedules either; the controller's first tick will handle that. func (m *Manager) HydrateSchedules(loader func(id string) (Schedule, bool)) { + m.scheduleMu.Lock() + defer m.scheduleMu.Unlock() m.mu.Lock() defer m.mu.Unlock() for _, id := range m.order { diff --git a/go/internal/loadpoint/schedule_remove_goal_test.go b/go/internal/loadpoint/schedule_remove_goal_test.go index 67b532a2..1865a6ac 100644 --- a/go/internal/loadpoint/schedule_remove_goal_test.go +++ b/go/internal/loadpoint/schedule_remove_goal_test.go @@ -18,7 +18,7 @@ func TestRemoveScheduleClearsDerivedGoalButKeepsManualCharge(t *testing.T) { t.Fatal("test needs a rolled goal") } var saved Schedule - m.SetScheduleSaver(func(_ string, s Schedule) { saved = s }) + m.SetScheduleSaver(func(_ string, s Schedule) error { saved = s; return nil }) sender := &fakeSender{} c := NewController(m, func(time.Time) (Directive, bool) { return Directive{}, false }, func(string) (EVSample, bool) { return EVSample{Connected: true, RequestActive: true}, true }, sender.Send) c.SetManualHold(cfg.ID, ManualHold{PowerW: 4140, PhaseMode: "3p", Persistent: true}) diff --git a/go/internal/loadpoint/schedule_save_test.go b/go/internal/loadpoint/schedule_save_test.go new file mode 100644 index 00000000..b3dac180 --- /dev/null +++ b/go/internal/loadpoint/schedule_save_test.go @@ -0,0 +1,85 @@ +package loadpoint + +import ( + "errors" + "testing" + "time" +) + +func TestScheduleCheckedReportsStorageFailureWithoutChangingGoal(t *testing.T) { + m := NewManager() + m.Load([]Config{{ID: "garage", DriverName: "easee"}}) + old := Schedule{SoC: .8, TimeOfDayMinUTC: 420, Recurring: true} + m.SetSchedule("garage", old) + m.RollSchedules(time.Now()) + before, _ := m.State("garage") + failure := errors.New("disk full") + m.SetScheduleSaver(func(string, Schedule) error { return failure }) + if ok, err := m.SetScheduleChecked("garage", Schedule{SoC: .9}); !ok || !errors.Is(err, failure) { + t.Fatalf("set = %v, %v", ok, err) + } + if ok, err := m.ClearScheduleChecked("garage"); !ok || !errors.Is(err, failure) { + t.Fatalf("clear = %v, %v", ok, err) + } + if m.SetSchedule("garage", Schedule{SoC: .9}) || m.ClearSchedule("garage") { + t.Fatal("compatibility wrapper reported a failed save as successful") + } + if ok, err := m.SetScheduleChecked("missing", old); ok || err != nil { + t.Fatalf("unknown ID = %v, %v", ok, err) + } + m.RollSchedules(time.Now()) + after, _ := m.State("garage") + if after.Schedule != old || after.TargetSoC != before.TargetSoC || after.TargetTime != before.TargetTime { + t.Fatalf("failed save changed the running goal: %+v", after) + } +} + +func TestScheduleWritesKeepOrderAndReadersSeeOldGoalDuringSave(t *testing.T) { + m := NewManager() + m.Load([]Config{{ID: "garage", DriverName: "easee"}}) + m.SetSchedule("garage", Schedule{SoC: .8, TimeOfDayMinUTC: 420, Recurring: true}) + m.RollSchedules(time.Now()) + before, _ := m.State("garage") + entered, release := make(chan struct{}), make(chan struct{}) + setDone, clearDone := make(chan error, 1), make(chan error, 1) + var writes []Schedule + m.SetScheduleSaver(func(_ string, s Schedule) error { + if !s.Empty() { + close(entered) + <-release + } + writes = append(writes, s) + return nil + }) + go func() { _, err := m.SetScheduleChecked("garage", Schedule{SoC: .9}); setDone <- err }() + <-entered + go func() { _, err := m.ClearScheduleChecked("garage"); clearDone <- err }() + select { + case err := <-clearDone: + t.Fatalf("Clear overtook an earlier pending save: %v", err) + case <-time.After(20 * time.Millisecond): + } + readDone := make(chan State, 1) + go func() { s, _ := m.State("garage"); readDone <- s }() + select { + case s := <-readDone: + if s.Schedule != before.Schedule || s.TargetSoC != before.TargetSoC || s.TargetTime != before.TargetTime { + t.Fatalf("new goal appeared before storage completed: %+v", s) + } + case <-time.After(time.Second): + t.Fatal("disk write blocked state reads") + } + close(release) + if err := <-setDone; err != nil { + t.Fatal(err) + } + if err := <-clearDone; err != nil { + t.Fatal(err) + } + if len(writes) != 2 || writes[0].SoC != .9 || !writes[1].Empty() { + t.Fatalf("save order = %+v", writes) + } + if s, _ := m.State("garage"); !s.Schedule.Empty() || s.TargetSoC != 0 || !s.TargetTime.IsZero() { + t.Fatalf("Clear lost to an older save: %+v", s) + } +} From b67f07b4af0f00e1336fd604e45e9db5ff5da735 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Sun, 6 Sep 2026 13:34:27 +0200 Subject: [PATCH 37/57] fix(ev): reject solar preference changes when storage fails --- .changeset/ev-solar-save-errors.md | 5 ++ go/cmd/ftw/app_link_solar_save_test.go | 42 +++++++++++ go/cmd/ftw/main.go | 6 +- go/internal/api/api.go | 7 +- .../api/api_loadpoint_solar_save_test.go | 52 ++++++++++++++ go/internal/appproto/ev.go | 4 +- go/internal/appproto/ports.go | 3 +- go/internal/loadpoint/loadpoint.go | 70 +++++++++++-------- 8 files changed, 152 insertions(+), 37 deletions(-) create mode 100644 .changeset/ev-solar-save-errors.md create mode 100644 go/cmd/ftw/app_link_solar_save_test.go create mode 100644 go/internal/api/api_loadpoint_solar_save_test.go diff --git a/.changeset/ev-solar-save-errors.md b/.changeset/ev-solar-save-errors.md new file mode 100644 index 00000000..5f7849b1 --- /dev/null +++ b/.changeset/ev-solar-save-errors.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +Save the solar charging choice before applying it. If storage fails, keep the previous choice and reject the change in both the box UI and Webapp. A retry can save and apply the choice once storage recovers. diff --git a/go/cmd/ftw/app_link_solar_save_test.go b/go/cmd/ftw/app_link_solar_save_test.go new file mode 100644 index 00000000..5667bcb5 --- /dev/null +++ b/go/cmd/ftw/app_link_solar_save_test.go @@ -0,0 +1,42 @@ +package main + +import ( + "errors" + "testing" + + "github.com/srcfl/ftw/go/internal/loadpoint" +) + +func TestAppSolarPreferenceRejectsFailedSaveAndAcceptsRetry(t *testing.T) { + m := loadpoint.NewManager() + m.Load([]loadpoint.Config{{ID: "garage", DriverName: "easee", SurplusOnly: true}}) + failure := errors.New("disk full") + saved := true + m.SetSurplusOnlySaver(func(_ string, v bool) error { + if failure != nil { + return failure + } + saved = v + return nil + }) + if previous, found, err := m.SetSurplusOnlyChecked("garage", false); !previous || !found || !errors.Is(err, failure) { + t.Fatalf("checked setter = %v, %v, %v", previous, found, err) + } + if previous, ok := m.SetSurplusOnly("garage", false); !previous || ok { + t.Fatalf("compatibility wrapper = %v, %v", previous, ok) + } + port := &appLoadpoints{mgr: m} + if _, ok := port.SetSurplusOnly("garage", false); ok { + t.Fatal("app port accepted a failed save") + } + if actual, ok := port.ObservedSurplusOnly("garage"); !ok || !actual || !saved { + t.Fatalf("failed save changed the active choice: actual=%v known=%v saved=%v", actual, ok, saved) + } + failure = nil + if previous, ok := port.SetSurplusOnly("garage", false); !ok || !previous { + t.Fatalf("retry = %v, %v", previous, ok) + } + if actual, ok := port.ObservedSurplusOnly("garage"); !ok || actual || saved { + t.Fatalf("retry did not save the choice: actual=%v known=%v saved=%v", actual, ok, saved) + } +} diff --git a/go/cmd/ftw/main.go b/go/cmd/ftw/main.go index 86555d73..a402b04d 100644 --- a/go/cmd/ftw/main.go +++ b/go/cmd/ftw/main.go @@ -788,15 +788,13 @@ func main() { // YAML, so the previous in-memory-only behaviour reverted the // flag on every restart. const lpSurplusKeyPrefix = "loadpoint_surplus_only:" - lpMgr.SetSurplusOnlySaver(func(id string, v bool) { + lpMgr.SetSurplusOnlySaver(func(id string, v bool) error { key := lpSurplusKeyPrefix + id val := "false" if v { val = "true" } - if err := st.SaveConfig(key, val); err != nil { - slog.Warn("failed to persist loadpoint surplus_only", "lp", id, "err", err) - } + return st.SaveConfig(key, val) }) hydrateLoadpointSurplusOnly := func() { lpMgr.HydrateSurplusOnly(func(id string) (bool, bool) { diff --git a/go/internal/api/api.go b/go/internal/api/api.go index 288f3cba..da0463fe 100644 --- a/go/internal/api/api.go +++ b/go/internal/api/api.go @@ -3703,7 +3703,12 @@ func (s *Server) handleLoadpointTarget(w http.ResponseWriter, r *http.Request) { } surplusDisabled := false if req.SurplusOnly != nil { - prev, ok := s.deps.Loadpoints.SetSurplusOnly(id, *req.SurplusOnly) + prev, ok, err := s.deps.Loadpoints.SetSurplusOnlyChecked(id, *req.SurplusOnly) + if err != nil { + slog.Warn("failed to save loadpoint solar preference", "lp", id, "err", err) + writeJSON(w, 500, map[string]string{"error": "Could not save solar charging preference. Your previous choice is unchanged. Try again."}) + return + } if !ok { writeJSON(w, 404, map[string]string{"error": "loadpoint not found"}) return diff --git a/go/internal/api/api_loadpoint_solar_save_test.go b/go/internal/api/api_loadpoint_solar_save_test.go new file mode 100644 index 00000000..90f66c88 --- /dev/null +++ b/go/internal/api/api_loadpoint_solar_save_test.go @@ -0,0 +1,52 @@ +package api + +import ( + "errors" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "testing" + + "github.com/srcfl/ftw/go/internal/loadpoint" +) + +func TestSolarPreferenceStorageFailureKeepsChoiceAndRetrySaves(t *testing.T) { + for _, previous := range []bool{false, true} { + t.Run(strconv.FormatBool(previous), func(t *testing.T) { + m := loadpoint.NewManager() + m.Load([]loadpoint.Config{{ID: "garage", DriverName: "easee", SurplusOnly: previous}}) + saved, fail := previous, true + m.SetSurplusOnlySaver(func(_ string, v bool) error { + if fail { + return errors.New("disk full") + } + saved = v + return nil + }) + srv := New(&Deps{Loadpoints: m}) + request := func() *httptest.ResponseRecorder { + r := httptest.NewRequest(http.MethodPost, "/api/loadpoints/garage/target", + strings.NewReader(`{"surplus_only":`+strconv.FormatBool(!previous)+`}`)) + r.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + srv.Handler().ServeHTTP(rr, r) + return rr + } + rr := request() + if rr.Code != http.StatusInternalServerError || !strings.Contains(rr.Body.String(), "previous choice is unchanged") { + t.Fatalf("failed save returned %d: %s", rr.Code, rr.Body.String()) + } + if s, _ := m.State("garage"); s.SurplusOnly != previous || saved != previous { + t.Fatalf("failure changed solar preference: state=%v saved=%v", s.SurplusOnly, saved) + } + fail = false + if rr = request(); rr.Code != http.StatusOK { + t.Fatalf("retry returned %d: %s", rr.Code, rr.Body.String()) + } + if s, _ := m.State("garage"); s.SurplusOnly == previous || saved != s.SurplusOnly { + t.Fatalf("retry did not save the active preference: state=%v saved=%v", s.SurplusOnly, saved) + } + }) + } +} diff --git a/go/internal/appproto/ev.go b/go/internal/appproto/ev.go index 9621c0ab..e23cfa52 100644 --- a/go/internal/appproto/ev.go +++ b/go/internal/appproto/ev.go @@ -420,8 +420,8 @@ func (h *Handler) loadpointSurplusOnlySet(cmd Cmd, uptimeMs int64) error { } if _, ok := lp.SetSurplusOnly(id, want); !ok { - // The loadpoint went away between the existence check and the - // write — a configuration reload mid-command. + // The loadpoint disappeared or storage rejected the change. Keep + // the previous choice and let the app offer a retry. return h.settleAndReport(cmd.CmdID, CmdResult{ CmdID: cmd.CmdID, State: CmdRejected, diff --git a/go/internal/appproto/ports.go b/go/internal/appproto/ports.go index 9a0d8404..b59fa6f4 100644 --- a/go/internal/appproto/ports.go +++ b/go/internal/appproto/ports.go @@ -171,7 +171,8 @@ type Loadpoints interface { // ObservedSoC is the state of charge the box holds for the car now. ObservedSoC(id string) (soc float64, ok bool) // SetSurplusOnly turns PV-only charging on or off and reports the value - // it replaced. The implementation carries the replan the HTTP target + // it replaced, or ok=false if the loadpoint is missing or the save fails. + // The implementation carries the replan the HTTP target // route does — synchronous when the flag turns off, because the car may // now draw from the grid and the plan must say so before the app reads // it back. diff --git a/go/internal/loadpoint/loadpoint.go b/go/internal/loadpoint/loadpoint.go index 382d77d6..db62a0f7 100644 --- a/go/internal/loadpoint/loadpoint.go +++ b/go/internal/loadpoint/loadpoint.go @@ -263,10 +263,11 @@ type Manager struct { byID map[string]*loadpointRuntime order []string // insertion-preserving id list for deterministic listing + // intentMu serializes durable goal and solar edits with config reloads. + intentMu sync.Mutex // 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. - scheduleMu sync.Mutex scheduleSaver func(id string, s Schedule) error // surplusOnlySaver, if non-nil, persists the runtime surplus_only @@ -275,7 +276,7 @@ type Manager struct { // finding that frustrating since the toggle lives in the dashboard // EV modal, not the YAML they'd think to edit. Same pattern as // scheduleSaver. - surplusOnlySaver func(id string, v bool) + surplusOnlySaver func(id string, v bool) error // nowFn is the clock the manager uses for time-sensitive logic // (session-completion timer in particular). Defaults to time.Now; @@ -490,8 +491,8 @@ func (m *Manager) SetCommanded(id string, w float64, reason string) { // Load replaces the configured set. Idempotent: existing state is // carried across when the ID is kept; removed IDs are dropped. func (m *Manager) Load(cfgs []Config) { - m.scheduleMu.Lock() - defer m.scheduleMu.Unlock() + m.intentMu.Lock() + defer m.intentMu.Unlock() m.sessionMu.Lock() defer m.sessionMu.Unlock() var changedCapacity []string @@ -875,35 +876,44 @@ func (m *Manager) SetTarget(id string, soc float64, targetTime time.Time) bool { return true } -// SetSurplusOnly toggles the runtime surplus_only flag for a loadpoint. -// Mutates Config.SurplusOnly so subsequent Configs() calls reflect the -// new value (both the MPC LoadpointSpec builder in main.go and the -// dispatch controller read from there). Returns (previous, ok) so a -// caller can detect the transition direction — disabling surplus_only -// is a regime change for the planner (the EV may now import from the -// grid) and the API handler forces a tagged replan in that case. +// SetSurplusOnly changes solar-only charging and returns the previous choice. +// A missing loadpoint or failed save returns ok=false. Consumers use the +// transition to replan before charging can draw from the grid. func (m *Manager) SetSurplusOnly(id string, v bool) (prev bool, ok bool) { - m.mu.Lock() + prev, ok, err := m.SetSurplusOnlyChecked(id, v) + return prev, ok && err == nil +} + +// SetSurplusOnlyChecked keeps the previous solar preference until storage +// accepts the change. Readers and charging continue with the current choice. +func (m *Manager) SetSurplusOnlyChecked(id string, v bool) (prev bool, ok bool, err error) { + m.intentMu.Lock() + defer m.intentMu.Unlock() + m.mu.RLock() lp, ok := m.byID[id] if !ok { - m.mu.Unlock() - return false, false + m.mu.RUnlock() + return false, false, nil } prev = lp.Config.SurplusOnly - lp.Config.SurplusOnly = v saver := m.surplusOnlySaver - m.mu.Unlock() + m.mu.RUnlock() if saver != nil && prev != v { - saver(id, v) + if err := saver(id, v); err != nil { + return prev, true, err + } } - return prev, true + m.mu.Lock() + lp.Config.SurplusOnly = v + m.mu.Unlock() + return prev, true, nil } // SetSurplusOnlySaver wires the persistence callback. Pass nil to -// disable. Mirrors SetScheduleSaver — the saver runs on every change -// (after the mutex is released, so the storage I/O isn't on the hot -// path). -func (m *Manager) SetSurplusOnlySaver(saver func(id string, v bool)) { +// disable. The saver runs before each change without blocking state reads. +func (m *Manager) SetSurplusOnlySaver(saver func(id string, v bool) error) { + m.intentMu.Lock() + defer m.intentMu.Unlock() m.mu.Lock() defer m.mu.Unlock() m.surplusOnlySaver = saver @@ -915,6 +925,8 @@ func (m *Manager) SetSurplusOnlySaver(saver func(id string, v bool)) { // the YAML default, (zero, false) otherwise. Matches the pattern used // by HydrateSchedules. func (m *Manager) HydrateSurplusOnly(load func(id string) (bool, bool)) { + m.intentMu.Lock() + defer m.intentMu.Unlock() m.mu.Lock() defer m.mu.Unlock() for id, lp := range m.byID { @@ -1122,8 +1134,8 @@ func (lp *loadpointRuntime) snapshot() State { // SetScheduleSaver wires the persistence callback. Pass nil to disable. // Safe to call before or after Load(). func (m *Manager) SetScheduleSaver(saver func(id string, s Schedule) error) { - m.scheduleMu.Lock() - defer m.scheduleMu.Unlock() + m.intentMu.Lock() + defer m.intentMu.Unlock() m.mu.Lock() defer m.mu.Unlock() m.scheduleSaver = saver @@ -1140,8 +1152,8 @@ 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) { - m.scheduleMu.Lock() - defer m.scheduleMu.Unlock() + m.intentMu.Lock() + defer m.intentMu.Unlock() m.mu.RLock() lp, ok := m.byID[id] saver := m.scheduleSaver @@ -1160,7 +1172,7 @@ func (m *Manager) SetScheduleChecked(id string, s Schedule) (bool, error) { } m.mu.Lock() defer m.mu.Unlock() - // Load shares scheduleMu, so the configured loadpoint cannot change + // Load shares intentMu, so the configured loadpoint cannot change // between the save and the in-memory update. if s.SoC > lp.schedule.SoC { lp.chargingDeclined = false @@ -1225,8 +1237,8 @@ func (m *Manager) ClearScheduleChecked(id string) (bool, error) { // Does NOT invoke the saver — this is a load path. Does NOT call // RollSchedules either; the controller's first tick will handle that. func (m *Manager) HydrateSchedules(loader func(id string) (Schedule, bool)) { - m.scheduleMu.Lock() - defer m.scheduleMu.Unlock() + m.intentMu.Lock() + defer m.intentMu.Unlock() m.mu.Lock() defer m.mu.Unlock() for _, id := range m.order { From 8f3b6b5e3b73e274b4252ce7d72fd8ba6599bc3e Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Sun, 6 Sep 2026 13:40:48 +0200 Subject: [PATCH 38/57] fix(ev): keep fuse budgets below feasible charge steps Signed-off-by: Fredrik Ahlgren --- .changeset/ev-fuse-ceiling.md | 5 ++++ go/internal/loadpoint/controller.go | 2 +- go/internal/loadpoint/manual_limits.go | 21 +-------------- go/internal/loadpoint/manual_limits_test.go | 30 +++++++++++++++++++++ go/internal/loadpoint/snap.go | 23 ++++++++++++++++ 5 files changed, 60 insertions(+), 21 deletions(-) create mode 100644 .changeset/ev-fuse-ceiling.md diff --git a/.changeset/ev-fuse-ceiling.md b/.changeset/ev-fuse-ceiling.md new file mode 100644 index 00000000..a3bcc854 --- /dev/null +++ b/.changeset/ev-fuse-ceiling.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +Keep EV power below the fuse budget when the budget falls between charging steps or below the minimum. diff --git a/go/internal/loadpoint/controller.go b/go/internal/loadpoint/controller.go index dfed0e38..84fd4dbd 100644 --- a/go/internal/loadpoint/controller.go +++ b/go/internal/loadpoint/controller.go @@ -833,7 +833,7 @@ func (c *Controller) applyFuseClampAndCooldown(now time.Time, lpCfg Config, want return wantW, "" } // Need to ramp down. Snap to the largest allowed step ≤ cap. - snapped := SnapChargeW(cap, lpCfg.MinChargeW, lpCfg.MaxChargeW, lpCfg.AllowedStepsW) + snapped := floorChargeW(cap, lpCfg.MinChargeW, lpCfg.MaxChargeW, lpCfg.AllowedStepsW) if snapped > 0 && snapped >= lpCfg.MinChargeW { slog.Info("loadpoint fuse-clamp: ramped down", "lp", lpCfg.ID, "want_w", wantW, "fuse_cap_w", cap, "snapped_w", snapped) diff --git a/go/internal/loadpoint/manual_limits.go b/go/internal/loadpoint/manual_limits.go index 7c67c04e..7d497af1 100644 --- a/go/internal/loadpoint/manual_limits.go +++ b/go/internal/loadpoint/manual_limits.go @@ -5,13 +5,6 @@ import "math" // A manual selection is a ceiling. Never round it up to a larger step or // let it bypass the configured charger's rating. func clampManualPower(cfg Config, hold ManualHold, site SiteFuse) float64 { - want := hold.PowerW - if math.IsNaN(want) || math.IsInf(want, 0) || want <= 0 { - return 0 - } - if cfg.MaxChargeW > 0 && want > cfg.MaxChargeW { - want = cfg.MaxChargeW - } floor := cfg.MinChargeW mode := hold.PhaseMode if mode == "" { @@ -20,19 +13,7 @@ func clampManualPower(cfg Config, hold ManualHold, site SiteFuse) float64 { if (mode == "1p" || mode == "auto") && site.Phases() == 3 { floor /= 3 } - if want < floor { - return 0 - } - if len(cfg.AllowedStepsW) == 0 { - return want - } - best := 0.0 - for _, step := range cfg.AllowedStepsW { - if step >= floor && step <= want && step > best { - best = step - } - } - return best + return floorChargeW(hold.PowerW, floor, cfg.MaxChargeW, cfg.AllowedStepsW) } func (c *Controller) applyInstallationLimits(cmd map[string]any) { diff --git a/go/internal/loadpoint/manual_limits_test.go b/go/internal/loadpoint/manual_limits_test.go index e232b904..cbe4f1ca 100644 --- a/go/internal/loadpoint/manual_limits_test.go +++ b/go/internal/loadpoint/manual_limits_test.go @@ -63,3 +63,33 @@ func TestCurrentCeilingCannotTurnZeroFuseBudgetIntoDefaultAmps(t *testing.T) { } } } + +// A fuse budget is a ceiling, including between steps and below the minimum. +// The dispatch may pause; it must never increase an already reduced request. +func TestFusePowerBudgetNeverRoundsUp(t *testing.T) { + for _, tc := range []struct { + name string + want, cap, max, expected float64 + steps []float64 + }{ + {"below minimum", 11000, 3000, 11000, 0, []float64{0, 4140, 6900, 11000}}, + {"between steps", 11000, 6000, 11000, 4140, []float64{0, 4140, 6900, 11000}}, + {"no steps below minimum", 11000, 3000, 11000, 0, nil}, + {"continuous budget", 11000, 5000, 11000, 5000, nil}, + {"invalid step above rating", 11000, 8000, 7000, 6900, []float64{0, 4140, 6900, 8000}}, + {"reduced one phase request", 2000, 1500, 11000, 0, []float64{0, 4140, 6900, 11000}}, + } { + t.Run(tc.name, func(t *testing.T) { + cfg := holdLoadpoint() + cfg.MinChargeW = 4140 + cfg.MaxChargeW = tc.max + cfg.AllowedStepsW = tc.steps + c := newTestController(t, []Config{cfg}, nil, map[string]EVSample{}, &fakeSender{}) + c.SetFuseEVMax(func() (float64, bool) { return tc.cap, true }) + got, _ := c.applyFuseClampAndCooldown(time.Now(), cfg, tc.want) + if got != tc.expected || got > tc.cap || got > tc.want { + t.Fatalf("want %v cap %v: sent %v, expected %v", tc.want, tc.cap, got, tc.expected) + } + }) + } +} diff --git a/go/internal/loadpoint/snap.go b/go/internal/loadpoint/snap.go index 5e4c0406..a9c606f1 100644 --- a/go/internal/loadpoint/snap.go +++ b/go/internal/loadpoint/snap.go @@ -47,6 +47,29 @@ func SnapChargeW(want, min, max float64, steps []float64) float64 { return best } +// floorChargeW treats want as a hard ceiling. No feasible step means pause. +func floorChargeW(want, min, max float64, steps []float64) float64 { + if math.IsNaN(want) || math.IsInf(want, 0) || want <= 0 { + return 0 + } + if max > 0 && want > max { + want = max + } + if want < min { + return 0 + } + if len(steps) == 0 { + return want + } + best := 0.0 + for _, step := range steps { + if step >= min && step <= want && step > best { + best = step + } + } + return best +} + // PhaseFor returns the phase count chosen for wantW given the mode // and split threshold (W). "auto" below split → 1Φ, above → 3Φ. // Unknown modes fall back to 3Φ for safety (the pre-switching From 7a7024beeaa8083c925b4dcecb2a0ea979683e30 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Sun, 6 Sep 2026 13:39:46 +0200 Subject: [PATCH 39/57] fix(ev): reject manual requests across unverified session resets --- .changeset/ev-unverified-session-reset.md | 5 +++++ .../loadpoint/controller_hold_restore.go | 8 ++++---- .../loadpoint/manual_hold_state_test.go | 18 ++++++++++++++++++ 3 files changed, 27 insertions(+), 4 deletions(-) create mode 100644 .changeset/ev-unverified-session-reset.md diff --git a/.changeset/ev-unverified-session-reset.md b/.changeset/ev-unverified-session-reset.md new file mode 100644 index 00000000..637ff4b8 --- /dev/null +++ b/.changeset/ev-unverified-session-reset.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +Pause a prior manual request when a session counter resets, including when the previous session had no verified ID. Keep an explicit Start when the same uninterrupted session gains its first verified ID. diff --git a/go/internal/loadpoint/controller_hold_restore.go b/go/internal/loadpoint/controller_hold_restore.go index 700b3217..562e1154 100644 --- a/go/internal/loadpoint/controller_hold_restore.go +++ b/go/internal/loadpoint/controller_hold_restore.go @@ -52,10 +52,10 @@ func (c *Controller) restoreManualHoldForSession(id string) { changed := bound && previous.configuration != current.configuration if bound && previous.deviceID != "" && current.deviceID != "" { changed = changed || previous.deviceID != current.deviceID - // An explicit Start from a paused/unconfirmed session may acquire its - // first session ID as charging starts. It already names this hardware. - firstSessionProof := previous.sessionID == "" && current.sessionID != "" - changed = changed || (!firstSessionProof && previous.generation != current.generation) + // ObserveSession preserves the generation for a valid first proof. + // A changed generation therefore means a new connection or a counter + // reset, even when the earlier session ID was still unknown. + changed = changed || previous.generation != current.generation } // A Start before the first hardware reading binds here. Once bound, a // different hardware/session/config generation must not inherit its power. diff --git a/go/internal/loadpoint/manual_hold_state_test.go b/go/internal/loadpoint/manual_hold_state_test.go index f8cc329e..cc63a20d 100644 --- a/go/internal/loadpoint/manual_hold_state_test.go +++ b/go/internal/loadpoint/manual_hold_state_test.go @@ -210,6 +210,24 @@ func TestExplicitStartCanAcquireFirstSessionProof(t *testing.T) { } } +func TestFirstSessionIDAfterCounterResetCannotInheritManualStart(t *testing.T) { + store := &sessionMemory{data: map[string]string{}} + m := sessionManager(store, "garage", "charger") + m.ObserveSession("garage", true, 0, 11000, true, "easee:A", "") + c := NewController(m, nil, nil, nil) + c.SetManualHold("garage", ManualHold{PowerW: 4140, Persistent: true}) + // The old session was paused and unverified. A fresh charging session + // appears with less energy, after an unplug the box did not observe. + m.ObserveSession("garage", true, 4300, 500, true, "easee:A", "session-2") + c.restoreManualHoldForSession("garage") + if h, ok := c.GetManualHold("garage", time.Now()); !ok || h.PowerW != 0 { + t.Fatalf("new car inherited the previous Start: %+v %v", h, ok) + } + if s, _ := m.State("garage"); !s.ManualRestoreUnconfirmed { + t.Fatal("changed session did not ask for confirmation") + } +} + func TestClearBeforeTelemetrySurvivesAnotherImmediateRestart(t *testing.T) { store := &sessionMemory{data: map[string]string{}} m := sessionManager(store, "garage", "charger") From 0fe4c9b4f60387b10b186ff97e1c632d17bbd668 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Sun, 6 Sep 2026 13:43:17 +0200 Subject: [PATCH 40/57] fix(ev): acknowledge only the current manual choice Signed-off-by: Fredrik Ahlgren --- .changeset/ev-command-acknowledgement.md | 5 +++ go/internal/loadpoint/controller.go | 4 ++- go/internal/loadpoint/loadpoint.go | 12 ++++++- .../loadpoint/manual_pause_status_test.go | 35 +++++++++++++++++-- go/internal/loadpoint/manual_status.go | 7 +++- 5 files changed, 58 insertions(+), 5 deletions(-) create mode 100644 .changeset/ev-command-acknowledgement.md diff --git a/.changeset/ev-command-acknowledgement.md b/.changeset/ev-command-acknowledgement.md new file mode 100644 index 00000000..9395a754 --- /dev/null +++ b/.changeset/ev-command-acknowledgement.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +Keep a changed charging request pending until the controller processes that choice and receives a fresh charger reading. An earlier command cannot confirm a new current or pause. diff --git a/go/internal/loadpoint/controller.go b/go/internal/loadpoint/controller.go index 84fd4dbd..277a165c 100644 --- a/go/internal/loadpoint/controller.go +++ b/go/internal/loadpoint/controller.go @@ -1621,8 +1621,10 @@ func (c *Controller) tickOne(ctx context.Context, now time.Time, lpCfg Config, d // clamp that overrides the value overrides the reason with it. Fed // to Manager.SetCommanded after the last clamp has spoken. cmdReason := "" + var manualCommandUpdatedAt time.Time if hold, ok := c.GetManualHold(lpCfg.ID, now); ok { cmdReason = "manual_hold" + manualCommandUpdatedAt = hold.UpdatedAt // Manual override active — skip MPC translation. The hold's // non-zero fields override the loadpoint config + site fuse; // zero/empty fields fall through to the normal defaults so a @@ -1865,7 +1867,7 @@ func (c *Controller) tickOne(ctx context.Context, now time.Time, lpCfg Config, d // plan slot, Stop hold, surplus clamp — from ever reading as a // charge that failed. if w, ok := cmd["power_w"].(float64); ok { - c.manager.SetCommanded(lpCfg.ID, w, cmdReason) + c.manager.setCommandedForManual(lpCfg.ID, w, cmdReason, manualCommandUpdatedAt) } payload, err := json.Marshal(cmd) if err != nil { diff --git a/go/internal/loadpoint/loadpoint.go b/go/internal/loadpoint/loadpoint.go index db62a0f7..4b90b584 100644 --- a/go/internal/loadpoint/loadpoint.go +++ b/go/internal/loadpoint/loadpoint.go @@ -210,6 +210,8 @@ type State struct { // CommandedSinceMs is when the current order was first given; it moves // when CommandedW or CommandedReason changes. Zero until the first tick. CommandedSinceMs int64 `json:"commanded_since_ms,omitempty"` + // Internal identity of the manual choice used to compute the order. + ManualCommandUpdatedAt time.Time `json:"-"` // CommandedReason names the dispatch branch that decided CommandedW: // "plan", "no_plan_budget", "pv_surplus", "pv_surplus_pause", @@ -417,7 +419,8 @@ type loadpointRuntime struct { commandedReason string // commandedSince is when the current (commandedW, commandedReason) pair // was first ordered. The manual status counts elapsed time from it. - commandedSince time.Time + commandedSince time.Time + manualCommandUpdatedAt time.Time // The interruption hysteresis state. chargingSteadySince anchors the // current continuous above-floor run; steadyRunArmed latches once that @@ -472,12 +475,17 @@ func (m *Manager) SetCommandedW(id string, w float64) { // "wake_kick". Empty keeps whatever was recorded before (used by the // legacy SetCommandedW wrapper). No-op for an unknown id. func (m *Manager) SetCommanded(id string, w float64, reason string) { + m.setCommandedForManual(id, w, reason, time.Time{}) +} + +func (m *Manager) setCommandedForManual(id string, w float64, reason string, updatedAt time.Time) { m.mu.Lock() defer m.mu.Unlock() if lp, ok := m.byID[id]; ok { changed := !lp.commandedKnown || lp.commandedW != w || (reason != "" && reason != lp.commandedReason) lp.commandedW = w + lp.manualCommandUpdatedAt = updatedAt lp.commandedKnown = true if reason != "" { lp.commandedReason = reason @@ -554,6 +562,7 @@ func (m *Manager) Load(cfgs []Config) { lp.commandedReason = existing.commandedReason lp.commandedKnown = existing.commandedKnown lp.commandedSince = existing.commandedSince + lp.manualCommandUpdatedAt = existing.manualCommandUpdatedAt lp.chargingSteadySince = existing.chargingSteadySince lp.stoppedSince = existing.stoppedSince lp.steadyRunArmed = existing.steadyRunArmed @@ -1125,6 +1134,7 @@ func (lp *loadpointRuntime) snapshot() State { if st.PluggedIn && st.SoCSource == "" && !lp.socConfirmed { st.SoCSource = "assumed" } + st.ManualCommandUpdatedAt = lp.manualCommandUpdatedAt if !lp.commandedSince.IsZero() { st.CommandedSinceMs = lp.commandedSince.UnixMilli() } diff --git a/go/internal/loadpoint/manual_pause_status_test.go b/go/internal/loadpoint/manual_pause_status_test.go index 0c7bc172..c6c0f6f5 100644 --- a/go/internal/loadpoint/manual_pause_status_test.go +++ b/go/internal/loadpoint/manual_pause_status_test.go @@ -10,7 +10,7 @@ import ( func TestPauseNeedsFreshStoppedCharger(t *testing.T) { now := time.Now() h := ManualHold{PowerW: 0, Persistent: true, StartedAt: now} - st := State{Phases: 3, VoltageV: 230, CommandedKnown: true, CommandedW: 0, CommandedReason: "manual_hold", CommandedSinceMs: now.UnixMilli()} + st := State{Phases: 3, VoltageV: 230, CommandedKnown: true, CommandedW: 0, CommandedReason: "manual_hold", CommandedSinceMs: now.UnixMilli(), ManualCommandUpdatedAt: now} for _, tc := range []struct { name string reading ChargerReading @@ -40,7 +40,7 @@ func TestPauseNeedsFreshStoppedCharger(t *testing.T) { func TestLowerCurrentWaitsForChargerEvenWhilePowerFlows(t *testing.T) { now := time.Now() h := ManualHold{PowerW: 4140, Persistent: true, StartedAt: now.Add(-time.Hour), UpdatedAt: now} - st := State{Phases: 3, VoltageV: 230, CurrentPowerW: 11000, CommandedKnown: true, CommandedW: 4140, CommandedReason: "manual_hold", CommandedSinceMs: now.UnixMilli()} + st := State{Phases: 3, VoltageV: 230, CurrentPowerW: 11000, CommandedKnown: true, CommandedW: 4140, CommandedReason: "manual_hold", CommandedSinceMs: now.UnixMilli(), ManualCommandUpdatedAt: now} ch := ChargerReading{Known: true, Charging: true, LimitKnown: true, LimitA: 16, UpdatedAt: now} if got := ManualStatusFrom(h, true, st, ch, now.Add(5*time.Second)); got.State != ManualSent { t.Fatalf("old11kW falsely confirmed6A: %+v", got) @@ -54,3 +54,34 @@ func TestLowerCurrentWaitsForChargerEvenWhilePowerFlows(t *testing.T) { t.Fatalf("confirmed reduction not shown: %+v", got) } } + +func TestFreshOldCommandCannotConfirmANewManualChoice(t *testing.T) { + now := time.Now() + h := ManualHold{PowerW: 4140, Persistent: true, StartedAt: now.Add(-time.Hour), UpdatedAt: now} + st := State{Phases: 3, VoltageV: 230, CurrentPowerW: 11000, CommandedKnown: true, CommandedW: 11000, CommandedReason: "manual_hold", ManualCommandUpdatedAt: now.Add(-time.Minute)} + ch := ChargerReading{Known: true, Charging: true, LimitKnown: true, LimitA: 16, UpdatedAt: now.Add(time.Second)} + if got := ManualStatusFrom(h, true, st, ch, now.Add(2*time.Second)); got.State != ManualSent { + t.Fatalf("old command acknowledged new choice: %+v", got) + } + // Even an unchanged clamped order must be computed for the new choice. + st.CommandedReason = "fuse_limit" + st.CommandedW = 4140 + ch.LimitA = 6 + if got := ManualStatusFrom(h, true, st, ch, now.Add(2*time.Second)); got.State != ManualSent { + t.Fatalf("old clamp acknowledged new choice: %+v", got) + } + st.ManualCommandUpdatedAt = h.UpdatedAt + if got := ManualStatusFrom(h, true, st, ch, now.Add(2*time.Second)); got.State != ManualCharging || got.LimitReason != "fuse_limit" { + t.Fatalf("new command not acknowledged: %+v", got) + } + h.PowerW = 0 + st.CommandedW = 0 + st.CurrentPowerW = 0 + st.CommandedReason = "manual_hold" + st.ManualCommandUpdatedAt = now.Add(-time.Minute) + ch.Charging = false + ch.LimitA = 0 + if got := ManualStatusFrom(h, true, st, ch, now.Add(2*time.Second)); got.State != ManualPausing { + t.Fatalf("old pause acknowledged new choice: %+v", got) + } +} diff --git a/go/internal/loadpoint/manual_status.go b/go/internal/loadpoint/manual_status.go index 11d3f3ab..19fe5553 100644 --- a/go/internal/loadpoint/manual_status.go +++ b/go/internal/loadpoint/manual_status.go @@ -169,12 +169,13 @@ func ManualStatusFrom(h ManualHold, held bool, st State, ch ChargerReading, now elapsed = now.Sub(since) } + commandMatches := h.UpdatedAt.IsZero() || st.ManualCommandUpdatedAt.Equal(h.UpdatedAt) if h.PowerW == 0 { m.State = ManualPausing switch { case ch.Unavailable: m.State = ManualUnavailable - case st.CommandedKnown && st.CommandedReason == "manual_hold" && st.CommandedW == 0 && + case commandMatches && st.CommandedKnown && st.CommandedReason == "manual_hold" && st.CommandedW == 0 && !ch.UpdatedAt.IsZero() && !ch.UpdatedAt.Before(since) && ch.Known && !ch.Charging && st.CurrentPowerW < manualChargingFloorW && (!ch.LimitKnown || ch.LimitA < 0.1): @@ -189,6 +190,10 @@ func ManualStatusFrom(h ManualHold, held bool, st State, ch ChargerReading, now switch { case ch.Unavailable: m.State = ManualUnavailable + case !commandMatches && elapsed >= manualConfirmTimeout: + m.State = ManualStalled + case !commandMatches: + m.State = ManualSent case ch.Known && ch.Stalled: m.State = ManualStalled case m.ChargerLimitKnown && !limitMatches && elapsed >= manualConfirmTimeout: From 2e730b799122aeae1f2051f3c95e6aee5796da4a Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Sun, 6 Sep 2026 13:42:58 +0200 Subject: [PATCH 41/57] fix(ev): preserve newer manual requests during automatic release --- .changeset/ev-release-current-request.md | 5 ++ go/internal/loadpoint/controller.go | 31 ++++++- .../controller_manual_release_race_test.go | 83 +++++++++++++++++++ 3 files changed, 115 insertions(+), 4 deletions(-) create mode 100644 .changeset/ev-release-current-request.md create mode 100644 go/internal/loadpoint/controller_manual_release_race_test.go diff --git a/.changeset/ev-release-current-request.md b/.changeset/ev-release-current-request.md new file mode 100644 index 00000000..5e679644 --- /dev/null +++ b/.changeset/ev-release-current-request.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +Apply an automatic charging stop only if the manual request has not changed since the controller checked it. Keep a newer Pause, Start or slider change, and give each explicit retry a fresh wait for the car to draw current. diff --git a/go/internal/loadpoint/controller.go b/go/internal/loadpoint/controller.go index 277a165c..60467f43 100644 --- a/go/internal/loadpoint/controller.go +++ b/go/internal/loadpoint/controller.go @@ -1336,6 +1336,7 @@ func (c *Controller) SetManualHold(id string, h ManualHold) { } saver := c.manualHoldSaver c.holdMu.Unlock() + c.resetManualIdle(id) // Persist outside the lock (saver may do disk I/O). Only persistent // operator holds survive a restart; clearing or a timed hold writes the // "cleared" sentinel so a stale persistent hold isn't resurrected. @@ -1356,6 +1357,30 @@ func (c *Controller) ClearManualHold(id string) { } c.manualPersistMu.Lock() defer c.manualPersistMu.Unlock() + c.clearManualHoldLocked(id) +} + +// releaseManualHoldIfCurrent applies a tick's decision only to the request +// it read. A newer Pause, Start or slider change keeps its own command. +func (c *Controller) releaseManualHoldIfCurrent(id string, expected ManualHold) bool { + if c == nil { + return false + } + c.manualPersistMu.Lock() + defer c.manualPersistMu.Unlock() + c.holdMu.Lock() + current, found := c.holds[id] + c.holdMu.Unlock() + if !found || current != expected { + return false + } + c.clearManualHoldLocked(id) + return true +} + +// clearManualHoldLocked requires manualPersistMu. Keep removal and its save +// ordered with explicit commands and session restoration. +func (c *Controller) clearManualHoldLocked(id string) { first := c.markManualExplicit(id) c.holdMu.Lock() _, existed := c.holds[id] @@ -1593,10 +1618,9 @@ func (c *Controller) tickOne(ctx context.Context, now time.Time, lpCfg Config, d // tick falls straight through to automatic (surplus/plan) dispatch. if hold, held := c.GetManualHold(lpCfg.ID, now); held && hold.PowerW > 0 { if !sample.RequestActive { - if c.manualHoldIdleFor(lpCfg.ID, now) >= SessionCompletionTimeout { + if c.manualHoldIdleFor(lpCfg.ID, now) >= SessionCompletionTimeout && c.releaseManualHoldIfCurrent(lpCfg.ID, hold) { slog.Info("loadpoint manual hold auto-released — vehicle stopped requesting current (full/declined)", "lp", lpCfg.ID, "idle", SessionCompletionTimeout) - c.ClearManualHold(lpCfg.ID) } } else { c.resetManualIdle(lpCfg.ID) @@ -1609,10 +1633,9 @@ func (c *Controller) tickOne(ctx context.Context, now time.Time, lpCfg Config, d // surplus/plan dispatch instead of holding the wallbox at a fixed // amperage the rest of the session. if hold, held := c.GetManualHold(lpCfg.ID, now); held && hold.ReleaseAtSoC > 0 { - if st, ok := c.manager.State(lpCfg.ID); ok && st.CurrentSoC >= hold.ReleaseAtSoC { + if st, ok := c.manager.State(lpCfg.ID); ok && st.CurrentSoC >= hold.ReleaseAtSoC && c.releaseManualHoldIfCurrent(lpCfg.ID, hold) { slog.Info("loadpoint manual hold released — charge-now target reached", "lp", lpCfg.ID, "soc", st.CurrentSoC, "release_at_soc", hold.ReleaseAtSoC) - c.ClearManualHold(lpCfg.ID) } } diff --git a/go/internal/loadpoint/controller_manual_release_race_test.go b/go/internal/loadpoint/controller_manual_release_race_test.go new file mode 100644 index 00000000..76cf41e6 --- /dev/null +++ b/go/internal/loadpoint/controller_manual_release_race_test.go @@ -0,0 +1,83 @@ +package loadpoint + +import ( + "context" + "testing" + "time" +) + +func TestStaleAutoReleaseCannotDeleteNewPauseStartOrRetry(t *testing.T) { + for _, action := range []string{"pause", "start", "same_request_retry"} { + t.Run(action, func(t *testing.T) { + m := NewManager() + m.Load([]Config{{ID: "garage", DriverName: "charger"}}) + c := NewController(m, nil, nil, nil) + c.SetManualHold("garage", ManualHold{PowerW: 4140, Persistent: true}) + old, _ := c.GetManualHold("garage", time.Now()) + next := old // The UI preserves StartedAt when editing the request. + switch action { + case "pause": + next.PowerW = 0 + case "start": + next.PowerW = 5520 + } + entered, finishSave := make(chan struct{}), make(chan struct{}) + setDone, releaseDone := make(chan struct{}), make(chan bool, 1) + clears := 0 + c.SetManualHoldSaver(func(_ string, _ ManualHold, cleared bool) { + if cleared { + clears++ + return + } + close(entered) + <-finishSave + }) + go func() { c.SetManualHold("garage", next); close(setDone) }() + <-entered + // A tick decided to release the old request just before this + // operator change. It waits behind the new request's disk write. + go func() { releaseDone <- c.releaseManualHoldIfCurrent("garage", old) }() + select { + case <-releaseDone: + t.Fatal("auto-release overtook the explicit save") + case <-time.After(20 * time.Millisecond): + } + close(finishSave) + <-setDone + if released := <-releaseDone; released { + t.Fatal("stale tick removed the newer request") + } + current, ok := c.GetManualHold("garage", time.Now()) + if !ok || current.PowerW != next.PowerW || current.UpdatedAt == old.UpdatedAt || clears != 0 { + t.Fatalf("new choice lost: current=%+v active=%v clears=%d", current, ok, clears) + } + if !c.releaseManualHoldIfCurrent("garage", current) || clears != 1 { + t.Fatal("a current release decision must still clear and save") + } + }) + } +} + +func TestExplicitRetryGetsAFullNewIdleTimeout(t *testing.T) { + now := time.Now() + cfg := chargeNowLoadpoint() + sender := &fakeSender{} + samples := map[string]EVSample{cfg.DriverName: {Connected: true, RequestActive: false}} + c := newTestController(t, []Config{cfg}, nil, samples, sender) + c.SetManualHold(cfg.ID, ManualHold{PowerW: 4140, Persistent: true}) + c.Tick(context.Background(), now) + c.Tick(context.Background(), now.Add(SessionCompletionTimeout-time.Second)) + old, ok := c.GetManualHold(cfg.ID, now) + if !ok { + t.Fatal("old request released before its timeout") + } + c.SetManualHold(cfg.ID, old) + c.Tick(context.Background(), now.Add(SessionCompletionTimeout+time.Second)) + if _, ok := c.GetManualHold(cfg.ID, now); !ok { + t.Fatal("retry inherited the old request's nearly expired idle timer") + } + c.Tick(context.Background(), now.Add(2*SessionCompletionTimeout+2*time.Second)) + if _, ok := c.GetManualHold(cfg.ID, now); ok { + t.Fatal("retry never released after its own full idle timeout") + } +} From 040ad254a5d497184e3c6ada52973b923e49e57d Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Sun, 6 Sep 2026 13:44:15 +0200 Subject: [PATCH 42/57] docs(ev): describe manual request timestamps Signed-off-by: Fredrik Ahlgren --- go/internal/loadpoint/controller.go | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/go/internal/loadpoint/controller.go b/go/internal/loadpoint/controller.go index 60467f43..64610056 100644 --- a/go/internal/loadpoint/controller.go +++ b/go/internal/loadpoint/controller.go @@ -410,11 +410,10 @@ type ManualHold struct { // release target. ReleaseAtSoC float64 - // StartedAt is when the operator installed the hold. The API keeps it - UpdatedAt time.Time `json:"updated_at,omitempty"` - // across an Update of the amps, so the manual tab can say how long the - // charge has been asked for. SetManualHold fills a zero value. + // StartedAt remains the first request time when the current changes. StartedAt time.Time + // UpdatedAt identifies the latest choice, including a current change. + UpdatedAt time.Time `json:"updated_at,omitempty"` } // Directive is the loadpoint-relevant slice of mpc.SlotDirective. From 42b347e2a22e2eb42fe550589de240806e5ad082 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Sun, 6 Sep 2026 13:45:54 +0200 Subject: [PATCH 43/57] fix(ev): retain the reason when site telemetry pauses charging Signed-off-by: Fredrik Ahlgren --- go/internal/api/api_loadpoint_manual_test.go | 12 +++++++++++- go/internal/loadpoint/controller.go | 6 +++++- .../loadpoint/controller_commanded_reason_test.go | 7 +++++++ 3 files changed, 23 insertions(+), 2 deletions(-) diff --git a/go/internal/api/api_loadpoint_manual_test.go b/go/internal/api/api_loadpoint_manual_test.go index 5fd23a10..d07fa002 100644 --- a/go/internal/api/api_loadpoint_manual_test.go +++ b/go/internal/api/api_loadpoint_manual_test.go @@ -1,6 +1,7 @@ package api import ( + "context" "encoding/json" "net/http" "net/http/httptest" @@ -238,7 +239,9 @@ func TestManualHoldRefusesReleaseTargetAlreadyMet(t *testing.T) { func TestLoadpointsCarryManualStatus(t *testing.T) { mgr := loadpoint.NewManager() mgr.Load([]loadpoint.Config{{ID: "garage", DriverName: "easee", MinChargeW: 1380, MaxChargeW: 11000}}) - ctrl := loadpoint.NewController(mgr, nil, nil, nil) + ctrl := loadpoint.NewController(mgr, func(time.Time) (loadpoint.Directive, bool) { return loadpoint.Directive{}, false }, func(string) (loadpoint.EVSample, bool) { + return loadpoint.EVSample{Connected: true, RequestActive: true}, true + }, nil) tel := telemetry.NewStore() srv := New(&Deps{Loadpoints: mgr, LoadpointCtrl: ctrl, Tel: tel}) @@ -292,6 +295,10 @@ func TestLoadpointsCarryManualStatus(t *testing.T) { // The Easee echoes the limit: accepted, waiting for the car. tel.Update("easee", telemetry.DerEV, 0, nil, json.RawMessage(`{"max_a":6,"charging":false,"reason_no_current_label":"car not drawing current"}`)) + if m = manual(); m.State != loadpoint.ManualSent { + t.Fatalf("charger echo cannot confirm an unprocessed request: %+v", m) + } + ctrl.Tick(context.Background(), time.Now()) m = manual() if m.State != loadpoint.ManualAccepted || !m.ChargerLimitKnown || m.ChargerLimitA != 6 || m.ChargerReason != "car not drawing current" { t.Fatalf("after the charger took the limit: %+v", m) @@ -303,6 +310,9 @@ func TestLoadpointsCarryManualStatus(t *testing.T) { t.Fatalf("after Update: %+v (first start %d)", m, first) } + // The controller processes the new choice before its charger response. + ctrl.Tick(context.Background(), time.Now()) + // The charger says the command stalled. tel.Update("easee", telemetry.DerEV, 0, nil, json.RawMessage(`{"max_a":16,"charging":false,"reason_no_current_label":"EV not accepting current","command_stalled":true}`)) if m = manual(); m.State != loadpoint.ManualStalled || m.ChargerReason != "EV not accepting current" { diff --git a/go/internal/loadpoint/controller.go b/go/internal/loadpoint/controller.go index 64610056..123d6f5b 100644 --- a/go/internal/loadpoint/controller.go +++ b/go/internal/loadpoint/controller.go @@ -1581,7 +1581,11 @@ func (c *Controller) tickOne(ctx context.Context, now time.Time, lpCfg Config, d // being handled is the meter's. // The standdown is still the box ordering zero; record it so the // interruption latch knows this stop is ours. - c.manager.SetCommanded(lpCfg.ID, 0, "site_meter_stale") + var manualUpdatedAt time.Time + if hold, held := c.GetManualHold(lpCfg.ID, now); held { + manualUpdatedAt = hold.UpdatedAt + } + c.manager.setCommandedForManual(lpCfg.ID, 0, "site_meter_stale", manualUpdatedAt) payload, err := json.Marshal(map[string]any{ "action": "ev_set_current", "power_w": 0, diff --git a/go/internal/loadpoint/controller_commanded_reason_test.go b/go/internal/loadpoint/controller_commanded_reason_test.go index c2ec3d97..5306f0ea 100644 --- a/go/internal/loadpoint/controller_commanded_reason_test.go +++ b/go/internal/loadpoint/controller_commanded_reason_test.go @@ -127,7 +127,14 @@ func TestCommandedReasonSiteMeterStale(t *testing.T) { samples := map[string]EVSample{cfg.DriverName: {Connected: true, PowerW: 0, RequestActive: true}} c := newTestController(t, []Config{cfg}, nil, samples, sender) + c.SetManualHold(cfg.ID, ManualHold{PowerW: 4140, Persistent: true}) c.TickWithDispatch(context.Background(), base, false) + h, _ := c.GetManualHold(cfg.ID, base) + st, _ := c.manager.State(cfg.ID) + got := ManualStatusFrom(h, true, st, ChargerReading{Known: true, UpdatedAt: time.Now()}, time.Now()) + if got.State != ManualLimited || got.LimitReason != "site_meter_stale" { + t.Fatalf("manual standdown lost its known reason: %+v", got) + } if w, r := commandedReason(t, c, cfg.ID); w != 0 || r != "site_meter_stale" { t.Errorf("standdown: want (0, site_meter_stale), got (%.0f, %q)", w, r) From 7e6dc2e024a5fc5a60b8b184189352ec0b993d35 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Sun, 6 Sep 2026 13:52:03 +0200 Subject: [PATCH 44/57] fix(ev): guide the first charger connection and clarify saves --- .changeset/ev-first-connection-path.md | 5 ++ web/settings-shell.test.mjs | 20 ++++++++ web/settings.js | 30 ++++++++---- web/settings/tabs/devices.js | 64 ++++++++++++++++++++++++-- web/settings/tabs/loadpoints.js | 44 ++++++++++++++---- web/settings/tabs/loadpoints.test.mjs | 24 ++++++++++ 6 files changed, 164 insertions(+), 23 deletions(-) create mode 100644 .changeset/ev-first-connection-path.md diff --git a/.changeset/ev-first-connection-path.md b/.changeset/ev-first-connection-path.md new file mode 100644 index 00000000..9b86f82a --- /dev/null +++ b/.changeset/ev-first-connection-path.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +Guide the first charger connection from Chargers into the charger catalog and back after saving the connection. Hide the unrelated global Save button for charger autosave; keep explicit saves beside OCPP and shared-car settings. diff --git a/web/settings-shell.test.mjs b/web/settings-shell.test.mjs index 4b41ccca..1a4b8c6e 100644 --- a/web/settings-shell.test.mjs +++ b/web/settings-shell.test.mjs @@ -25,6 +25,7 @@ function stubElement() { className: "", innerHTML: "", dataset: {}, + style: {}, handlers: {}, classList: { add() {}, remove() {}, toggle() {} }, addEventListener(type, fn) { this.handlers[type] = fn; }, @@ -99,3 +100,22 @@ describe("the settings shell after a save", () => { assert.equal(called, 0, "a rejected save told the tab it landed"); }); }); + + +describe("charger setup navigation and saves", () => { + it("keeps the selected tab, hides global Save only there and still exposes explicit saves", async () => { + const { elements, requests, tabs } = loadShell({ drivers: [], loadpoints: [] }); + let context; + tabs.control = { render: ctx => { context = ctx; return ''; } }; + tabs.loadpoints = { render: ctx => { context = ctx; return ''; } }; + elements['settings-btn'].handlers.click(); + await settled(); + context.navigateTab('loadpoints'); + assert.equal(elements['settings-save'].hidden, true); + assert.equal(elements['settings-save'].style.display, 'none'); + await context.saveConfig(); + assert.equal(requests.filter(r => r.opts?.method === 'POST').length, 1); + context.navigateTab('control'); + assert.equal(elements['settings-save'].hidden, false); + }); +}); diff --git a/web/settings.js b/web/settings.js index cce54823..3b79ed7b 100644 --- a/web/settings.js +++ b/web/settings.js @@ -67,19 +67,25 @@ tabsEl.addEventListener("click", function (e) { if (e.target.tagName === "BUTTON" && e.target.dataset.tab) { - tabsEl.querySelectorAll("button").forEach(function (b) { - b.classList.toggle("active", b === e.target); - }); - captureCurrentTab(); - currentTab = e.target.dataset.tab; - renderTab(currentTab); + navigateTab(e.target.dataset.tab); } }); - saveBtn.addEventListener("click", function () { + function navigateTab(tab) { + captureCurrentTab(); + currentTab = tab; + tabsEl.querySelectorAll("button").forEach(function (button) { + button.classList.toggle("active", button.dataset.tab === tab); + }); + renderTab(tab); + } + + saveBtn.addEventListener("click", function () { saveSettings().catch(function () {}); }); + + function saveSettings() { captureCurrentTab(); setStatus("Saving..."); - apiFetch("/api/config", { + return apiFetch("/api/config", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(currentConfig), @@ -106,11 +112,13 @@ if (res && res.restart_required) { showRestartModal(res.restart_reasons || []); } + return res; }) .catch(function (e) { setStatus("Save failed: " + e.message, "error"); + throw e; }); - }); + } // ---- Restart-required modal ---- @@ -269,6 +277,8 @@ } function renderTab(tab) { + saveBtn.hidden = tab === "loadpoints" || (tab === "devices" && !!S.chargerSetup); + saveBtn.style.display = saveBtn.hidden ? "none" : ""; var def = S.tabs[tab]; if (!def) { bodyEl.innerHTML = '

Unknown tab: ' + escHtml(tab) + '

'; @@ -285,6 +295,8 @@ setByPath: setByPath, captureCurrentTab: captureCurrentTab, renderTab: renderTab, + navigateTab: navigateTab, + saveConfig: saveSettings, apiFetch: apiFetch, }; var html = ""; diff --git a/web/settings/tabs/devices.js b/web/settings/tabs/devices.js index 7f3cc8a7..7d96a051 100644 --- a/web/settings/tabs/devices.js +++ b/web/settings/tabs/devices.js @@ -919,7 +919,11 @@ render: function (ctx) { var help = ctx.help, escHtml = ctx.escHtml, config = ctx.config; if (!config.drivers) config.drivers = []; - var html = '
Add from catalog' + + var html = S.chargerSetup ? '

Connect your charger

' + + '

Choose your charger below and enter its connection details. Continue to charging when the connection is ready.

' + + '' + + '

' : ''; + html += '
' + (S.chargerSetup ? 'Choose your charger' : 'Add from catalog') + '' + '
' + '' + '' + @@ -1325,7 +1329,10 @@ if (!host || !picker) return; var query = (search && search.value || "").trim(); - var matches = searchCatalog(entries, query); + var choices = S.chargerSetup ? entries.filter(function (entry) { + return (entry.capabilities || []).indexOf('ev') >= 0; + }) : entries; + var matches = searchCatalog(choices, query); host.textContent = ""; if (matches.length === 0) { @@ -1355,7 +1362,7 @@ var tags = document.createElement("div"); tags.className = "drv-catalog-tags"; - (e.capabilities || []).forEach(function (cap) { + (S.chargerSetup ? [] : (e.capabilities || [])).forEach(function (cap) { var tag = document.createElement("span"); tag.className = "drv-catalog-tag"; tag.textContent = cap; @@ -1781,7 +1788,13 @@ } var finishAdd = function () { config.drivers.push(driver); + if (S.chargerSetup) S.chargerSetupPending = driver.name; ctx.renderTab("devices"); + if (S.chargerSetup) { + var connection = bodyEl.querySelector('[data-path="drivers.' + (config.drivers.length - 1) + '.config.email"]') || + bodyEl.querySelector('[data-path="drivers.' + (config.drivers.length - 1) + '.config.host"]'); + if (connection) { connection.scrollIntoView({ block: 'center' }); connection.focus(); } + } }; if (chosen.dataset.channel !== "beta") { finishAdd(); @@ -1801,6 +1814,49 @@ }); }); + var continueCharging = document.getElementById('charger-setup-continue'); + if (continueCharging) { + bodyEl.querySelectorAll('.device-meta,.driver-module-status,.device-core-row').forEach(function (element) { element.hidden = true; element.style.display = 'none'; }); + var channel = document.getElementById('driver-catalog-channel'); + if (channel) channel.parentElement.hidden = true; + var name = document.getElementById('driver-catalog-name'); + if (name) name.placeholder = 'e.g. garage'; + var picker = document.getElementById('driver-catalog-picker'); + if (picker) picker.closest('fieldset').querySelectorAll(':scope > p').forEach(function (paragraph) { paragraph.hidden = true; }); + var pendingIndex = (config.drivers || []).findIndex(function (device) { return device.name === S.chargerSetupPending; }); + var connectionInput = bodyEl.querySelector('[data-path="drivers.' + pendingIndex + '.config.email"]') || bodyEl.querySelector('[data-path="drivers.' + pendingIndex + '.config.host"]'); + var connectionBox = connectionInput && connectionInput.closest('fieldset'); + if (connectionBox) { + connectionBox.appendChild(continueCharging); + connectionBox.appendChild(document.getElementById('charger-setup-status')); + } + } + if (continueCharging) continueCharging.addEventListener('click', function () { + ctx.captureCurrentTab(); + var charger = (config.drivers || []).find(function (device) { return device.name === S.chargerSetupPending; }); + var status = document.getElementById('charger-setup-status'); + if (!charger) { status.textContent = 'Choose your charger below first.'; return; } + var connection = charger.config || {}; + if (Object.prototype.hasOwnProperty.call(connection, 'email') && (!connection.email || !connection.serial)) { + status.textContent = 'Connect your account and choose the charger first.'; + return; + } + if (Object.prototype.hasOwnProperty.call(connection, 'host') && !connection.host) { + status.textContent = 'Enter your charger’s address first.'; + return; + } + continueCharging.disabled = true; + status.textContent = 'Saving the charger connection…'; + ctx.saveConfig().then(function () { + S.chargerSetup = false; + S.chargerSetupPending = null; + ctx.navigateTab('loadpoints'); + }).catch(function (error) { + status.textContent = 'Connection not saved: ' + error.message + '. Try again.'; + continueCharging.disabled = false; + }); + }); + // Cloud-driver Connect buttons. bodyEl.querySelectorAll(".ev-connect-btn").forEach(function (connectBtn) { connectBtn.addEventListener("click", function () { @@ -1852,7 +1908,7 @@ if (d && d.config) d.config.serial = sel.value; if (config.ev_charger) config.ev_charger.serial = sel.value; }; - if (statusEl) statusEl.textContent = chargers.length + " charger(s) found"; + if (statusEl) statusEl.textContent = chargers.length + (S.chargerSetup ? " charger(s) found. Choose Continue to charging to save the connection." : " charger(s) found"); }).catch(function (e) { if (statusEl) statusEl.textContent = "Error: " + e.message; }).finally(function () { diff --git a/web/settings/tabs/loadpoints.js b/web/settings/tabs/loadpoints.js index 65a4c9f1..89e8b3ff 100644 --- a/web/settings/tabs/loadpoints.js +++ b/web/settings/tabs/loadpoints.js @@ -436,7 +436,9 @@ var help = ctx.help, escHtml = ctx.escHtml, config = ctx.config; if (!config.loadpoints) config.loadpoints = []; var ocppIds = ocppChargerIds(S.ocppStatus); - var drivers = evDriverNames(config, S.ocppStatus); + var drivers = evDriverNames(config, S.ocppStatus).filter(function (name) { + return name !== S.chargerSetupPending; + }); var html = '

' + @@ -451,10 +453,12 @@ if (!drivers.length) { html += - '

' + - '⚠ No EV-capable driver configured and no OCPP charger connected. Add a driver under Devices ' + - '(e.g. drivers/ctek_hybrid.lua), or point an OCPP charger at the URL above.' + - '
'; + '
' + + '

Connect a charger

' + + '

Choose your charger and enter its connection details. Then check its power limit and your car’s battery size here.

' + + '' + + '

If your charger connects directly using OCPP, open OCPP connection settings below.

' + + '
'; } html += '
'; @@ -487,7 +491,7 @@ '
' + '' + '' + '
' + @@ -529,14 +533,14 @@ }); html += '
'; - html += + if (drivers.length) html += '
Add charger' + '
' + '' + '
' + '' + '