Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
5 changes: 5 additions & 0 deletions .changeset/ev-resume-after-zero.md
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.
43 changes: 43 additions & 0 deletions go/internal/loadpoint/controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Comment on lines 1936 to 1938

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 Retain the resume edge until resume succeeds

When ev_resume fails transiently but the subsequent ev_set_current succeeds, the manager has already been changed from 0 W to the positive offer here. The next tick therefore sees prevW > 0 and never retries ev_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 👍 / 👎.

}
payload, err := json.Marshal(cmd)
Expand All @@ -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)

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 Apply the new clamp before resuming OCPP chargers

When an OCPP charger previously accepted a higher rate and the post-standdown offer is lower (for example, after a fuse clamp), this bare ev_resume is routed to ocpp.Server.Command, which restores the last non-zero amperage before the following ev_set_current applies the new limit. Because the resume call waits for profile confirmation, the charger can draw at the stale higher rate during that interval, defeating the hardware-protection clamp; resume must carry the already-clamped offer or otherwise avoid restoring the old rate on this path.

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
Expand Down
12 changes: 10 additions & 2 deletions go/internal/loadpoint/controller_dispatch_safety_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
}
}
100 changes: 100 additions & 0 deletions go/internal/loadpoint/controller_resume_after_zero_test.go
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)
}
}
19 changes: 19 additions & 0 deletions go/internal/loadpoint/controller_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down
33 changes: 33 additions & 0 deletions web/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 {
Expand Down
3 changes: 0 additions & 3 deletions web/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand Down
18 changes: 18 additions & 0 deletions web/dashboard-simplification.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ const history = html.match(/<main id="view-history"[\s\S]*?<\/main>/)?.[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",
Expand All @@ -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",
Expand All @@ -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(
Expand Down
2 changes: 2 additions & 0 deletions web/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,8 @@ <h2>Your home, right now</h2>
</div>
</section>

<div id="charging-notices" hidden></div>

<section class="power-now" id="power-now" aria-labelledby="power-now-title">
<div class="power-now-head">
<div>
Expand Down
Loading