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 350fe876b..cf4aefca8 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": "5cc7bb1a65fe41c4f2cb28a7f546875313b2df85", + "commit": "aa0ee46ac071ae8400657b463845735316263cdc", "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 f1547bd54..03265872c 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" @@ -416,10 +418,12 @@ func classifyControlFeedback(f *ControlFeedback, cmd telemetry.CommandEvidence, return } // A positive clamp explains the lower offer, not prolonged zero delivery. - // Keep known safety pauses and unconfirmed/stale commands authoritative. + // An explicit load-balancer reason can explain zero delivery. Keep known + // safety pauses and unconfirmed/stale commands authoritative. evMismatchObserved := !cmd.PowerMismatchSince.IsZero() && cmd.LastObservation.Sub(cmd.PowerMismatchSince) >= 2*time.Minute noPowerObserved := evMismatchObserved || !cmd.NoPowerSince.IsZero() && cmd.NoPowerAt.Sub(cmd.NoPowerSince) >= 2*time.Minute if f.Kind == "ev" && commanded && cmd.Result == "accepted" && + f.LimitedBy != "load_balancer" && coreReason != "site_meter_stale" && coreReason != "site_phase_currents_stale" && coreReason != "fuse_cooldown" && f.SentW != nil && *f.SentW > 100 && f.ActualW != nil && math.Abs(*f.ActualW) < 100 && f.OfferedA != nil && *f.OfferedA > 0 && noPowerObserved { @@ -487,6 +491,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..46d7d4e2e 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,47 @@ 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"}, + {"zero without a named limiter", ``, 0, "not_following", "no_power_response", "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 18a184e63..dcdbbfed0 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 site power limits', fuse_cooldown: 'Waiting after a site power 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 bdef3a5ea..317df0962 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', () => {