diff --git a/.changeset/ev-charge-now-boost.md b/.changeset/ev-charge-now-boost.md new file mode 100644 index 000000000..aa13c3ea7 --- /dev/null +++ b/.changeset/ev-charge-now-boost.md @@ -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. diff --git a/go/internal/api/api_loadpoint_manual.go b/go/internal/api/api_loadpoint_manual.go index 16a83a152..60385c5f8 100644 --- a/go/internal/api/api_loadpoint_manual.go +++ b/go/internal/api/api_loadpoint_manual.go @@ -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 @@ -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 @@ -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. @@ -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)) @@ -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 } } } @@ -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 } diff --git a/go/internal/loadpoint/controller.go b/go/internal/loadpoint/controller.go index 6298b1658..0b7128ffb 100644 --- a/go/internal/loadpoint/controller.go +++ b/go/internal/loadpoint/controller.go @@ -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. @@ -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 { + 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) + } + } + 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 diff --git a/go/internal/loadpoint/controller_charge_now_release_test.go b/go/internal/loadpoint/controller_charge_now_release_test.go new file mode 100644 index 000000000..4bb36fbd4 --- /dev/null +++ b/go/internal/loadpoint/controller_charge_now_release_test.go @@ -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") + } +} diff --git a/go/internal/loadpoint/loadpoint.go b/go/internal/loadpoint/loadpoint.go index 778d904b6..3d103b59b 100644 --- a/go/internal/loadpoint/loadpoint.go +++ b/go/internal/loadpoint/loadpoint.go @@ -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. diff --git a/web/app.js b/web/app.js index 4a9644c5a..3d4d523e5 100644 --- a/web/app.js +++ b/web/app.js @@ -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 @@ -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"; @@ -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. @@ -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"; @@ -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; diff --git a/web/ev-charge-now.test.mjs b/web/ev-charge-now.test.mjs new file mode 100644 index 000000000..3f6ad5f8f --- /dev/null +++ b/web/ev-charge-now.test.mjs @@ -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/); +});