From 602aa0f7b657eb88e4eb751f20c8d011c4d34e15 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Thu, 8 Oct 2026 07:01:16 +0200 Subject: [PATCH] fix(control): show a charger's load balancer as the limit An Easee charger's load balancer can cut the car below FTW's request. The EV sheet then said "Not following" and that the charger had not said why, with requested current, charger offer and charger limit all equal. Read the driver's vendor-neutral current_limited_by. When it names the load balancer and the car draws less than asked, report load_balancer_limit as limited, with words that name the load balancer. More power than asked still warns. Move the bundled driver pin to srcfl/device-drivers#172, which keeps the load-balancing reason while the car charges. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Fredrik Ahlgren --- .changeset/easee-load-balancer-limit.md | 5 +++ drivers/BUNDLED_SOURCE.json | 2 +- go/internal/api/api_control_feedback.go | 12 ++++- go/internal/api/api_control_scenarios_test.go | 44 +++++++++++++++++++ web/control-feedback.js | 5 +++ web/control-feedback.test.mjs | 8 ++++ 6 files changed, 73 insertions(+), 3 deletions(-) create mode 100644 .changeset/easee-load-balancer-limit.md diff --git a/.changeset/easee-load-balancer-limit.md b/.changeset/easee-load-balancer-limit.md new file mode 100644 index 000000000..753c3ae09 --- /dev/null +++ b/.changeset/easee-load-balancer-limit.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +When an Easee charger's load balancer cuts the car below FTW's request, the EV sheet now says "Limited by the charger's load balancer" instead of "Not following" with no reason. Bundles easee_cloud 1.3.7, which keeps that reason while the car charges and matches its reason labels to Easee's published table. diff --git a/drivers/BUNDLED_SOURCE.json b/drivers/BUNDLED_SOURCE.json index 32ba7bbc1..2b2235cfc 100644 --- a/drivers/BUNDLED_SOURCE.json +++ b/drivers/BUNDLED_SOURCE.json @@ -16,7 +16,7 @@ "from the signed channel. Run scripts/sync-bundled-drivers.sh to update." ], "repository": "srcfl/device-drivers", - "commit": "c9de25106b07071fde55c9f8f5e95b69c58e6fc9", + "commit": "3c0bc2acb730f7f921eb7457f1122491f7d7fd7f", "source_dir": "drivers/lua", "drivers": [ "ambibox_v2x", "ctek", "ctek_hybrid", "ctek_v2", "deye", "easee_cloud", diff --git a/go/internal/api/api_control_feedback.go b/go/internal/api/api_control_feedback.go index 0e7d1ced2..1b8b00d44 100644 --- a/go/internal/api/api_control_feedback.go +++ b/go/internal/api/api_control_feedback.go @@ -38,6 +38,7 @@ type ControlFeedback struct { VerificationTier *int `json:"-"` VerificationLost bool `json:"-"` Response string `json:"-"` + LimitedBy string `json:"-"` SiteBeforeW *float64 `json:"-"` SiteAfterW *float64 `json:"-"` SiteBeforeAtMs int64 `json:"-"` @@ -66,6 +67,7 @@ type feedbackReading struct { DeviceLimitA *float64 `json:"device_limit_a"` DeviceLimitAgeS *float64 `json:"device_limit_age_s"` Reason string `json:"reason_no_current_label"` + LimitedBy string `json:"current_limited_by"` Connected *bool `json:"connected"` Online *bool `json:"is_online"` } @@ -139,7 +141,7 @@ func (s *Server) controlFeedback(now time.Time) []ControlFeedback { f := ControlFeedback{Driver: rd.Driver, Kind: kind.String(), Mode: mode, State: "waiting", Reason: "no_command", Severity: "info", Response: "unconfirmed"} var d feedbackReading _ = json.Unmarshal(rd.Data, &d) - f.ReadbackW, f.OfferedA, f.DeviceReason = d.SetpointW, d.MaxA, d.Reason + f.ReadbackW, f.OfferedA, f.DeviceReason, f.LimitedBy = d.SetpointW, d.MaxA, d.Reason, d.LimitedBy if d.DeviceLimitAgeS != nil && *d.DeviceLimitAgeS >= 0 && *d.DeviceLimitAgeS <= 120 { f.DeviceLimitA = d.DeviceLimitA } @@ -337,7 +339,7 @@ func setControlStatus(f *ControlFeedback) { "vehicle_complete", "vehicle_limit_completion", "vehicle_not_requesting": status = "following" case "battery_full", "battery_nearly_full", "battery_nearly_empty", "core_limit", - "fuse_limit", "fuse_cooldown", "charger_limit": + "fuse_limit", "fuse_cooldown", "charger_limit", "load_balancer_limit": status = "limited" case "device_limit", "offered_current_lower": status, severity = "limited", "warning" @@ -472,6 +474,12 @@ func classifyControlFeedback(f *ControlFeedback, cmd telemetry.CommandEvidence, set("limited", reason, "info") return } + // The charger names its load balancer only while it holds the car + // back. More power than asked still warns. + if f.Kind == "ev" && f.LimitedBy == "load_balancer" && *f.SentW > 100 && *f.ActualW > -100 && *f.ActualW < *f.SentW { + set("limited", "load_balancer_limit", "info") + return + } reason := "power_above_target" switch { case math.Abs(*f.SentW) < 100: diff --git a/go/internal/api/api_control_scenarios_test.go b/go/internal/api/api_control_scenarios_test.go index 365957861..ba050afc7 100644 --- a/go/internal/api/api_control_scenarios_test.go +++ b/go/internal/api/api_control_scenarios_test.go @@ -272,6 +272,7 @@ func TestControlStatusAnswersEveryReason(t *testing.T) { "battery_full": {"limited", "info"}, "battery_nearly_full": {"limited", "info"}, "fuse_limit": {"limited", "info"}, + "load_balancer_limit": {"limited", "info"}, "device_limit": {"limited", "warning"}, "power_below_target": {"not_following", "warning"}, "setpoint_changed": {"not_following", "warning"}, @@ -366,3 +367,46 @@ func TestChangeOnlyChargerIgnoringNewTargetWarns(t *testing.T) { } }) } + +// A tester's Easee load balancer cut an 11 kW charge to 8.3 kW while the +// battery charged. The charger named its load balancer, so the shortfall is a +// known limit, not an unexplained warning. Without that name it still warns, +// and more power than asked is never explained by a limit. +func TestLoadBalancedChargerReadsAsLimited(t *testing.T) { + for _, tc := range []struct { + name, extra string + actualW float64 + status, reason, severity string + }{ + {"named", `,"current_limited_by":"load_balancer"`, 8300, "limited", "load_balancer_limit", "info"}, + {"held at zero", `,"current_limited_by":"load_balancer"`, 0, "limited", "load_balancer_limit", "info"}, + {"not named", ``, 8300, "not_following", "power_below_target", "warning"}, + {"above target", `,"current_limited_by":"load_balancer"`, 13000, "not_following", "power_above_target", "warning"}, + } { + t.Run(tc.name, func(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + tel := telemetry.NewStore() + tel.DriverHealthMut("easee") + srv := New(&Deps{Tel: tel}) + changedAt := time.Now() + var last ControlFeedback + for sec := range 6 * 60 { + if sec%60 == 0 { + c := tel.BeginCommand("easee", []byte(`{"action":"ev_set_current","power_w":11000}`), time.Now()) + tel.CompleteCommand(c, "accepted") + } + time.Sleep(time.Second) + if sec%10 == 0 { + data := fmt.Sprintf(`{"connected":true,"max_a":16,"control_power_observed_at":%q,"control_power_confirmed":true,"power_max_age_s":180%s}`, + changedAt.Format(time.RFC3339Nano), tc.extra) + tel.Update("easee", telemetry.DerEV, tc.actualW, nil, []byte(data)) + } + last = srv.controlFeedback(time.Now())[0] + } + if last.Status != tc.status || last.Reason != tc.reason || last.Severity != tc.severity { + t.Fatalf("got %s/%s/%s, want %s/%s/%s", last.Status, last.Reason, last.Severity, tc.status, tc.reason, tc.severity) + } + }) + }) + } +} diff --git a/web/control-feedback.js b/web/control-feedback.js index 95fda5faa..d41aca637 100644 --- a/web/control-feedback.js +++ b/web/control-feedback.js @@ -65,6 +65,7 @@ const REASON_TITLES = { battery_full: 'Battery full', battery_nearly_full: 'Battery nearly full', battery_nearly_empty: 'Battery nearly empty', core_limit: 'Limited by FTW', fuse_limit: 'Limited by the main fuse', fuse_cooldown: 'Waiting after a fuse limit', charger_limit: 'Limited by FTW’s charger setting', device_limit: 'Charger limit', offered_current_lower: 'Charger offers less current', + load_balancer_limit: 'Limited by the charger’s load balancer', device_fault: 'Device fault', telemetry_stale: 'No fresh readings', readings_lost: 'Lost control', command_failed: 'Commands failing', command_unconfirmed: 'Command not confirmed', default_failed: 'Cannot hand back control', response_unknown: 'No power reading', observe_only: 'Monitoring only', disabled: 'Disabled', device_control: 'Own control', @@ -97,6 +98,9 @@ function sentence(row) { case 'charger_limit': return 'The charger limit in FTW’s settings caps the rate.'; case 'device_limit': return `The charger’s own limit is ${amps(row.device_limit_a)}, below the ${amps(row.requested_a)} FTW asked for.`; case 'offered_current_lower': return `The charger offers ${amps(row.offered_a)}, below the ${amps(row.requested_a)} FTW asked for.`; + case 'load_balancer_limit': return num(actual) && actual >= 100 + ? `Asked for ${target(row)}, delivering ${controlPower(actual)}. The charger’s load balancer is limiting the car.` + : `Asked for ${target(row)}, but the charger’s load balancer gives the car no current.`; case 'power_below_target': case 'power_above_target': return `Asked for ${target(row)}, delivering ${controlPower(actual)}.`; case 'power_wrong_direction': return `Asked for ${target(row)}, but it is ${doing(row, actual)} ${controlPower(actual)}.`; case 'no_power_response': return `Asked for ${target(row)}, but it delivers no power.`; @@ -133,6 +137,7 @@ function nextStep(row) { case 'site_meter_stale': case 'site_phase_currents_stale': return 'Check the grid meter’s connection.'; case 'device_limit': return 'Raise the limit in the charger’s app if you want faster charging.'; case 'offered_current_lower': return 'Check the charger’s load balancing and limits.'; + case 'load_balancer_limit': return 'The load balancer raises the current again when the house uses less. If this happens often, check that FTW’s main fuse setting matches the load balancer’s.'; case 'observe_only': return 'Turn on control in the device settings if you want FTW to steer it.'; default: return ''; } diff --git a/web/control-feedback.test.mjs b/web/control-feedback.test.mjs index 00654f005..8e1c807bd 100644 --- a/web/control-feedback.test.mjs +++ b/web/control-feedback.test.mjs @@ -59,6 +59,14 @@ test('expected limits stay calm and explain themselves', () => { assert.equal(car.title, 'Car is full'); const limit = controlStatus(row({kind: 'ev', status: 'limited', reason: 'device_limit', severity: 'warning', device_limit_a: 8, requested_a: 16})); assert.equal(limit.text, 'The charger’s own limit is 8 A, below the 16 A FTW asked for.'); + const balanced = controlStatus(row({kind: 'ev', mode: 'plan', status: 'limited', reason: 'load_balancer_limit', sent_w: 11000, actual_w: 8300})); + assert.equal(balanced.title, 'Limited by the charger’s load balancer'); + assert.equal(balanced.text, 'Asked for 11.0 kW charge, delivering 8.3 kW. The charger’s load balancer is limiting the car.'); + assert.match(balanced.next, /main fuse setting matches the load balancer/); + assert.doesNotMatch(balanced.next, /has not said why/); + assert.equal(balanced.tone, 'neutral'); + const held = controlStatus(row({kind: 'ev', status: 'limited', reason: 'load_balancer_limit', sent_w: 11000, actual_w: 0})); + assert.equal(held.text, 'Asked for 11.0 kW charge, but the charger’s load balancer gives the car no current.'); }); test('the receipt walks from sent to confirmed without inventing proof', () => {