Skip to content
Closed
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
13 changes: 13 additions & 0 deletions .changeset/ev-charge-now-boost.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
"ftw": minor
---

The EV modal's Start button becomes "Charge now → target": the manual
hold charges at the slider's amps and releases itself once the car's
estimated state of charge reaches the schedule's target (80 % when no
schedule is set), falling straight back to planned dispatch — pressing
Start no longer overrides the planner for the rest of the session. The
release target survives restarts with the hold, holds without a target
keep the old pin-until-Stop-or-unplug contract, and
POST /api/loadpoints/{id}/manual_hold accepts the new
`release_at_soc_pct` field.
16 changes: 16 additions & 0 deletions go/internal/api/api_loadpoint_manual.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,12 @@ type manualHoldRequest struct {
MaxAmpsPerPhase float64 `json:"max_amps_per_phase,omitempty"`
SitePhases int `json:"site_phases,omitempty"`
HoldS int `json:"hold_s"`

// ReleaseAtSoCPct (0–100) turns the hold into "charge now → target,
// then back to the plan": the controller releases it once the
// loadpoint's estimated SoC reaches the target. 0 keeps the legacy
// pin-until-Stop-or-unplug contract.
ReleaseAtSoCPct float64 `json:"release_at_soc_pct,omitempty"`
}

// manualHoldResponse mirrors the active hold so the operator can
Expand All @@ -48,6 +54,7 @@ type manualHoldResponse struct {
MaxAmpsPerPhase float64 `json:"max_amps_per_phase,omitempty"`
SitePhases int `json:"site_phases,omitempty"`
ExpiresAtMs int64 `json:"expires_at_ms,omitempty"`
ReleaseAtSoCPct float64 `json:"release_at_soc_pct,omitempty"`
}

// maxManualHoldS bounds the hold duration so a forgotten hold can't
Expand Down Expand Up @@ -107,6 +114,12 @@ func (s *Server) handleLoadpointManualHold(w http.ResponseWriter, r *http.Reques
})
return
}
if req.ReleaseAtSoCPct < 0 || req.ReleaseAtSoCPct > 100 {
writeJSON(w, 400, map[string]string{
"error": "release_at_soc_pct must be between 0 and 100",
})
return
}

// hold_s == 0 → persistent override (no time expiry); hold_s > 0 →
// bounded diagnostic hold expiring at now+hold_s.
Expand All @@ -125,6 +138,7 @@ func (s *Server) handleLoadpointManualHold(w http.ResponseWriter, r *http.Reques
SitePhases: req.SitePhases,
ExpiresAt: expires,
Persistent: persistent,
ReleaseAtSoC: req.ReleaseAtSoCPct / 100,
}
s.deps.LoadpointCtrl.SetManualHold(id, hold)
writeJSON(w, 200, manualHoldResponseFrom(hold, true))
Expand Down Expand Up @@ -223,6 +237,7 @@ func (s *Server) decorateLoadpointsWithManual(states []loadpoint.State) {
if h, ok := s.deps.LoadpointCtrl.GetManualHold(states[i].ID, now); ok {
states[i].ManualActive = true
states[i].ManualChargeW = h.PowerW
states[i].ManualReleaseSoC = h.ReleaseAtSoC
}
}
}
Expand All @@ -243,5 +258,6 @@ func manualHoldResponseFrom(h loadpoint.ManualHold, active bool) manualHoldRespo
if !h.ExpiresAt.IsZero() {
resp.ExpiresAtMs = h.ExpiresAt.UnixMilli()
}
resp.ReleaseAtSoCPct = h.ReleaseAtSoC * 100
return resp
}
22 changes: 22 additions & 0 deletions go/internal/loadpoint/controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -399,6 +399,15 @@ type ManualHold struct {
// the flag is what distinguishes it from the zero-ExpiresAt "clear"
// sentinel that SetManualHold honours.
Persistent bool

// ReleaseAtSoC (0–1) turns the hold into "charge now, then back to
// the plan": once the loadpoint's estimated (or BMS-anchored) SoC
// reaches this fraction, the controller clears the hold and the
// same tick falls through to automatic surplus/plan dispatch.
// Zero keeps the legacy contract — pinned until Stop or unplug.
// Persisted with the hold, so a restart mid-boost keeps the
// release target.
ReleaseAtSoC float64
}

