diff --git a/.changeset/easee-load-balancer-limit.md b/.changeset/easee-load-balancer-limit.md new file mode 100644 index 00000000..753c3ae0 --- /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 32ba7bbc..2b2235cf 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 0e7d1ced..1b8b00d4 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 36595786..ba050afc 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 95fda5fa..d41aca63 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 00654f00..8e1c807b 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', () => {