fix(ev): manual charge runs until the car is full - #1056
Conversation
"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 <noreply@anthropic.com>
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: c08b011daa
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| // CONTROL write — strict (FIX-B): persistent manual hold (hold_s:0) | ||
| // with no SoC release — see the note above the slider. |
There was a problem hiding this comment.
Keep a fallback release for chargers without request state
When Charge now is used with OCPP or any driver that omits request_active, this creates a persistent hold with neither a SoC release nor a usable full signal. The telemetry adapter defaults the missing field to true (go/cmd/ftw/main.go:1883-1902), OCPP's pushReading omits it (go/internal/ocpp/handlers.go:578-584), and the controller only auto-releases when a driver explicitly reports false (go/internal/loadpoint/controller.go:1520-1535). Consequently, after the vehicle reaches its limit while remaining plugged in, the manual hold continues to bypass plan/PV control until Stop or unplug—and may resume fixed-rate charging if the vehicle requests current again—contrary to the new promise that the plan takes over; retain a fallback release for these chargers or gate no-target holds on the capability.
Useful? React with 👍 / 👎.


What changed
POST /api/loadpoints/{id}/manual_holdrefuses arelease_at_soc_pctthe SoC estimate already meets with 409 and a message naming the estimate. Omitting the field, or a target above the estimate, still installs.web/ev-charge-now.test.mjsrewritten for the new contract,web/ev-plan-status.test.mjsupdated for the strip copy,TestManualHoldRefusesReleaseTargetAlreadyMetadded ingo/internal/api.Why
Field report from the maintainer's site today (Easee carport, estimate at 80 %, schedule 80 % by 07:00). "Charge now → 80 %" from #1007 releases at the schedule target. The estimate already sat there, so the hold cleared on the next tick. The box log shows five releases between 06:15 and 06:18 and no charge. The only way to change the 80 was to save a different schedule, and the operator ended up trying to drag the current-charge slider below 80 % to get around it.
The estimate is a guess on chargers that cannot read the car. Gating a manual start on it turns Start into a no-op exactly when the operator wants to go past the plan. Manual now means: charger runs. The car-declined auto-release (Easee emits
request_activesince device-drivers#103), Stop and unplug still end it, so the "Start pins the wallbox forever" concern from #1002 stays covered.The 409 exists so no caller, scripted or otherwise, can install the same silent no-op through the API. The app door (
appproto) never set a release target, so it needs no change.Verification
npm test: 499 pass, 0 fail.go test ./internal/api/ ./internal/loadpoint/andgo vet ./internal/api/: pass.web/locally with/apiproxied to the live box. Opened the EV modal and the Manual tab with a hold active: the strip reads "Manual charge at 11.0 kW — plan and PV logic are off until the car is full, Stop or unplug." and the tab reads "Charging at the slider's amps until the car is full, Stop or unplug (fuse still limits)." with Update / Stop. The idle state ("Charge now") was not driven live since that would have stopped a running charge; it is pinned by the web test.Refs #1002. Follows #1007.
🤖 Generated with Claude Code
Note
Medium Risk
Changes operator-facing EV charging control and manual-hold API validation; mistaken SoC estimates could affect who gets a 409, but core dispatch logic is unchanged beyond refusing no-op holds.
Overview
EV manual charge no longer ties "Charge now" to the schedule's target SoC. The dashboard installs a persistent
manual_holdwithoutrelease_at_soc_pct, and copy/buttons say charging continues until the car stops drawing, Stop, or unplug (API-installed holds that still have a release target are explained in the UI when active).API:
POST /api/loadpoints/{id}/manual_holdreturns 409 whenrelease_at_soc_pctis set but the loadpoint's estimated SoC already meets or exceeds that target, so callers cannot install a hold that the controller would clear on the next tick.Tests cover the new 409 behavior (
TestManualHoldRefusesReleaseTargetAlreadyMet) and the updated web contract (ev-charge-now.test.mjs,ev-plan-status.test.mjs). Patch changeset included.Reviewed by Cursor Bugbot for commit c08b011. Bugbot is set up for automated code reviews on this repo. Configure here.