Repository navigation
fix(ev): resume after a 0 W standdown and span the Overview car notice #1187
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 1 commit
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,5 @@ | ||
| --- | ||
| "ftw": patch | ||
| --- | ||
|
|
||
| Overview no longer squeezes a plugged-in car notice into a one-word-wide column, and a charger paused at 0 W is resumed when FTW offers current again. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -566,6 +566,35 @@ func (c *Controller) SetCommandTimeout(timeout time.Duration) { | |
| c.commandTimeout = timeout | ||
| } | ||
|
|
||
| func previousCommanded(c *Controller, id string) (w float64, known bool) { | ||
| if c == nil || c.manager == nil { | ||
| return 0, false | ||
| } | ||
| st, ok := c.manager.State(id) | ||
| if !ok { | ||
| return 0, false | ||
| } | ||
| return st.CommandedW, st.CommandedKnown | ||
| } | ||
|
|
||
| // resumeAfterZeroOffer sends ev_resume when dispatch returns to a non-zero | ||
| // offer after commanding 0 W. Cloud chargers that map 0 A to a sticky user | ||
| // pause (Easee dynamicChargerCurrent) otherwise keep the contactor open | ||
| // until the cable is unplugged, even after a later ev_set_current with amps. | ||
| func (c *Controller) resumeAfterZeroOffer(ctx context.Context, lpCfg Config, prevW float64, prevKnown bool, offerW float64) { | ||
| if c == nil || !prevKnown || prevW > 0 || offerW <= 0 { | ||
| return | ||
| } | ||
| payload, err := json.Marshal(map[string]any{"action": "ev_resume"}) | ||
| if err != nil { | ||
| return | ||
| } | ||
| if err := c.sendDispatchWithDeadline(ctx, lpCfg.DriverName, payload); err != nil { | ||
| slog.Warn("loadpoint resume after zero offer", | ||
| "lp", lpCfg.ID, "driver", lpCfg.DriverName, "err", err) | ||
| } | ||
| } | ||
|
|
||
| func (c *Controller) sendDispatchWithDeadline(ctx context.Context, driver string, payload []byte) error { | ||
| if c == nil || c.send == nil { | ||
| return nil | ||
|
|
@@ -1901,7 +1930,11 @@ func (c *Controller) tickOne(ctx context.Context, now time.Time, lpCfg Config, d | |
| // The interruption latch reads this to keep a pause the box chose — | ||
| // plan slot, Stop hold, surplus clamp — from ever reading as a | ||
| // charge that failed. | ||
| prevW, prevKnown := previousCommanded(c, lpCfg.ID) | ||
| var offerW float64 | ||
| var haveOffer bool | ||
| if w, ok := cmd["power_w"].(float64); ok { | ||
| offerW, haveOffer = w, true | ||
| c.manager.setCommandedForManual(lpCfg.ID, w, cmdReason, manualCommandUpdatedAt) | ||
| } | ||
| payload, err := json.Marshal(cmd) | ||
|
|
@@ -1911,6 +1944,16 @@ func (c *Controller) tickOne(ctx context.Context, now time.Time, lpCfg Config, d | |
| if c.send == nil { | ||
| return | ||
| } | ||
| // Easee (and similar cloud chargers) treat a 0 A dynamic limit as a | ||
| // user pause that stays until resume or unplug. Writing amps alone | ||
| // does not reopen the contactor — field report 2026-09-07, e-tron | ||
| // "no voltage" / Easee "user paused". Resume on the 0 W → offer edge | ||
| // so a later plan, surplus, Charge now, or safety recovery actually | ||
| // starts current. Skip the first command of a process (prevKnown | ||
| // false): that is not a standdown we issued. | ||
| if haveOffer { | ||
| c.resumeAfterZeroOffer(ctx, lpCfg, prevW, prevKnown, offerW) | ||
|
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.
When an OCPP charger previously accepted a higher rate and the post-standdown offer is lower (for example, after a fuse clamp), this bare AGENTS.md reference: AGENTS.md:L35-L37 Useful? React with 👍 / 👎. |
||
| } | ||
| // The one command whose outcome decides whether core can actuate this | ||
| // charger. A charger that answers every poll and refuses this holds | ||
| // whatever current it last accepted, and the plan goes on counting the | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,100 @@ | ||
| package loadpoint | ||
|
|
||
| import ( | ||
| "context" | ||
| "testing" | ||
| "time" | ||
| ) | ||
|
|
||
| // A 0 W offer (no plan, surplus pause, safety standdown) maps on Easee to a | ||
| // sticky user-pause at 0 A. The next non-zero offer must send ev_resume | ||
| // before ev_set_current, otherwise the contactor stays open until unplug. | ||
| func TestTickResumesChargerWhenOfferReturns(t *testing.T) { | ||
| sender := &fakeSender{} | ||
| cfgs := []Config{{ | ||
| ID: "garage", | ||
| DriverName: "easee", | ||
| MinChargeW: 1400, | ||
| MaxChargeW: 11000, | ||
| AllowedStepsW: []float64{0, 1400, 4100, 7400, 11000}, | ||
| }} | ||
| samples := map[string]EVSample{ | ||
| "easee": {PowerW: 0, Connected: true, RequestActive: true}, | ||
| } | ||
| m := NewManager() | ||
| m.Load(cfgs) | ||
| var dir *Directive | ||
| plan := PlanFunc(func(now time.Time) (Directive, bool) { | ||
| if dir == nil { | ||
| return Directive{}, false | ||
| } | ||
| return *dir, true | ||
| }) | ||
| tel := TelemetryFunc(func(driver string) (EVSample, bool) { | ||
| s, ok := samples[driver] | ||
| return s, ok | ||
| }) | ||
| c := NewController(m, plan, tel, sender.Send) | ||
| now := time.Date(2026, 9, 7, 12, 0, 0, 0, time.UTC) | ||
|
|
||
| c.Tick(context.Background(), now) | ||
| if len(sender.calls) != 1 || sender.calls[0].action != "ev_set_current" || sender.calls[0].power != 0 { | ||
| t.Fatalf("no-plan standdown: %+v", sender.calls) | ||
| } | ||
| if countAction(sender.calls, "ev_resume") != 0 { | ||
| t.Fatalf("first 0 W command must not resume: %+v", sender.calls) | ||
| } | ||
|
|
||
| dir = &Directive{ | ||
| SlotStart: now, | ||
| SlotEnd: now.Add(15 * time.Minute), | ||
| LoadpointEnergyWh: map[string]float64{"garage": 2750}, | ||
| } | ||
| c.Tick(context.Background(), now.Add(5*time.Second)) | ||
| if len(sender.calls) != 3 { | ||
| t.Fatalf("reoffer command count = %d, want 3 (0 W, resume, set); got %+v", len(sender.calls), sender.calls) | ||
| } | ||
| if sender.calls[1].action != "ev_resume" { | ||
| t.Fatalf("expected ev_resume after 0 W standdown, got %+v", sender.calls[1]) | ||
| } | ||
| if sender.calls[2].action != "ev_set_current" || sender.calls[2].power != 11000 { | ||
| t.Fatalf("expected 11000 W ev_set_current after resume, got %+v", sender.calls[2]) | ||
| } | ||
|
|
||
| sender.calls = nil | ||
| c.Tick(context.Background(), now.Add(10*time.Second)) | ||
| if countAction(sender.calls, "ev_resume") != 0 { | ||
| t.Fatalf("must not resume again while already offering: %+v", sender.calls) | ||
| } | ||
| set, ok := lastSetCurrent(sender.calls) | ||
| if !ok || set.power != 11000 { | ||
| t.Fatalf("continued offer: %+v", sender.calls) | ||
| } | ||
| } | ||
|
|
||
| func TestTickFirstPositiveOfferDoesNotResume(t *testing.T) { | ||
| sender := &fakeSender{} | ||
| slotStart := time.Date(2026, 9, 7, 12, 0, 0, 0, time.UTC) | ||
| cfgs := []Config{{ | ||
| ID: "garage", | ||
| DriverName: "easee", | ||
| MinChargeW: 1400, | ||
| MaxChargeW: 11000, | ||
| AllowedStepsW: []float64{0, 1400, 4100, 7400, 11000}, | ||
| }} | ||
| directive := &Directive{ | ||
| SlotStart: slotStart, | ||
| SlotEnd: slotStart.Add(15 * time.Minute), | ||
| LoadpointEnergyWh: map[string]float64{"garage": 2750}, | ||
| } | ||
| samples := map[string]EVSample{"easee": {PowerW: 0, Connected: true, RequestActive: true}} | ||
| c := newTestController(t, cfgs, directive, samples, sender) | ||
|
|
||
| c.Tick(context.Background(), slotStart) | ||
| if len(sender.calls) != 1 || sender.calls[0].action != "ev_set_current" || sender.calls[0].power != 11000 { | ||
| t.Fatalf("first offer of a process: %+v", sender.calls) | ||
| } | ||
| if countAction(sender.calls, "ev_resume") != 0 { | ||
| t.Fatalf("first offer is not a standdown we issued: %+v", sender.calls) | ||
| } | ||
| } |
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
ev_resumefails transiently but the subsequentev_set_currentsucceeds, the manager has already been changed from 0 W to the positive offer here. The next tick therefore seesprevW > 0and never retriesev_resume; on the sticky-pause chargers this change targets, the car remains paused for the entire positive plan interval until another zero edge or unplug occurs. Track a pending resume independently or clear it only after the resume succeeds.Useful? React with 👍 / 👎.