diff --git a/.changeset/ev-resume-after-zero.md b/.changeset/ev-resume-after-zero.md new file mode 100644 index 000000000..17bb2acb2 --- /dev/null +++ b/.changeset/ev-resume-after-zero.md @@ -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. diff --git a/go/internal/loadpoint/controller.go b/go/internal/loadpoint/controller.go index 5d28fecd2..c4c41c66b 100644 --- a/go/internal/loadpoint/controller.go +++ b/go/internal/loadpoint/controller.go @@ -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) + } // 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 diff --git a/go/internal/loadpoint/controller_dispatch_safety_test.go b/go/internal/loadpoint/controller_dispatch_safety_test.go index f18417e7d..30fae77ff 100644 --- a/go/internal/loadpoint/controller_dispatch_safety_test.go +++ b/go/internal/loadpoint/controller_dispatch_safety_test.go @@ -48,9 +48,13 @@ func TestStaleSiteMeterStopsScheduledChargeAndRecovers(t *testing.T) { } c.TickWithDispatch(context.Background(), now.Add(5*time.Second), true) - if len(sender.calls) != 2 || sender.calls[1].power <= 0 { + set, ok := lastSetCurrent(sender.calls) + if !ok || set.power <= 0 { t.Fatalf("fresh site meter did not resume scheduled charge: %+v", sender.calls) } + if countAction(sender.calls, "ev_resume") != 1 { + t.Fatalf("reoffer after 0 W standdown must send ev_resume: %+v", sender.calls) + } } // Persistent operator holds survive restarts and must also survive a safety @@ -77,7 +81,11 @@ func TestStaleSiteMeterStopsPersistentManualHoldAndRecovers(t *testing.T) { samples[cfg.DriverName] = EVSample{Connected: true, RequestActive: true} c.TickWithDispatch(context.Background(), now.Add(SessionCompletionTimeout+2*time.Minute), true) - if len(sender.calls) != 3 || sender.calls[2].power != 6900 { + set, ok := lastSetCurrent(sender.calls) + if !ok || set.power != 6900 { t.Fatalf("fresh site meter did not resume persistent manual hold: %+v", sender.calls) } + if countAction(sender.calls, "ev_resume") != 1 { + t.Fatalf("reoffer after 0 W standdown must send ev_resume: %+v", sender.calls) + } } diff --git a/go/internal/loadpoint/controller_resume_after_zero_test.go b/go/internal/loadpoint/controller_resume_after_zero_test.go new file mode 100644 index 000000000..4b549909b --- /dev/null +++ b/go/internal/loadpoint/controller_resume_after_zero_test.go @@ -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) + } +} diff --git a/go/internal/loadpoint/controller_test.go b/go/internal/loadpoint/controller_test.go index 5af308208..1c4585a6d 100644 --- a/go/internal/loadpoint/controller_test.go +++ b/go/internal/loadpoint/controller_test.go @@ -27,6 +27,25 @@ type sentCommand struct { sitePhases int } +func lastSetCurrent(calls []sentCommand) (sentCommand, bool) { + for i := len(calls) - 1; i >= 0; i-- { + if calls[i].action == "ev_set_current" { + return calls[i], true + } + } + return sentCommand{}, false +} + +func countAction(calls []sentCommand, action string) int { + n := 0 + for _, c := range calls { + if c.action == action { + n++ + } + } + return n +} + func (f *fakeSender) Send(ctx context.Context, driver string, payload []byte) error { var d struct { Action string `json:"action"` diff --git a/go/internal/mpc/service_requested_replan_test.go b/go/internal/mpc/service_requested_replan_test.go index 6dfb314d6..0d6a7a1d3 100644 --- a/go/internal/mpc/service_requested_replan_test.go +++ b/go/internal/mpc/service_requested_replan_test.go @@ -95,11 +95,17 @@ func TestFailedGoalReplanStopsOldEVAllocationButKeepsManualControl(t *testing.T) return loadpoint.EVSample{Connected: true, RequestActive: true, DeviceID: "easee:A", SessionID: "session-1"}, true }, func(_ context.Context, _ string, payload []byte) error { var command struct { + Action string `json:"action"` PowerW float64 `json:"power_w"` } if err := json.Unmarshal(payload, &command); err != nil { return err } + // ev_resume is a side effect of a 0 W → offer edge; this test + // is about the setpoint the charger is ordered to deliver. + if command.Action != "" && command.Action != "ev_set_current" { + return nil + } sent = append(sent, command.PowerW) return nil }) diff --git a/web/app.css b/web/app.css index cbee9d30d..cda87684e 100644 --- a/web/app.css +++ b/web/app.css @@ -279,6 +279,38 @@ body.ftw-app .overview-heading > div:first-child > p:last-child { max-width: 62ch; color: var(--fg-dim); } + +/* Direct child of the 12-column Overview grid: span the row or the + card occupies one column and the copy wraps a word per line. */ +body.ftw-app #charging-notices { + grid-column: 1 / -1; +} +body.ftw-app #charging-notices[hidden] { + display: none !important; +} +body.ftw-app #charging-notices .overview-card { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 6px 16px; + margin: 0; + padding: 14px 16px; +} +body.ftw-app #charging-notices .overview-card p { + flex: 1 1 16rem; + margin: 0; + font-size: 0.9rem; +} +body.ftw-app #charging-notices .overview-card button { + padding: 0.3rem 0; + border: 0; + background: none; + color: var(--accent-e); + font: inherit; + text-align: left; + text-decoration: underline; + cursor: pointer; +} body.ftw-app .overview-health { display: inline-flex; align-items: center; @@ -2487,6 +2519,7 @@ body.ftw-app .diagnose-detail { padding-top: 4px; } body.ftw-app .power-now, + body.ftw-app #charging-notices, body.ftw-app .overview-outlook, body.ftw-app .overview-today, body.ftw-app .fuse-row { diff --git a/web/app.js b/web/app.js index d94ab9698..e9d6bdb1a 100644 --- a/web/app.js +++ b/web/app.js @@ -2164,14 +2164,11 @@ var section = document.createElement("section"); section.className = "overview-card"; section.setAttribute("aria-label", "Car connection"); - section.style.cssText = "padding:0.9rem;margin-bottom:1rem"; var title = document.createElement("strong"); var status = document.createElement("p"); status.setAttribute("role", "status"); - status.style.cssText = "margin:0.45rem 0;font-size:0.9rem"; var button = document.createElement("button"); button.type = "button"; - button.style.cssText = "background:none;border:0;color:var(--accent-e);padding:0.3rem 0;text-align:left;text-decoration:underline;font:inherit;cursor:pointer"; section.append(title, status, button); host.appendChild(section); row = { section: section, title: title, status: status, button: button }; diff --git a/web/dashboard-simplification.test.mjs b/web/dashboard-simplification.test.mjs index 1b7c53500..2cc31b01a 100644 --- a/web/dashboard-simplification.test.mjs +++ b/web/dashboard-simplification.test.mjs @@ -19,6 +19,7 @@ const history = html.match(/
/)?.[0] || "" describe("simplified dashboard overview", () => { it("answers now, price, plan, today, and fuse in that order", () => { for (const id of [ + "charging-notices", "power-now", "overview-price", "overview-plan-summary", @@ -27,6 +28,11 @@ describe("simplified dashboard overview", () => { ]) { assert.match(overview, new RegExp(`id="${id}"`)); } + assert.ok( + overview.indexOf('id="charging-notices"') < + overview.indexOf('id="power-now"'), + "the plugged-in car notice should precede Power now", + ); const orderedIds = [ "power-now", @@ -44,6 +50,18 @@ describe("simplified dashboard overview", () => { } }); + it("spans the plugged-in car notice across the Overview grid", () => { + // #view-overview is 12 columns. A direct child without a span occupies + // one column and wraps the notice a word per line, leaving the rest of + // the row empty (v3.1.3-beta.1 field report). + assert.match( + css, + /body\.ftw-app #charging-notices\s*\{[^}]*grid-column:\s*1\s*\/\s*-1/, + ); + assert.match(css, /body\.ftw-app #charging-notices\[hidden\]/); + assert.match(app, /getElementById\("charging-notices"\)/); + }); + it("offers accessible Flow and Values panels around the existing diagram", () => { assert.match(overview, /role="tablist" aria-label="Power now view"/); assert.match( diff --git a/web/index.html b/web/index.html index ddbff7552..978b94646 100644 --- a/web/index.html +++ b/web/index.html @@ -157,6 +157,8 @@

Your home, right now

+ +