From 3ee1921b31695e12632bf94708fac56645ec6cbf Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Sun, 27 Sep 2026 07:43:41 +0200 Subject: [PATCH] fix(loadpoint): let a battery boost run together with Charge now A manual hold sets how much the car draws; a boost only lets the home battery cover the loadpoint's live draw above a reserve. The two answer different questions, and dispatch already counts the boost from the charger's measured power, whoever set it. Yet the boost preflight refused a loadpoint with a manual hold, the tick ended a running boost as soon as one started, and the EV modal disabled Boost while Charge now ran. An owner topping up before a trip could not have the battery cover it. Remove the manual-hold refusal and stop, the operator_hold stop reason and its labels, and the modal's "Stop the manual charge first" block. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MuerPFZFG88kgu8sWVHeq7 --- .changeset/boost-with-charge-now.md | 5 +++ go/internal/loadpoint/battery_boost.go | 11 ++--- go/internal/loadpoint/battery_boost_test.go | 49 +++++++++++++++------ web/app.js | 2 - web/loadpoint-battery-boost.test.mjs | 5 ++- web/loadpoints.js | 1 - 6 files changed, 47 insertions(+), 26 deletions(-) create mode 100644 .changeset/boost-with-charge-now.md diff --git a/.changeset/boost-with-charge-now.md b/.changeset/boost-with-charge-now.md new file mode 100644 index 000000000..42dccd08a --- /dev/null +++ b/.changeset/boost-with-charge-now.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +Boost from home battery now works together with Charge now. Charge now sets how much the car draws; a boost lets the home battery cover that draw above the reserve you choose, for the time you choose. Before, the boost button was disabled while Charge now ran, and starting Charge now ended a running boost, so a quick top-up before leaving always came from the grid. Fuse, battery and charger limits apply as before. diff --git a/go/internal/loadpoint/battery_boost.go b/go/internal/loadpoint/battery_boost.go index a9d6c68cc..266dc9b4e 100644 --- a/go/internal/loadpoint/battery_boost.go +++ b/go/internal/loadpoint/battery_boost.go @@ -24,7 +24,6 @@ const ( BatteryBoostStoppedVehicleUnplugged BatteryBoostStopReason = "vehicle_unplugged" BatteryBoostStoppedEVTargetReached BatteryBoostStopReason = "ev_target_reached" BatteryBoostStoppedDepartureReached BatteryBoostStopReason = "departure_reached" - BatteryBoostStoppedOperatorHold BatteryBoostStopReason = "operator_hold" BatteryBoostStoppedSurplusOnly BatteryBoostStopReason = "surplus_only" BatteryBoostStoppedSiteSafety BatteryBoostStopReason = "site_safety_block" BatteryBoostStoppedLoadpointDriver BatteryBoostStopReason = "loadpoint_driver_unavailable" @@ -216,9 +215,9 @@ func (c *Controller) batteryBoostLivePreflight(id string, lease BatteryBoostLeas if st.SurplusOnly { return BatteryBoostStoppedSurplusOnly, errors.New("surplus_only is an operator clamp") } - if _, held := c.GetManualHold(id, now); held { - return BatteryBoostStoppedOperatorHold, errors.New("loadpoint operator hold is active") - } + // A manual hold sets how much the car draws; a boost only lets the home + // battery cover that draw. They answer different questions, so Charge + // now and a boost run together. c.batteryBoostMu.Lock() safety := c.batteryBoostSafety @@ -348,10 +347,6 @@ func (c *Controller) evaluateBatteryBoost(id string, now time.Time, connected, d c.stopBatteryBoost(id, BatteryBoostStoppedSiteSafety, now) return } - if _, held := c.GetManualHold(id, now); held { - c.stopBatteryBoost(id, BatteryBoostStoppedOperatorHold, now) - return - } if st, ok := c.manager.State(id); ok { if st.SurplusOnly { c.stopBatteryBoost(id, BatteryBoostStoppedSurplusOnly, now) diff --git a/go/internal/loadpoint/battery_boost_test.go b/go/internal/loadpoint/battery_boost_test.go index 2f993400e..f28d886c9 100644 --- a/go/internal/loadpoint/battery_boost_test.go +++ b/go/internal/loadpoint/battery_boost_test.go @@ -76,21 +76,16 @@ func TestBatteryBoostValidationAndOperatorClamps(t *testing.T) { name string lease BatteryBoostLease surplusOnly bool - hold bool }{ - {"short duration", BatteryBoostLease{StartedAt: now, ExpiresAt: now.Add(30 * time.Second), MinBatterySoC: 0.3}, false, false}, - {"long duration", BatteryBoostLease{StartedAt: now, ExpiresAt: now.Add(MaxBatteryBoostDuration + time.Second), MinBatterySoC: 0.3}, false, false}, - {"low reserve", BatteryBoostLease{StartedAt: now, ExpiresAt: now.Add(time.Hour), MinBatterySoC: 0.04}, false, false}, - {"departure after expiry", BatteryBoostLease{StartedAt: now, ExpiresAt: now.Add(time.Hour), DepartureAt: now.Add(2 * time.Hour), MinBatterySoC: 0.3}, false, false}, - {"surplus only", validBatteryBoost(now), true, false}, - {"manual hold", validBatteryBoost(now), false, true}, + {"short duration", BatteryBoostLease{StartedAt: now, ExpiresAt: now.Add(30 * time.Second), MinBatterySoC: 0.3}, false}, + {"long duration", BatteryBoostLease{StartedAt: now, ExpiresAt: now.Add(MaxBatteryBoostDuration + time.Second), MinBatterySoC: 0.3}, false}, + {"low reserve", BatteryBoostLease{StartedAt: now, ExpiresAt: now.Add(time.Hour), MinBatterySoC: 0.04}, false}, + {"departure after expiry", BatteryBoostLease{StartedAt: now, ExpiresAt: now.Add(time.Hour), DepartureAt: now.Add(2 * time.Hour), MinBatterySoC: 0.3}, false}, + {"surplus only", validBatteryBoost(now), true}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { ctrl, _, _ := newBatteryBoostController(t, tc.surplusOnly) - if tc.hold { - ctrl.SetManualHold("garage", ManualHold{Persistent: true, PowerW: 1380}) - } if _, err := ctrl.EnableBatteryBoost("garage", tc.lease, now); err == nil { t.Fatal("expected validation error") } @@ -98,6 +93,37 @@ func TestBatteryBoostValidationAndOperatorClamps(t *testing.T) { } } +// Charge now sets how much the car draws; a boost lets the home battery cover +// that draw. Starting either one keeps the other, and the boost counts what the +// held charge actually draws. +func TestBatteryBoostRunsWithChargeNow(t *testing.T) { + now := time.Now() + for _, order := range []string{"hold first", "boost first"} { + t.Run(order, func(t *testing.T) { + ctrl, mgr, _ := newBatteryBoostController(t, false) + hold := func() { ctrl.SetManualHold("garage", ManualHold{Persistent: true, PowerW: 11000}) } + if order == "hold first" { + hold() + } + if _, err := ctrl.EnableBatteryBoost("garage", validBatteryBoost(now), now); err != nil { + t.Fatalf("boost refused beside Charge now: %v", err) + } + if order == "boost first" { + hold() + } + ctrl.TickWithDispatch(context.Background(), now.Add(time.Minute), true) + if _, status := ctrl.BatteryBoost("garage", now.Add(time.Minute)); !status.Active { + t.Fatalf("Charge now ended the boost: %+v", status) + } + mgr.Observe("garage", true, 11000, 0, true) + st, _ := mgr.State("garage") + if w, reserve := ctrl.ActiveBatteryBoostTotals([]State{st}, now.Add(time.Minute)); w != 11000 || reserve != 0.3 { + t.Fatalf("boost covers %.0f W above %.2f, want the held 11000 W above 0.30", w, reserve) + } + }) + } +} + func TestBatteryBoostAutoStopsAndClearsPersistedLease(t *testing.T) { now := time.Now() tests := []struct { @@ -110,9 +136,6 @@ func TestBatteryBoostAutoStopsAndClearsPersistedLease(t *testing.T) { {"expiry", nil, now.Add(time.Hour), true, BatteryBoostStoppedExpired}, {"unplug", func(_ *Controller, s *EVSample) { s.Connected = false }, now.Add(time.Minute), true, BatteryBoostStoppedVehicleUnplugged}, {"site safety", nil, now.Add(time.Minute), false, BatteryBoostStoppedSiteSafety}, - {"operator hold", func(c *Controller, _ *EVSample) { - c.SetManualHold("garage", ManualHold{Persistent: true, PowerW: 1380}) - }, now.Add(time.Minute), true, BatteryBoostStoppedOperatorHold}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { diff --git a/web/app.js b/web/app.js index 95e0f2cb5..ee0b4e5ea 100644 --- a/web/app.js +++ b/web/app.js @@ -3203,7 +3203,6 @@ vehicle_unplugged: "car unplugged", ev_target_reached: "car target reached", departure_reached: "departure time reached", - operator_hold: "a manual charge took priority", surplus_only: "PV only took priority", site_safety_block: "site-meter safety stopped it", loadpoint_driver_unavailable: "charger driver unavailable", @@ -3406,7 +3405,6 @@ } else { var why = ""; if (!lpNow.plugged_in) why = "Plug in the car first."; - else if (lpNow.manual_active) why = "Stop the manual charge first — it already takes what it needs."; else if (lpNow.surplus_only) why = "Turn off PV only first."; startBtn.disabled = !!why; startBtn.style.opacity = why ? "0.5" : "1"; diff --git a/web/loadpoint-battery-boost.test.mjs b/web/loadpoint-battery-boost.test.mjs index 3890cdfc3..67d4e3b36 100644 --- a/web/loadpoint-battery-boost.test.mjs +++ b/web/loadpoint-battery-boost.test.mjs @@ -23,7 +23,8 @@ test('the EV modal owns the battery boost lease', () => { assert.doesNotMatch(boostView, /departure/); // Start is refused for the same reasons the controller refuses it. assert.match(boostView, /Plug in the car first/); - assert.match(boostView, /Stop the manual charge first/); + // Charge now sets the car's draw; a boost only lets the battery cover it. + assert.doesNotMatch(boostView, /Stop the manual charge first/); assert.match(boostView, /Turn off PV only first/); // Active state and stop. assert.match(boostView, /method: "DELETE"/); @@ -33,7 +34,7 @@ test('the EV modal owns the battery boost lease', () => { test('every controller stop reason has words', () => { for (const reason of [ 'cancelled', 'expired', 'vehicle_unplugged', 'ev_target_reached', 'departure_reached', - 'operator_hold', 'surplus_only', 'site_safety_block', 'loadpoint_driver_unavailable', + 'surplus_only', 'site_safety_block', 'loadpoint_driver_unavailable', 'battery_unavailable', 'battery_reserve_reached', 'battery_hold', 'core_mode', 'fuse_safety_block', 'restart_lease_invalid', ]) { diff --git a/web/loadpoints.js b/web/loadpoints.js index 8917e6a9a..dcd40c84a 100644 --- a/web/loadpoints.js +++ b/web/loadpoints.js @@ -101,7 +101,6 @@ vehicle_unplugged: 'Vehicle unplugged', ev_target_reached: 'EV target reached', departure_reached: 'Departure time reached', - operator_hold: 'Charger hold took priority', surplus_only: 'Surplus-only policy took priority', site_safety_block: 'Site meter safety stopped boost', loadpoint_driver_unavailable: 'Charger driver unavailable',