// Directive is the loadpoint-relevant slice of mpc.SlotDirective.
Expand Down Expand Up @@ -1525,6 +1534,19 @@ func (c *Controller) tickOne(ctx context.Context, now time.Time, lpCfg Config, d
}
}

// Release a "charge now" hold at its target SoC. The operator asked
// for immediate charge up to a level, not a pin-forever: clearing
// here lets this same tick fall straight through to automatic
// 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 {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Honor charge-now targets when capacity is omitted

When a loadpoint has neither vehicle_capacity_wh nor a paired vehicle BMS, this condition can never become true: configuration permits omitting capacity, while estimateSoC in loadpoint.go returns only the fixed plug-in anchor whenever capacity is zero, regardless of delivered session energy. The UI now installs every Charge-now hold with an 80% or scheduled release target, but on these sites the hold continues past that target until Stop, unplug, or a supported vehicle-declined signal—recreating the persistent high-current behavior this change is meant to prevent. Apply the documented capacity fallback to SoC inference or otherwise ensure target-based holds can terminate without configured capacity.

Useful? React with 👍 / 👎.

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)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Charge now uses latched session SoC

Medium Severity

Charge-now release compares ReleaseAtSoC to CurrentSoC after Observe, which pins CurrentSoC to targetSoC once sessionComplete latches. The UI sends the schedule target as the release point, so a hold clears on the first tick even when the car is still below that target — typically after it declined at its own charge limit. Charge now then cannot override a completed session without an unplug.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit d47879c. Configure here.

}

cmd := map[string]any{"action": "ev_set_current"}
if hold, ok := c.GetManualHold(lpCfg.ID, now); ok {
// Manual override active — skip MPC translation. The hold's
Expand Down
90 changes: 90 additions & 0 deletions go/internal/loadpoint/controller_charge_now_release_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
package loadpoint

import (
"context"
"testing"
"time"
)

// A "charge now" hold (ReleaseAtSoC > 0) must release itself once the
// loadpoint's inferred SoC reaches the target, and the SAME tick must
// fall through to automatic plan dispatch — the whole point is that
// Start no longer kills the planner for the rest of the session.

func chargeNowLoadpoint() Config {
return Config{
ID: "garage",
DriverName: "easee",
MinChargeW: 4140,
MaxChargeW: 11000,
AllowedStepsW: ftwStepSet,
PhaseMode: "3p",
VehicleCapacityWh: 60000,
PluginSoC: 0.5, // inferred SoC = 0.5 + session_wh/60000
}
}

func TestChargeNowHoldReleasesAtTargetSoC(t *testing.T) {
base := time.Date(2026, 8, 30, 12, 0, 0, 0, time.UTC)
cfg := chargeNowLoadpoint()
dir := &Directive{
SlotStart: base.Add(-1 * time.Second),
SlotEnd: base.Add(15 * time.Minute),
LoadpointEnergyWh: map[string]float64{cfg.ID: 0},
}
sender := &fakeSender{}
samples := map[string]EVSample{cfg.DriverName: {
Connected: true, PowerW: 11000, SessionWh: 6000, RequestActive: true,
}}
c := newTestController(t, []Config{cfg}, dir, samples, sender)
c.SetSiteFuse(SiteFuse{MaxAmps: 16, Voltage: 230, PhaseCnt: 3})

c.SetManualHold(cfg.ID, ManualHold{
PowerW: 11040, PhaseMode: "3p", Persistent: true, ReleaseAtSoC: 0.8,
})

// SoC = 0.5 + 6000/60000 = 0.6 — below target: the hold stays and
// the hold wattage is what gets dispatched.
c.Tick(context.Background(), base)
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)
}

// Session energy grows past the target: SoC = 0.5 + 18300/60000 =
// 0.805 ≥ 0.8 — the hold releases and the SAME tick dispatches the
// plan's allocation (0 Wh here → explicit 0 W standdown), not the
// hold wattage.
samples[cfg.DriverName] = EVSample{
Connected: true, PowerW: 11000, SessionWh: 18300, RequestActive: true,
}
later := base.Add(30 * time.Second)
c.Tick(context.Background(), later)
if _, active := c.GetManualHold(cfg.ID, later); active {
t.Errorf("hold still active after SoC reached its release target")
}
if n := len(sender.calls); n == 0 || sender.calls[n-1].power != 0 {
t.Errorf("at target: want plan dispatch (0 W standdown), got %+v", sender.calls[len(sender.calls)-1])
}
}

