Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
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/easee-load-balancer-limit.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion drivers/BUNDLED_SOURCE.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
12 changes: 10 additions & 2 deletions go/internal/api/api_control_feedback.go
Original file line number Diff line number Diff line change
Expand Up @@ -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:"-"`
Expand Down Expand Up @@ -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"`
}
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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:
Expand Down
44 changes: 44 additions & 0 deletions go/internal/api/api_control_scenarios_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
Expand Down Expand Up @@ -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)
}
})
})
}
}
5 changes: 5 additions & 0 deletions web/control-feedback.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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.`;
Expand Down Expand Up @@ -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 '';
}
Expand Down
8 changes: 8 additions & 0 deletions web/control-feedback.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
Loading