From c08b011daa4efa23cc8b6366279fb6a40e4d0a66 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Fri, 4 Sep 2026 06:53:08 +0200 Subject: [PATCH] fix(ev): manual charge runs until the car is full MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Charge now → target" (#1007) released the hold the moment the SoC estimate reached the schedule target. On a charger that cannot read the car the estimate is a guess, and when it already sat at the target the button released itself on the next tick: five presses, no charge (box log 2026-09-04 06:15–06:18). The manual tab no longer sends a SoC release; Start runs until the car stops asking for current, Stop, or unplug, and the plan takes over after that. The API refuses a release_at_soc_pct the estimate already meets (409) instead of installing a hold that clears on the next tick. Refs #1002. Co-Authored-By: Claude Fable 5.1 --- .changeset/manual-charge-runs-until-full.md | 5 +++ go/internal/api/api_loadpoint_manual.go | 17 ++++++++ go/internal/api/api_loadpoint_manual_test.go | 42 ++++++++++++++++++++ web/app.js | 27 +++++++------ web/ev-charge-now.test.mjs | 31 ++++++++++----- web/ev-plan-status.test.mjs | 5 ++- 6 files changed, 102 insertions(+), 25 deletions(-) create mode 100644 .changeset/manual-charge-runs-until-full.md diff --git a/.changeset/manual-charge-runs-until-full.md b/.changeset/manual-charge-runs-until-full.md new file mode 100644 index 000000000..86ebf167e --- /dev/null +++ b/.changeset/manual-charge-runs-until-full.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +EV manual charge: "Charge now" runs until the car is full, Stop or unplug. It no longer stops at the schedule's target SoC. The SoC estimate is a guess on chargers that cannot read the car, and a Start that released itself the moment the guess sat at the target left the operator with no way to charge. The API also refuses a `release_at_soc_pct` the estimate already meets (409) instead of installing a hold that clears on the next tick. diff --git a/go/internal/api/api_loadpoint_manual.go b/go/internal/api/api_loadpoint_manual.go index 60385c5f8..a063300f4 100644 --- a/go/internal/api/api_loadpoint_manual.go +++ b/go/internal/api/api_loadpoint_manual.go @@ -1,6 +1,8 @@ package api import ( + "fmt" + "math" "net/http" "time" @@ -120,6 +122,21 @@ func (s *Server) handleLoadpointManualHold(w http.ResponseWriter, r *http.Reques }) return } + // A release target the estimate already meets would install a hold + // that the controller clears on its next tick — the charger never + // starts and the caller sees "active" for a few seconds. Refuse it + // up front and say why, so the caller can raise the target or omit + // it and charge until the car is full. + if req.ReleaseAtSoCPct > 0 { + if st, ok := s.deps.Loadpoints.State(id); ok && st.PluggedIn && + st.CurrentSoC*100 >= req.ReleaseAtSoCPct { + writeJSON(w, 409, map[string]string{ + "error": fmt.Sprintf("car is already at %d %% — raise release_at_soc_pct or omit it to charge until the car is full", + int(math.Round(st.CurrentSoC*100))), + }) + return + } + } // hold_s == 0 → persistent override (no time expiry); hold_s > 0 → // bounded diagnostic hold expiring at now+hold_s. diff --git a/go/internal/api/api_loadpoint_manual_test.go b/go/internal/api/api_loadpoint_manual_test.go index 9f7f53607..409fefc1a 100644 --- a/go/internal/api/api_loadpoint_manual_test.go +++ b/go/internal/api/api_loadpoint_manual_test.go @@ -188,3 +188,45 @@ func TestManualHoldDeleteClears(t *testing.T) { t.Errorf("hold still active after DELETE") } } + +// A "charge now → X %" hold whose target the SoC estimate already meets +// is refused, not installed-then-released: the operator who pressed +// Start five times against an estimate sitting on the schedule target +// got "active" for one tick each time and no charge. Omitting the +// target (or setting it above the estimate) still installs. +func TestManualHoldRefusesReleaseTargetAlreadyMet(t *testing.T) { + srv, ctrl := newManualHoldServer(t) + // Plugged in; the estimate re-anchored to 80 %. + srv.deps.Loadpoints.Observe("garage", true, 0, 0, true) + if !srv.deps.Loadpoints.SetCurrentSoC("garage", 0.8) { + t.Fatalf("SetCurrentSoC failed") + } + post := func(body string) *httptest.ResponseRecorder { + 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) + return rr + } + if rr := post(`{"power_w":11040,"hold_s":0,"release_at_soc_pct":80}`); rr.Code != http.StatusConflict { + t.Fatalf("target == estimate: status = %d, want 409 (body: %s)", rr.Code, rr.Body.String()) + } else if !strings.Contains(rr.Body.String(), "already at 80 %") { + t.Errorf("409 body should name the estimate: %s", rr.Body.String()) + } + if _, active := ctrl.GetManualHold("garage", time.Now()); active { + t.Fatalf("refused hold must not be installed") + } + if rr := post(`{"power_w":11040,"hold_s":0,"release_at_soc_pct":70}`); rr.Code != http.StatusConflict { + t.Errorf("target below estimate: status = %d, want 409", rr.Code) + } + if rr := post(`{"power_w":11040,"hold_s":0,"release_at_soc_pct":90}`); rr.Code != http.StatusOK { + t.Errorf("target above estimate: status = %d, want 200 (body: %s)", rr.Code, rr.Body.String()) + } + ctrl.ClearManualHold("garage") + if rr := post(`{"power_w":11040,"hold_s":0}`); rr.Code != http.StatusOK { + t.Errorf("no target: status = %d, want 200 (body: %s)", rr.Code, rr.Body.String()) + } + if h, active := ctrl.GetManualHold("garage", time.Now()); !active || h.ReleaseAtSoC != 0 { + t.Errorf("no-target hold should be installed without a SoC release, got active=%v %+v", active, h) + } +} diff --git a/web/app.js b/web/app.js index 0758b27b0..24ba1188d 100644 --- a/web/app.js +++ b/web/app.js @@ -2698,8 +2698,8 @@ 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 pinned at " + formatW(lp.manual_charge_w || 0) + - " — plan and PV logic are off until Stop or unplug."; + : "Manual charge at " + formatW(lp.manual_charge_w || 0) + + " — plan and PV logic are off until the car is full, Stop or unplug."; } else if (charging) { text = winActive ? "Charging on plan until " + evFmtClock(lp.plan_next_end_ms) + "." + kwPlanned @@ -2945,11 +2945,12 @@ if (curA < minA) { curA = minA; } if (curA > maxA) { curA = maxA; } - // "Charge now" stops at the schedule's target SoC when one is set - // (80 % default otherwise), then hands back to the plan — pressing - // Start no longer kills the planner for the rest of the session. - var releasePct = (lp && lp.schedule && lp.schedule.soc > 0) - ? Math.round(lp.schedule.soc * 100) : 80; + // Manual charge has no SoC gate. The estimate is a guess when the + // charger cannot read the car, and gating Start on it left the + // operator with a button that released itself on the next tick + // whenever the guess already sat at the target. Start runs until the + // car stops asking for current, Stop, or unplug; the plan takes over + // again after that. var box = document.createElement("div"); box.style.marginTop = "0.75rem"; @@ -3007,8 +3008,8 @@ 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)." - : "Manual override active — overriding PV surplus (fuse still limits).") - : "Charge now runs at the slider's amps until " + releasePct + " %, then hands back to the plan."; + : "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."; box.appendChild(status); // Start / Stop buttons. @@ -3019,7 +3020,7 @@ var startBtn = document.createElement("button"); startBtn.type = "button"; - startBtn.textContent = active ? "Update" : "Charge now → " + releasePct + " %"; + startBtn.textContent = active ? "Update" : "Charge now"; startBtn.style.flex = "1"; startBtn.style.padding = "0.4rem 0.6rem"; startBtn.style.border = "none"; @@ -3050,7 +3051,8 @@ startBtn.disabled = true; status.textContent = "Starting…"; var a = parseInt(slider.value, 10) || minA; - // CONTROL write — strict (FIX-B): persistent manual hold (hold_s:0). + // 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", { method: "POST", headers: { "Content-Type": "application/json" }, @@ -3058,10 +3060,9 @@ power_w: aToW(a), hold_s: 0, phase_mode: phases === 1 ? "1p" : "3p", - release_at_soc_pct: releasePct, }), }).then(function () { - status.textContent = "Charging at " + a + " A → stops at " + releasePct + " %, then back to the plan."; + status.textContent = "Charging at " + a + " A until the car is full, Stop or unplug."; manualNeedsRebuild = true; // reflect active state on next poll }).catch(function () { startBtn.disabled = false; diff --git a/web/ev-charge-now.test.mjs b/web/ev-charge-now.test.mjs index 3f6ad5f8f..897551057 100644 --- a/web/ev-charge-now.test.mjs +++ b/web/ev-charge-now.test.mjs @@ -4,17 +4,28 @@ import test from 'node:test'; const source = readFileSync(new URL('./app.js', import.meta.url), 'utf8'); -// "Charge now → target" (#1002): Start is a bounded boost that hands -// back to the plan at the target SoC, not a pin-forever. +// Manual charge runs until the car is full. The SoC estimate is a guess +// on chargers that cannot read the car, so gating Start on it produced +// a button that released itself on the next tick whenever the guess +// already sat at the schedule target (#1002 follow-up). -test('Start posts a release target and says where it stops', () => { - assert.match(source, /release_at_soc_pct: releasePct/); - // Target defaults to the schedule SoC, 80 % otherwise. - assert.match(source, /lp\.schedule && lp\.schedule\.soc > 0\)\s*\n?\s*\? Math\.round\(lp\.schedule\.soc \* 100\) : 80/); - // The button names its contract. - assert.match(source, /"Charge now → " \+ releasePct \+ " %"/); - // Active state explains the release, both in the manual tab and the - // plan strip. +test('Start installs a hold without a SoC release', () => { + const manual = source.slice( + source.indexOf('function buildManualChargeSection'), + source.indexOf('function sliderHeader'), + ); + // The manual tab never sends release_at_soc_pct and never derives a + // target from the schedule. + 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/); +}); + +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/); }); diff --git a/web/ev-plan-status.test.mjs b/web/ev-plan-status.test.mjs index 4882dabab..c334619b9 100644 --- a/web/ev-plan-status.test.mjs +++ b/web/ev-plan-status.test.mjs @@ -20,8 +20,9 @@ test('plan-status strip renders every visibility state', () => { // PV-only mode nobody chose. assert.match(source, /Waiting for tomorrow's electricity prices/); assert.match(source, /grid_deferred/); - // Manual hold names its cost: the plan is off until Stop or unplug. - assert.match(source, /plan and PV logic are off until Stop or unplug/); + // 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/); // 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/); });