func TestLegacyHoldWithoutTargetNeverSoCReleases(t *testing.T) {
base := time.Date(2026, 8, 30, 12, 0, 0, 0, time.UTC)
cfg := chargeNowLoadpoint()
sender := &fakeSender{}
// Fully charged by the inference: SoC = 0.5 + 30000/60000 = 1.0.
samples := map[string]EVSample{cfg.DriverName: {
Connected: true, PowerW: 11000, SessionWh: 30000, RequestActive: true,
}}
c := newTestController(t, []Config{cfg}, nil, samples, sender)
c.SetSiteFuse(SiteFuse{MaxAmps: 16, Voltage: 230, PhaseCnt: 3})

c.SetManualHold(cfg.ID, ManualHold{PowerW: 11040, PhaseMode: "3p", Persistent: true})

c.Tick(context.Background(), base)
if _, active := c.GetManualHold(cfg.ID, base); !active {
t.Errorf("legacy hold (no ReleaseAtSoC) must keep the pin-until-Stop-or-unplug contract")
}
}
11 changes: 7 additions & 4 deletions go/internal/loadpoint/loadpoint.go
Original file line number Diff line number Diff line change
Expand Up @@ -161,10 +161,13 @@ type State struct {

// ManualActive is true when an operator manual hold ("Start" / amp
// slider) is pinned on this loadpoint, overriding surplus/plan.
// ManualChargeW is the held setpoint in watts. Populated by the API
// layer from the loadpoint controller.
ManualActive bool `json:"manual_active"`
ManualChargeW float64 `json:"manual_charge_w,omitempty"`
// ManualChargeW is the held setpoint in watts. ManualReleaseSoC
// (0–1), when non-zero, is the "charge now" target at which the
// controller releases the hold back to the plan. Populated by the
// API layer from the loadpoint controller.
ManualActive bool `json:"manual_active"`
ManualChargeW float64 `json:"manual_charge_w,omitempty"`
ManualReleaseSoC float64 `json:"manual_release_soc,omitempty"`

// BatteryBoost is the explicit, bounded home-battery-to-EV permission
// for this loadpoint. Populated by the API layer from Controller state.
Expand Down
24 changes: 18 additions & 6 deletions web/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -2613,8 +2613,11 @@
var charging = (lp.current_power_w || 0) >= 100;
var hasSchedule = lp.schedule && lp.schedule.soc > 0;
if (lp.manual_active) {
text = "Manual charge pinned at " + formatW(lp.manual_charge_w || 0) +
" — plan and PV logic are off until Stop or unplug.";
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.";
} else if (charging) {
text = winActive
? "Charging on plan until " + evFmtClock(lp.plan_next_end_ms) + "." + kwPlanned
Expand Down Expand Up @@ -2843,6 +2846,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;

var box = document.createElement("div");
box.style.marginTop = "0.75rem";
box.style.paddingTop = "0.6rem";
Expand Down Expand Up @@ -2897,8 +2906,10 @@
status.style.marginTop = "0.35rem";
status.style.minHeight = "1em";
status.textContent = active
? "Manual override active — overriding PV surplus (fuse still limits)."
: "Stopped = automatic (PV-surplus-only if enabled below). Start overrides it.";
? (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.";
box.appendChild(status);

// Start / Stop buttons.
Expand All @@ -2909,7 +2920,7 @@

var startBtn = document.createElement("button");
startBtn.type = "button";
startBtn.textContent = active ? "Update" : "Start";
startBtn.textContent = active ? "Update" : "Charge now → " + releasePct + " %";
startBtn.style.flex = "1";
startBtn.style.padding = "0.4rem 0.6rem";
startBtn.style.border = "none";
Expand Down Expand Up @@ -2948,9 +2959,10 @@
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 — overriding PV surplus.";
status.textContent = "Charging at " + a + " A → stops at " + releasePct + " %, then back to the plan.";
manualNeedsRebuild = true; // reflect active state on next poll
}).catch(function () {
startBtn.disabled = false;
Expand Down
20 changes: 20 additions & 0 deletions web/ev-charge-now.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
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');

// "Charge now → target" (#1002): Start is a bounded boost that hands
// back to the plan at the target SoC, not a pin-forever.

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.
assert.match(source, /stops at " \+ Math\.round\(lp\.manual_release_soc \* 100\)/);
assert.match(source, /returns to plan at/);
});