Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/manual-charge-runs-until-full.md
Original file line number Diff line number Diff line change
@@ -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.
17 changes: 17 additions & 0 deletions go/internal/api/api_loadpoint_manual.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package api

import (
"fmt"
"math"
"net/http"
"time"

Expand Down Expand Up @@ -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.
Expand Down
42 changes: 42 additions & 0 deletions go/internal/api/api_loadpoint_manual_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
27 changes: 14 additions & 13 deletions web/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -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.
Expand All @@ -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";
Expand Down Expand Up @@ -3050,18 +3051,18 @@
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.
Comment on lines +3054 to +3055

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

apiFetch("/api/loadpoints/" + encodeURIComponent(lp.id) + "/manual_hold", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
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;
Expand Down
31 changes: 21 additions & 10 deletions web/ev-charge-now.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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/);
});
5 changes: 3 additions & 2 deletions web/ev-plan-status.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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/);
});
Expand Down