diff --git a/CHANGELOG.md b/CHANGELOG.md index cd9bd2b..709e8ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## easee_cloud 1.3.7 + +- Keep a load-balancing ReasonForNoCurrent while the car charges, when the per-phase current is at least 2 A below the offer. Easee records the reason when it changes, so a limit that began before the last power change was dropped and FTW said the charger had given no reason. The offer is observation 48, or FTW's last write when that is missing. Other old reasons are still dropped while the car charges. +- Report `current_limited_by = "load_balancer"` for codes 1–5, 10 and 25–30 while a car is connected. Core reads this name, not Easee's codes. The charger's own limits (77, 78) and the car are not the load balancer. +- Match the labels for codes 6, 8–11, 25–30 and 77 to Easee's published table. Code 28 read "fuse limit reached"; Easee calls it "Current limited by Equalizer". The enumeration page joins `upstream_docs`. +- Not yet seen on hardware. Easee's table defines the "Current limited by …" codes, but no raw observation from a load-balanced charger has been captured. + ## Tests: no invented numbers - `drivers/tests/lua_harness/no_invented_numbers.lua` runs a driver twice with different registers, config and clock, then makes every read fail and every Modbus reply short. A number the driver still emits that is equal in both runs and was not read is invented: 0 W, 0 A, 0 % SoC, 50 Hz. A failed read must leave the value nil, so the host sends null. Sourceful's driver registry runs the same file on every publish. diff --git a/SUPPORT_STATUS.md b/SUPPORT_STATUS.md index bc95e00..684bb0e 100644 --- a/SUPPORT_STATUS.md +++ b/SUPPORT_STATUS.md @@ -46,8 +46,8 @@ Catalog source is not proof that a target can install or run a driver. | deye | 2.1.1 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | | easee | 1.0.4 | ftw-core | not_assessed | — | not_recorded | not_assessed | | easee | 1.0.4 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | -| easee_cloud | 1.3.6 | ftw-core | not_assessed | — | not_recorded | not_assessed | -| easee_cloud | 1.3.6 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | +| easee_cloud | 1.3.7 | ftw-core | not_assessed | — | not_recorded | not_assessed | +| easee_cloud | 1.3.7 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | | esphome_dsmr | 1.0.7 | ftw-core | not_assessed | — | not_recorded | not_assessed | | esphome_dsmr | 1.0.7 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | | etrel | 1.0.3 | ftw-core | not_assessed | — | not_recorded | not_assessed | diff --git a/devices.yaml b/devices.yaml index 95cdd73..d67b63d 100644 --- a/devices.yaml +++ b/devices.yaml @@ -338,7 +338,7 @@ manufacturers: protocols: - protocol: http driver: "easee_cloud" - version: "1.3.6" + version: "1.3.7" ders: [ev] control: true firmware_versions: "" diff --git a/docs/WRITING-A-DRIVER.md b/docs/WRITING-A-DRIVER.md index f75acdb..aa717a8 100644 --- a/docs/WRITING-A-DRIVER.md +++ b/docs/WRITING-A-DRIVER.md @@ -399,6 +399,11 @@ all modes. They never grant control or replace a safety limit: from the dynamic offer in `max_a`. `device_limit_age_s` is time since the successful settings read. Failed reads must not reset its age. Core stops treating it as a current limit after two minutes. +- `current_limited_by`: `"load_balancer"` when the charger says its own + load balancing, a separate load-balancing unit or a partner's circuit + limit holds the car below the offer or at zero. Leave it nil for the + charger's own limits and for the car. Core reports the car as limited + only when the measured power also falls short of its command. A device reading makes a response measured, nothing more. Core calls it confirmed only when an identified, separate site meter shows a matching diff --git a/drivers/lua/easee_cloud.lua b/drivers/lua/easee_cloud.lua index f8a04fe..aec0a84 100644 --- a/drivers/lua/easee_cloud.lua +++ b/drivers/lua/easee_cloud.lua @@ -25,7 +25,7 @@ DRIVER = { id = "easee_cloud", name = "Easee Cloud", manufacturer = "Easee", - version = "1.3.6", + version = "1.3.7", protocols = { "http" }, capabilities = { "ev" }, description = "Easee Home/Charge via Cloud REST API. No local protocol needed.", @@ -421,18 +421,18 @@ local REASON_LABELS = { [3] = "offline fallback circuit current too low", [4] = "circuit fuse too low", [5] = "waiting in queue", - [6] = "waiting (other cars fully charged)", + [6] = "waiting in fully charged queue", [7] = "illegal grid type", - [8] = "no current request from primary", - [9] = "max dynamic charger current too low", - [10] = "phase imbalance", - [11] = "equalizer communication lost", - [25] = "equalizer dynamic limit too low", - [26] = "equalizer static limit too low", - [27] = "offline fallback equalizer too low", - [28] = "fuse limit reached", - [29] = "current limited by equalizer", - [30] = "current limited by offline equalizer", + [8] = "no current request from car", + [9] = "master communication lost", + [10] = "equalizer current too low", + [11] = "phase not connected", + [25] = "current limited by circuit fuse", + [26] = "current limited by circuit max current", + [27] = "current limited by dynamic circuit current", + [28] = "current limited by equalizer", + [29] = "current limited by circuit load balancing", + [30] = "current limited by offline settings", [50] = "secondary unit not requesting current", [51] = "max charger current too low", [52] = "max dynamic charger current too low", @@ -443,7 +443,7 @@ local REASON_LABELS = { [57] = "erratic EV", [75] = "limited by cable rating", [76] = "limited by schedule", - [77] = "limited by charger current", + [77] = "limited by charger max current", [78] = "limited by dynamic charger current", [79] = "car not drawing current", [80] = "current ramping", @@ -457,6 +457,20 @@ local REASON_LABELS = { [100] = "EV not accepting current", } +-- Reasons in which Easee's circuit load balancing, an Equalizer or a +-- partner's dynamic circuit current holds the car back. Easee's table +-- words 25-30 "Current limited by ...", so the car may still charge below +-- the offer. 77 and 78 are the charger's own limits, which Core already +-- reads as device_limit_a and max_a. +local LOAD_BALANCER_REASONS = { + [1] = true, [2] = true, [3] = true, [4] = true, [5] = true, [10] = true, + [25] = true, [26] = true, [27] = true, [28] = true, [29] = true, [30] = true, +} + +-- A car may draw a little less than the offer, and the per-phase current +-- below comes from total power and one voltage. A smaller gap shows nothing. +local LIMIT_GAP_A = 2 + local email, password, configured_max_a local device_limit_a, device_limit_read_ms local settings_poll_ms = 0 @@ -621,14 +635,6 @@ function driver_poll() last_power_observed_at = power_observed_at end - local reason_code = obs[OBS_REASON_NO_CUR] - -- ReasonForNoCurrent describes a blocked offer. An older reason is not - -- a current fault while the charger reports both charging and power. - local reason_at = normalized_session_start(timestamps[OBS_REASON_NO_CUR]) - local power_at = normalized_session_start(timestamps[OBS_TOTAL_POWER]) - if charging and power_w > 100 and reason_at and power_at and reason_at <= power_at then - reason_code = nil - end local cable_locked = obs[OBS_CABLE_LOCKED] if cable_locked ~= nil then cable_locked = (cable_locked == 1 or cable_locked == true) end local dyn_current = obs[OBS_DYN_CURRENT] @@ -652,6 +658,27 @@ function driver_poll() actual_amps_per_phase = power_w / vv / phases end + local reason_code = obs[OBS_REASON_NO_CUR] + -- ReasonForNoCurrent describes a blocked offer. An older reason is not + -- a current fault while the charger reports both charging and power, + -- unless it is a load-balancing limit and the car still draws well + -- below the offer. Easee records the reason only when it changes, so a + -- limit set before the last power change can still hold. + local reason_at = normalized_session_start(timestamps[OBS_REASON_NO_CUR]) + local power_at = normalized_session_start(timestamps[OBS_TOTAL_POWER]) + if charging and power_w > 100 and reason_at and power_at and reason_at <= power_at then + local offer = tonumber(dyn_current) or last_amps_set + local held_back = LOAD_BALANCER_REASONS[reason_code] and offer and actual_amps_per_phase and + actual_amps_per_phase < offer - LIMIT_GAP_A + if not held_back then reason_code = nil end + end + -- A vendor-neutral name for Core. Core still compares the measured power + -- with its own command before it reports the car as limited. + local current_limited_by = nil + if connected and LOAD_BALANCER_REASONS[reason_code] then + current_limited_by = "load_balancer" + end + -- command_stalled: true when we've been offering >0 A for >30 s but -- the charger isn't drawing AND Easee is reporting an EV-side or -- "max dynamic too low" reason. Lets the controller / UI tell the @@ -714,6 +741,7 @@ function driver_poll() state_label = OP_MODE_LABELS[op_mode] or "unknown", reason_no_current = reason_code, -- int: 0=ok; why NOT drawing current reason_no_current_label = reason_code and REASON_LABELS[reason_code], -- nil if 0/ok, string otherwise + current_limited_by = current_limited_by, -- "load_balancer" or nil is_online = is_online, cable_locked = cable_locked, device_limit_a = device_limit_a, diff --git a/drivers/tests/lua_harness/test_easee_cloud_load_balancer.lua b/drivers/tests/lua_harness/test_easee_cloud_load_balancer.lua new file mode 100644 index 0000000..fba1627 --- /dev/null +++ b/drivers/tests/lua_harness/test_easee_cloud_load_balancer.lua @@ -0,0 +1,94 @@ +dofile("drivers/tests/lua_harness/host_mock.lua") +-- Easee's load balancer can cut a charging car below the offer. The reason +-- stays recorded from when the limit began, which may be before the last +-- power change. A limit that still explains the shortfall must reach Core. +local driver = "drivers/lua/easee_cloud.lua" + +local function boot() + host.reset() + host._millis_step = 0 + host._http_responses["/accounts/login"] = '{"accessToken":"test","expiresIn":3600}' + host._http_responses["/config"] = '{"maxChargerCurrent":16}' + host._http_responses["/sessions/ongoing"] = '{}' + host._http_responses["/settings"] = '{}' + host._http_responses["/commands/"] = 'null' + dofile(driver) + driver_init({email="test@example.invalid", password="test", serial="TEST123"}) +end + +-- reason_time before power_time: the reason predates the last power change. +local function poll(mode, power_kw, reason, reason_time, offer_a) + local obs = { + {id=109, value=mode, timestamp="2026-10-08T05:00:00Z"}, + {id=120, value=power_kw, timestamp="2026-10-08T05:10:00Z"}, + {id=121, value=12.5, timestamp="2026-10-08T05:10:00Z"}, + {id=194, value=230, timestamp="2026-10-08T05:00:00Z"}, + {id=96, value=reason, timestamp=reason_time}, + } + if offer_a ~= nil then table.insert(obs, {id=48, value=offer_a, timestamp="2026-10-08T05:00:00Z"}) end + host._http_responses["/observations?ids="] = host.json_encode(obs) + driver_poll() + local rows = host._emitted.ev + assert(rows and #rows > 0, "no EV sample") + return rows[#rows] +end + +local OLD, NEW = "2026-10-08T05:05:00Z", "2026-10-08T05:15:00Z" + +-- The reported case: 16 A offered, 8.3 kW on three phases is 12 A per phase. +boot() +local cut = poll(3, 8.3, 29, OLD, 16) +assert(cut.reason_no_current == 29, "an active load-balancing limit was dropped while charging") +assert(cut.reason_no_current_label == "current limited by circuit load balancing", + "label does not match Easee's table: " .. tostring(cut.reason_no_current_label)) +assert(cut.current_limited_by == "load_balancer", "Core was not told the load balancer limits the car") + +-- Easee's Equalizer and a partner's dynamic circuit current are load balancing too. +local eq = poll(3, 8.3, 28, OLD, 16) +assert(eq.current_limited_by == "load_balancer" and eq.reason_no_current_label == "current limited by equalizer", + "an Equalizer limit was not reported") +assert(poll(3, 8.3, 27, OLD, 16).current_limited_by == "load_balancer", "a dynamic circuit limit was not reported") + +-- Drawing the whole offer: the old reason no longer explains anything. +local full = poll(3, 11.0, 29, OLD, 16) +assert(full.reason_no_current == nil and full.current_limited_by == nil, + "an old limit was reported while the car drew the whole offer") + +-- A gap under 2 A is within what a car and the estimate may differ by. +assert(poll(3, 9.8, 29, OLD, 16).current_limited_by == nil, "a small gap was read as a limit") + +-- An old reason that is not load balancing keeps the existing rule. +local other = poll(3, 8.3, 52, OLD, 16) +assert(other.reason_no_current == nil and other.current_limited_by == nil, + "an old non-balancing reason was reported while charging") + +-- Without a known offer there is nothing to measure the shortfall against. +boot() +local unknown = poll(3, 8.3, 29, OLD, nil) +assert(unknown.reason_no_current == nil and unknown.current_limited_by == nil, + "an old limit was kept without a known offer") + +-- FTW's own last write stands in for a missing offer readback. +assert(driver_command("ev_set_current", 11000, {phase_mode="3p", voltage=230, max_amps_per_phase=16}), + "ev_set_current failed") +local written = poll(3, 8.3, 29, OLD, nil) +assert(written.current_limited_by == "load_balancer", "FTW's last offer was not used as the offer") + +-- A reason newer than the last power change holds as before. +boot() +assert(poll(3, 8.3, 29, NEW, 16).current_limited_by == "load_balancer", "a new limit was dropped") + +-- No current at all: the load balancer holds a connected car at zero. +local held = poll(2, 0, 2, OLD, 16) +assert(held.reason_no_current == 2 and held.current_limited_by == "load_balancer", + "a car held at zero by the load balancer was not reported") +assert(poll(2, 0, 5, OLD, 16).current_limited_by == "load_balancer", "a load-balancing queue was not reported") + +-- Charger and car limits are not the load balancer. +assert(poll(2, 0, 52, OLD, 16).current_limited_by == nil, "a charger limit was named the load balancer") +assert(poll(2, 0, 100, OLD, 16).current_limited_by == nil, "a car refusal was named the load balancer") + +-- No car, no limit. +assert(poll(1, 0, 29, NEW, 16).current_limited_by == nil, "an unplugged charger reported a limit") + +print("Easee load balancer: passed") diff --git a/drivers/tests/test_easee_cloud_load_balancer.py b/drivers/tests/test_easee_cloud_load_balancer.py new file mode 100644 index 0000000..f8ca135 --- /dev/null +++ b/drivers/tests/test_easee_cloud_load_balancer.py @@ -0,0 +1,13 @@ +"""A charging Easee car cut by the load balancer must say so.""" +from pathlib import Path +import subprocess + + +def test_easee_cloud_reports_load_balancer_limit_while_charging(): + root = Path(__file__).resolve().parents[2] + result = subprocess.run( + [str(root / "lua55"), "drivers/tests/lua_harness/test_easee_cloud_load_balancer.lua"], + cwd=root, text=True, capture_output=True, check=False, + ) + assert result.returncode == 0, result.stdout + result.stderr + assert "Easee load balancer: passed" in result.stdout diff --git a/index.yaml b/index.yaml index 2dfe701..9790fbb 100644 --- a/index.yaml +++ b/index.yaml @@ -193,15 +193,15 @@ drivers: size_bytes: 4054 sha256: "4a2cd1efb4a5583468ce897ebd88a000e348917295c40210e86ecf834c0ba543" - name: "easee_cloud" - version: "1.3.6" + version: "1.3.7" tier: core protocol: http connectivity: cloud setup: [vendor_portal] ders: [ev] control: true - size_bytes: 41070 - sha256: "4f33149c0640c2e9d2c043e36fdf967d8b297f07da56aec934c21e81a397d22b" + size_bytes: 42628 + sha256: "8b1b9f980178af96fb897f275fbcd3b6a7f9a8df340f4907a9216f1596c849e4" - name: "esphome_dsmr" version: "1.0.7" tier: core diff --git a/manifests/easee_cloud.yaml b/manifests/easee_cloud.yaml index b50d5a4..8836f4b 100644 --- a/manifests/easee_cloud.yaml +++ b/manifests/easee_cloud.yaml @@ -1,5 +1,5 @@ name: "easee_cloud" -version: "1.3.6" +version: "1.3.7" tier: core author: "Sourceful Labs AB" protocol: http @@ -16,9 +16,9 @@ tested_devices: notes: "Easee Home/Charge via Cloud REST API. No local protocol needed." min_driver_version: "1.0.1" min_host_version: "2.0.0" -size_bytes: 41070 +size_bytes: 42628 dkb_id: "easee_cloud" -sha256: "4f33149c0640c2e9d2c043e36fdf967d8b297f07da56aec934c21e81a397d22b" +sha256: "8b1b9f980178af96fb897f275fbcd3b6a7f9a8df340f4907a9216f1596c849e4" signature: "" bytecode_sha256: "" @@ -28,6 +28,10 @@ upstream_docs: title: "Easee charger observations" kind: api_docs url_stability: stable + - url: "https://developer.easee.com/docs/enumerations" + title: "Easee enumerations (ReasonForNoCurrent)" + kind: api_docs + url_stability: stable - url: "https://developer.easee.com/reference/chargers_getongoingsessiondetails" title: "Easee ongoing session API" kind: api_docs diff --git a/spec/host-api-profile.json b/spec/host-api-profile.json index 18afd0f..2f8ce59 100644 --- a/spec/host-api-profile.json +++ b/spec/host-api-profile.json @@ -213,7 +213,8 @@ "control_power_observed_at", "control_power_confirmed", "device_limit_a", - "device_limit_age_s" + "device_limit_age_s", + "current_limited_by" ] }, "repeating_structures": "A repeating structure is a plural-named array, not numbered keys. pv.mppts is a list of {V, A, W} whose length is what the device physically has. The catalog's mppt1_v/mppt2_v cannot represent a four-MPPT inverter at all.", diff --git a/support-status.json b/support-status.json index e822c1c..b708e19 100644 --- a/support-status.json +++ b/support-status.json @@ -443,7 +443,7 @@ }, { "catalog_source": true, - "catalog_version": "1.3.6", + "catalog_version": "1.3.7", "driver_id": "easee_cloud", "targets": { "blixt-l1": {