-
Notifications
You must be signed in to change notification settings - Fork 10
feat(ev): Start becomes "charge now → target", releasing back to the plan #1005
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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) | ||
| } | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Charge now uses latched session SoCMedium Severity Charge-now release compares 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 | ||
|
|
||
| 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") | ||
| } | ||
| } |
| 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/); | ||
| }); |


There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When a loadpoint has neither
vehicle_capacity_whnor a paired vehicle BMS, this condition can never become true: configuration permits omitting capacity, whileestimateSoCinloadpoint.goreturns 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 👍 / 👎.