From 462fa2c6907b2ea38c430d113c9a3a6959124a9b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=B6dkolven?= Date: Thu, 22 Oct 2026 17:47:35 +0200 Subject: [PATCH 1/4] fix(easee_cloud): start a 6 A order at 7 A (1.3.7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Lödkolven --- CHANGELOG.md | 14 ++ SUPPORT_STATUS.md | 4 +- devices.yaml | 2 +- drivers/lua/easee_cloud.lua | 81 ++++++++++- .../test_easee_cloud_start_boost.lua | 128 ++++++++++++++++++ drivers/tests/test_easee_cloud_start_boost.py | 12 ++ index.yaml | 6 +- manifests/easee_cloud.yaml | 6 +- support-status.json | 2 +- 9 files changed, 241 insertions(+), 14 deletions(-) create mode 100644 drivers/tests/lua_harness/test_easee_cloud_start_boost.lua create mode 100644 drivers/tests/test_easee_cloud_start_boost.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 50ebcc5..bf0f7e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,20 @@ ## [Unreleased] +### easee_cloud 1.3.7 + +Start a 6 A order at 7 A. On an Easee Home with an Audi e-tron (three +phases), a resume at 6 A gave a positive pilot only 5 min 2 s to 5 min 47 s +later in four runs on 22 October 2026, also with a single resume; 7 A and +then 6 A started at once. evcc reports the same delay at 6 A. The driver +now offers 7 A per phase when a stopped charger gets a 6 A order, and only +when the order's `max_amps_per_phase` and the charger's static limit both +allow 7 A. It writes 6 A as soon as the charger reports charging, or after +90 s. One boost per start; a running session lowered to 6 A is not raised, +orders above 6 A are unchanged, and no extra resume is sent. +`start_boost: false` turns it off. Tested in the Lua harness; not yet on +hardware with this driver. + ### home_assistant_vehicle 0.1.0 A new read-only driver for a car whose level Home Assistant already reads. A diff --git a/SUPPORT_STATUS.md b/SUPPORT_STATUS.md index ab20856..f05acf0 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 0d32524..f0a2249 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/drivers/lua/easee_cloud.lua b/drivers/lua/easee_cloud.lua index f8a04fe..3351e2c 100644 --- a/drivers/lua/easee_cloud.lua +++ b/drivers/lua/easee_cloud.lua @@ -18,6 +18,7 @@ -- email: "user@example.com" -- password: "secret" -- serial: "EHHZBKPF" # optional — auto-detected if omitted +-- start_boost: false # optional — disable the 7 A start for 6 A orders DRIVER = { host_api_min = 1, @@ -25,7 +26,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.", @@ -87,6 +88,23 @@ local command_stalled_since_ms = 0 local EASEE_MIN_A = 6 local EASEE_MAX_A = 32 +-- 6 A start boost. Field data (22 Oct 2026, Easee Home + Audi e-tron, 3Φ, +-- four runs): after resume_charging with dynamicChargerCurrent=6 the +-- charger reported a positive pilot only 5 min 2 s – 5 min 47 s later, even +-- with a single resume. Starting at 7 A and lowering to 6 A once the car +-- drew current started at once. evcc reports the same ~5 min firmware +-- delay and clamps the dynamic current to 7 A before its start command. +-- Here the boost is one amp per phase, held only until the charger reports +-- charging (op_mode 3) or START_BOOST_MAX_MS passes, then 6 A. It is only +-- used when the command's fuse ceiling and the charger's static limit both +-- allow 7 A, and at most once per start. `start_boost: false` disables it. +local START_BOOST_A = EASEE_MIN_A + 1 +local START_BOOST_MAX_MS = 90000 +local start_boost_enabled = true +local boost_until_ms = nil -- non-nil while 7 A is offered for a 6 A order +local boost_spent = false -- one boost per start; reset by a 0 A order or a stop +local last_op_mode = nil -- op_mode from the latest successful poll + -- pick_phases is the driver-level phase decision: given the requested -- charging power, the per-phase fuse ceiling, the operator's mode -- preference, and the time since the last flip, return how many @@ -525,6 +543,10 @@ function driver_init(config) end end + if config and (config.start_boost == false or config.start_boost == "false") then + start_boost_enabled = false + end + if not email or not password then host.log("error", "Easee: email and password required in driver config") return @@ -748,6 +770,29 @@ function driver_poll() end end + -- 6 A start boost: step down to the ordered 6 A as soon as the charger + -- reports charging, or when the boost window ends without it. A failed + -- write keeps the boost pending so the next poll retries the step-down. + if boost_until_ms ~= nil then + if charging or now >= boost_until_ms then + local werr = write_setting(charger_serial, {dynamicChargerCurrent = EASEE_MIN_A}) + if werr == nil then + host.log("info", "Easee: start boost ended (" .. + (charging and "charging" or "timeout") .. "), dynamicChargerCurrent=6 A") + boost_until_ms = nil + last_amps_set = EASEE_MIN_A + else + host.log("warn", "Easee: start boost step-down failed (will retry next poll): " .. + redact_http_err(werr)) + end + end + end + -- A session that stops charging may need a new boost on its next start. + if last_op_mode == 3 and not charging and boost_until_ms == nil then + boost_spent = false + end + last_op_mode = op_mode + if obs[OBS_VOLTAGE] then host.emit_metric("ev_voltage_v", obs[OBS_VOLTAGE]) end @@ -791,7 +836,11 @@ function driver_command(action, power_w, cmd) return ok elseif action == "ev_pause" then local ok = post_command("/commands/pause_charging") - if ok then paused_state = true end + if ok then + paused_state = true + boost_until_ms = nil + boost_spent = false + end return ok elseif action == "ev_resume" then local ok = post_command("/commands/resume_charging") @@ -853,9 +902,31 @@ function driver_command(action, power_w, cmd) end local amps = per_phase_amps(power_w, voltage, phases, max_a) - local err = write_setting(charger_serial, {dynamicChargerCurrent = amps}) + local write_a = amps + local boosting = false + if amps == EASEE_MIN_A then + if boost_until_ms ~= nil then + -- Still within the boost: keep 7 A, do not restart the window. + write_a, boosting = START_BOOST_A, true + elseif start_boost_enabled and not boost_spent and last_op_mode ~= 3 and + max_a ~= nil and math.floor(max_a) >= START_BOOST_A and + (device_limit_a == nil or device_limit_a >= START_BOOST_A) then + write_a, boosting = START_BOOST_A, true + end + end + local err = write_setting(charger_serial, {dynamicChargerCurrent = write_a}) if err == nil then - last_amps_set = amps + if boosting then + if boost_until_ms == nil then + boost_until_ms = now_ms + START_BOOST_MAX_MS + boost_spent = true + host.log("info", "Easee: start boost 7 A for a 6 A start (Easee delays ~5 min at 6 A)") + end + else + boost_until_ms = nil + if amps == 0 then boost_spent = false end + end + last_amps_set = write_a -- After a phaseMode change, schedule one re-write of -- dynamicChargerCurrent on the next driver_poll. Easee's -- cloud resets it to maxChargerCurrent during the phase @@ -904,6 +975,8 @@ function driver_default_mode() if post_command("/commands/pause_charging") then paused_state = true end + boost_until_ms = nil + boost_spent = false local err = write_setting(charger_serial, {dynamicChargerCurrent = 0}) if err == nil then last_amps_set = 0 diff --git a/drivers/tests/lua_harness/test_easee_cloud_start_boost.lua b/drivers/tests/lua_harness/test_easee_cloud_start_boost.lua new file mode 100644 index 0000000..1c80185 --- /dev/null +++ b/drivers/tests/lua_harness/test_easee_cloud_start_boost.lua @@ -0,0 +1,128 @@ +-- A 6 A start is offered 7 A until the charger charges, then 6 A. +dofile("drivers/tests/lua_harness/host_mock.lua") +local driver = "drivers/lua/easee_cloud.lua" + +local function boot(cfg, config_body) + host.reset() + host._http_responses["/accounts/login"] = '{"accessToken":"test","expiresIn":3600}' + host._http_responses["/config"] = config_body or '{"maxChargerCurrent":16}' + host._http_responses["/settings"] = '{}' + host._http_responses["/commands/"] = '{}' + host._http_responses["/sessions/ongoing"] = '{}' + dofile(driver) + local c = {email="test@example.invalid", password="test", serial="TEST123"} + for k, v in pairs(cfg or {}) do c[k] = v end + driver_init(c) +end + +local function amps_writes() + local out = {} + for _, call in ipairs(host._calls) do + if call.func == "http_post" and call.args[1]:find("/settings", 1, true) then + local body = host.json_decode(call.args[2]) + if body.dynamicChargerCurrent ~= nil then table.insert(out, body.dynamicChargerCurrent) end + end + end + return out +end +local function last_amps() local w = amps_writes(); return w[#w] end +local function count_cmd(path) + local n = 0 + for _, call in ipairs(host._calls) do + if call.func == "http_post" and call.args[1]:find(path, 1, true) then n = n + 1 end + end + return n +end + +local function poll(mode, kw) + host._http_responses["/observations?ids="] = host.json_encode({ + {id=109,value=mode}, {id=120,value=kw or 0}, {id=121,value=1}, {id=124,value=100}, + }) + driver_poll() +end + +local SIX = {phase_mode="3p", voltage=230, max_amps_per_phase=25} +local function order(w, cmd) return driver_command("ev_set_current", w, cmd or SIX) end + +-- 1. Bump on a 6 A start, hold through repeated orders, step down once charging. +boot() +poll(2) +assert(order(4140)) +assert(last_amps() == 7, "6 A start was not boosted to 7 A") +assert(order(4140)); assert(last_amps() == 7, "boost dropped before charging") +poll(2) +assert(last_amps() == 7, "boost dropped while still awaiting start") +poll(3, 4.8) +assert(last_amps() == 6, "no step-down to 6 A after charging was seen") +assert(order(4140)); assert(last_amps() == 6, "boost repeated during a running session") +assert(count_cmd("/commands/resume_charging") == 0, "boost sent a resume") + +-- 2. No boost when the fuse ceiling leaves no amp above 6. +boot() +poll(2) +assert(order(4140, {phase_mode="3p", voltage=230, max_amps_per_phase=6.9})) +assert(last_amps() == 6, "boost ignored a 6 A fuse ceiling") +-- ...nor when Core gives no ceiling at all. +boot() +poll(2) +assert(order(4140, {phase_mode="3p", voltage=230})) +assert(last_amps() == 6, "boost without a known fuse ceiling") +-- ...nor when the charger's own static limit is 6 A. +boot(nil, '{"maxChargerCurrent":6}') +poll(2) +assert(order(4140)) +assert(last_amps() == 6, "boost above the charger's static limit") + +-- 3. No boost when more than 6 A is ordered. +boot() +poll(2) +assert(order(5520)); assert(last_amps() == 8, "8 A order changed") +assert(order(10350)); assert(last_amps() == 15, "15 A order changed") + +-- 4. Timeout: no charging within the window falls back to 6 A, once. +boot() +poll(2) +assert(order(4140)); assert(last_amps() == 7) +host._millis_counter = host._millis_counter + 91000 +poll(2) +assert(last_amps() == 6, "boost outlived its window") +assert(order(4140)); assert(last_amps() == 6, "boost restarted after timeout") + +-- 5. A higher order during the boost cancels it; a pause re-arms it. +boot() +poll(2) +assert(order(4140)); assert(last_amps() == 7) +assert(order(6900)); assert(last_amps() == 10, "higher order lost during boost") +host._millis_counter = host._millis_counter + 91000 +poll(2) +assert(last_amps() == 10, "cancelled boost still stepped down") +assert(driver_command("ev_pause", 0, {})) +assert(order(4140)); assert(last_amps() == 7, "pause did not re-arm the boost") + +-- 6. A charging session lowered to 6 A is not boosted. +boot() +poll(3, 6.9) +assert(order(4140)); assert(last_amps() == 6, "running session was boosted") + +-- 7. Disabled by config. +boot({start_boost=false}) +poll(2) +assert(order(4140)); assert(last_amps() == 6, "start_boost=false ignored") + +-- 8. 1Φ: the boost is per phase too (6 A → 7 A on one phase). +boot() +poll(2) +assert(order(1380, {phase_mode="1p", voltage=230, max_amps_per_phase=16})) +assert(last_amps() == 7, "1p 6 A start not boosted") + +-- 9. Default mode clears the boost. +boot() +poll(2) +assert(order(4140)); assert(last_amps() == 7) +driver_default_mode() +assert(last_amps() == 0) +host._millis_counter = host._millis_counter + 91000 +poll(2) +assert(last_amps() == 0, "default mode left a pending boost step-down") + +print("easee_cloud start boost: ok") diff --git a/drivers/tests/test_easee_cloud_start_boost.py b/drivers/tests/test_easee_cloud_start_boost.py new file mode 100644 index 0000000..cfba893 --- /dev/null +++ b/drivers/tests/test_easee_cloud_start_boost.py @@ -0,0 +1,12 @@ +"""A 6 A Easee start is offered 7 A until it charges, then 6 A.""" +from pathlib import Path +import subprocess + + +def test_easee_cloud_start_boost(): + root = Path(__file__).resolve().parents[2] + result = subprocess.run( + [str(root / "lua55"), "drivers/tests/lua_harness/test_easee_cloud_start_boost.lua"], + cwd=root, text=True, capture_output=True, check=False, + ) + assert result.returncode == 0, result.stdout + result.stderr diff --git a/index.yaml b/index.yaml index e853918..e6c143c 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: 44640 + sha256: "bc90198531f41da272b9e3641d22b261c9edf2031120b0184c334e5290ad3a40" - name: "esphome_dsmr" version: "1.0.7" tier: core diff --git a/manifests/easee_cloud.yaml b/manifests/easee_cloud.yaml index b50d5a4..1af6fef 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: 44640 dkb_id: "easee_cloud" -sha256: "4f33149c0640c2e9d2c043e36fdf967d8b297f07da56aec934c21e81a397d22b" +sha256: "bc90198531f41da272b9e3641d22b261c9edf2031120b0184c334e5290ad3a40" signature: "" bytecode_sha256: "" diff --git a/support-status.json b/support-status.json index b511b34..c9af28f 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": { From 323963d716001af70eebdb12a2ca92cb23a2c816 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=B6dkolven?= Date: Thu, 22 Oct 2026 17:48:48 +0200 Subject: [PATCH 2/4] fix(easee_cloud): boost only with a read static limit (Codex review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Lödkolven --- CHANGELOG.md | 4 ++-- drivers/lua/easee_cloud.lua | 6 +++--- drivers/tests/lua_harness/test_easee_cloud_start_boost.lua | 6 ++++++ index.yaml | 4 ++-- manifests/easee_cloud.yaml | 4 ++-- 5 files changed, 15 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bf0f7e3..8fc994a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,8 +9,8 @@ phases), a resume at 6 A gave a positive pilot only 5 min 2 s to 5 min 47 s later in four runs on 22 October 2026, also with a single resume; 7 A and then 6 A started at once. evcc reports the same delay at 6 A. The driver now offers 7 A per phase when a stopped charger gets a 6 A order, and only -when the order's `max_amps_per_phase` and the charger's static limit both -allow 7 A. It writes 6 A as soon as the charger reports charging, or after +when the order's `max_amps_per_phase` and the charger's static limit, as +read from its config, both allow 7 A. It writes 6 A as soon as the charger reports charging, or after 90 s. One boost per start; a running session lowered to 6 A is not raised, orders above 6 A are unchanged, and no extra resume is sent. `start_boost: false` turns it off. Tested in the Lua harness; not yet on diff --git a/drivers/lua/easee_cloud.lua b/drivers/lua/easee_cloud.lua index 3351e2c..a4b5fa4 100644 --- a/drivers/lua/easee_cloud.lua +++ b/drivers/lua/easee_cloud.lua @@ -96,8 +96,8 @@ local EASEE_MAX_A = 32 -- delay and clamps the dynamic current to 7 A before its start command. -- Here the boost is one amp per phase, held only until the charger reports -- charging (op_mode 3) or START_BOOST_MAX_MS passes, then 6 A. It is only --- used when the command's fuse ceiling and the charger's static limit both --- allow 7 A, and at most once per start. `start_boost: false` disables it. +-- used when the command's fuse ceiling and the charger's static limit (read +-- from /config, never assumed) both allow 7 A, and at most once per start. `start_boost: false` disables it. local START_BOOST_A = EASEE_MIN_A + 1 local START_BOOST_MAX_MS = 90000 local start_boost_enabled = true @@ -910,7 +910,7 @@ function driver_command(action, power_w, cmd) write_a, boosting = START_BOOST_A, true elseif start_boost_enabled and not boost_spent and last_op_mode ~= 3 and max_a ~= nil and math.floor(max_a) >= START_BOOST_A and - (device_limit_a == nil or device_limit_a >= START_BOOST_A) then + device_limit_a ~= nil and device_limit_a >= START_BOOST_A then write_a, boosting = START_BOOST_A, true end end diff --git a/drivers/tests/lua_harness/test_easee_cloud_start_boost.lua b/drivers/tests/lua_harness/test_easee_cloud_start_boost.lua index 1c80185..dbb3404 100644 --- a/drivers/tests/lua_harness/test_easee_cloud_start_boost.lua +++ b/drivers/tests/lua_harness/test_easee_cloud_start_boost.lua @@ -73,6 +73,12 @@ poll(2) assert(order(4140)) assert(last_amps() == 6, "boost above the charger's static limit") +-- ...nor when the charger's static limit could not be read. +boot(nil, '{}') +poll(2) +assert(order(4140)) +assert(last_amps() == 6, "boost with an unknown static limit") + -- 3. No boost when more than 6 A is ordered. boot() poll(2) diff --git a/index.yaml b/index.yaml index e6c143c..8ae0a68 100644 --- a/index.yaml +++ b/index.yaml @@ -200,8 +200,8 @@ drivers: setup: [vendor_portal] ders: [ev] control: true - size_bytes: 44640 - sha256: "bc90198531f41da272b9e3641d22b261c9edf2031120b0184c334e5290ad3a40" + size_bytes: 44674 + sha256: "00ba73e690376331148e5a5841dff2fd257c93063ef3defbca9d988adc902c08" - name: "esphome_dsmr" version: "1.0.7" tier: core diff --git a/manifests/easee_cloud.yaml b/manifests/easee_cloud.yaml index 1af6fef..50d4c04 100644 --- a/manifests/easee_cloud.yaml +++ b/manifests/easee_cloud.yaml @@ -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: 44640 +size_bytes: 44674 dkb_id: "easee_cloud" -sha256: "bc90198531f41da272b9e3641d22b261c9edf2031120b0184c334e5290ad3a40" +sha256: "00ba73e690376331148e5a5841dff2fd257c93063ef3defbca9d988adc902c08" signature: "" bytecode_sha256: "" From 97471dab4e4797f018191dd6e3645fbdb1c619c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=B6dkolven?= Date: Thu, 22 Oct 2026 17:48:57 +0200 Subject: [PATCH 3/4] docs(changelog): wrap easee_cloud 1.3.7 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Lödkolven --- CHANGELOG.md | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8fc994a..cd921b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,14 +7,14 @@ Start a 6 A order at 7 A. On an Easee Home with an Audi e-tron (three phases), a resume at 6 A gave a positive pilot only 5 min 2 s to 5 min 47 s later in four runs on 22 October 2026, also with a single resume; 7 A and -then 6 A started at once. evcc reports the same delay at 6 A. The driver -now offers 7 A per phase when a stopped charger gets a 6 A order, and only -when the order's `max_amps_per_phase` and the charger's static limit, as -read from its config, both allow 7 A. It writes 6 A as soon as the charger reports charging, or after -90 s. One boost per start; a running session lowered to 6 A is not raised, -orders above 6 A are unchanged, and no extra resume is sent. -`start_boost: false` turns it off. Tested in the Lua harness; not yet on -hardware with this driver. +then 6 A started at once. evcc reports the same delay at 6 A. The driver now +offers 7 A per phase when a stopped charger gets a 6 A order, and only when +the order's `max_amps_per_phase` and the charger's static limit, as read +from its config, both allow 7 A. It writes 6 A as soon as the charger +reports charging, or after 90 s. One boost per start; a running session +lowered to 6 A is not raised, orders above 6 A are unchanged, and no extra +resume is sent. `start_boost: false` turns it off. Tested in the Lua +harness; not yet on hardware with this driver. ### home_assistant_vehicle 0.1.0 From 8c4104ee3ebd885a065e3842d7efc83556a93546 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Thu, 22 Oct 2026 19:06:54 +0200 Subject: [PATCH 4/4] fix(easee_cloud): boost only when Core's cap allows 7 A The 7 A start for a 6 A order now needs Core's cap_amps_per_phase (srcfl/ftw#1809) to allow 7 A, besides max_amps_per_phase and the charger's static limit. max_amps_per_phase is the fuse rating, not the room left: on a phase with room for 6.4 A the car drew 7 A, Core cut to 0 W, and the next start boosted again. A Core that sends no cap gets no boost. Review findings on #190: - P1: every 6 A order during a boost checks the gate again; one that no longer fits writes 6 A at once and ends the boost. - P2: the 90 s window ends in driver_command too, and driver_poll ends it before reading observations, so a failed read cannot keep 7 A. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Fredrik Ahlgren --- CHANGELOG.md | 18 +++-- drivers/lua/easee_cloud.lua | 78 +++++++++++++------ .../test_easee_cloud_start_boost.lua | 74 ++++++++++++++++-- index.yaml | 4 +- manifests/easee_cloud.yaml | 4 +- 5 files changed, 141 insertions(+), 37 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cd921b4..b54ee27 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,12 +9,18 @@ phases), a resume at 6 A gave a positive pilot only 5 min 2 s to 5 min 47 s later in four runs on 22 October 2026, also with a single resume; 7 A and then 6 A started at once. evcc reports the same delay at 6 A. The driver now offers 7 A per phase when a stopped charger gets a 6 A order, and only when -the order's `max_amps_per_phase` and the charger's static limit, as read -from its config, both allow 7 A. It writes 6 A as soon as the charger -reports charging, or after 90 s. One boost per start; a running session -lowered to 6 A is not raised, orders above 6 A are unchanged, and no extra -resume is sent. `start_boost: false` turns it off. Tested in the Lua -harness; not yet on hardware with this driver. +7 A fits: Core's `cap_amps_per_phase` for this charger, the order's +`max_amps_per_phase` and the charger's static limit, as read from its +config, must all allow 7 A. `max_amps_per_phase` is the fuse rating, not the +room left, so a 6 A order on a phase with room for 6.4 A would otherwise draw +7 A and Core would stop the car. A Core that does not send +`cap_amps_per_phase` (srcfl/ftw#1809) gets no boost. Every order during a +boost checks this again, and one that no longer fits writes 6 A at once. The +driver writes 6 A as soon as the charger reports charging, or after 90 s, +also when observations fail. One boost per start; a running session lowered +to 6 A is not raised, orders above 6 A are unchanged, and no extra resume is +sent. `start_boost: false` turns it off. Tested in the Lua harness; not yet +on hardware with this driver. ### home_assistant_vehicle 0.1.0 diff --git a/drivers/lua/easee_cloud.lua b/drivers/lua/easee_cloud.lua index a4b5fa4..7969e1b 100644 --- a/drivers/lua/easee_cloud.lua +++ b/drivers/lua/easee_cloud.lua @@ -96,8 +96,8 @@ local EASEE_MAX_A = 32 -- delay and clamps the dynamic current to 7 A before its start command. -- Here the boost is one amp per phase, held only until the charger reports -- charging (op_mode 3) or START_BOOST_MAX_MS passes, then 6 A. It is only --- used when the command's fuse ceiling and the charger's static limit (read --- from /config, never assumed) both allow 7 A, and at most once per start. `start_boost: false` disables it. +-- used when 7 A fits (start_boost_fits), and at most once per start. +-- `start_boost: false` disables it. local START_BOOST_A = EASEE_MIN_A + 1 local START_BOOST_MAX_MS = 90000 local start_boost_enabled = true @@ -507,6 +507,35 @@ local function write_setting(serial, body_table) return err end +-- start_boost_fits: 7 A per phase fits when Core's cap for this charger +-- (cap_amps_per_phase: its room below the fuse and the import limit, after +-- other chargers' share), the order's max_amps_per_phase and the charger's +-- static limit, read from /config, all allow it. max_amps_per_phase alone is +-- the fuse rating, not the room left: a 6 A order on a phase with room for +-- 6.4 A would draw 7 A, Core would see an overage and stop the car, and the +-- next start would boost again. A Core that sends no cap gets no boost. +-- Checked on every order while a boost runs. +local function start_boost_fits(cmd, max_a) + local cap = cmd and type(cmd.cap_amps_per_phase) == "number" and cmd.cap_amps_per_phase or nil + return cap ~= nil and math.floor(cap) >= START_BOOST_A and + max_a ~= nil and math.floor(max_a) >= START_BOOST_A and + device_limit_a ~= nil and device_limit_a >= START_BOOST_A +end + +-- end_start_boost writes the ordered 6 A. A failed write leaves the boost +-- running, so the next poll or order tries again. +local function end_start_boost(why) + local werr = write_setting(charger_serial, {dynamicChargerCurrent = EASEE_MIN_A}) + if werr ~= nil then + host.log("warn", "Easee: start boost step-down failed (will retry): " .. redact_http_err(werr)) + return false + end + host.log("info", "Easee: start boost ended (" .. why .. "), dynamicChargerCurrent=6 A") + boost_until_ms = nil + last_amps_set = EASEE_MIN_A + return true +end + function driver_init(config) host.set_make("Easee") @@ -614,6 +643,11 @@ function driver_poll() return 10000 end + -- The boost window ends on time even when observations fail. + if boost_until_ms ~= nil and host.millis() >= boost_until_ms then + end_start_boost("timeout") + end + local obs, err, timestamps = get_observations(charger_serial) if err or not obs then host.log("warn", "Easee: observations poll failed: " .. redact_http_err(err)) @@ -771,21 +805,9 @@ function driver_poll() end -- 6 A start boost: step down to the ordered 6 A as soon as the charger - -- reports charging, or when the boost window ends without it. A failed - -- write keeps the boost pending so the next poll retries the step-down. - if boost_until_ms ~= nil then - if charging or now >= boost_until_ms then - local werr = write_setting(charger_serial, {dynamicChargerCurrent = EASEE_MIN_A}) - if werr == nil then - host.log("info", "Easee: start boost ended (" .. - (charging and "charging" or "timeout") .. "), dynamicChargerCurrent=6 A") - boost_until_ms = nil - last_amps_set = EASEE_MIN_A - else - host.log("warn", "Easee: start boost step-down failed (will retry next poll): " .. - redact_http_err(werr)) - end - end + -- reports charging. The timeout is checked above, before observations. + if boost_until_ms ~= nil and charging then + end_start_boost("charging") end -- A session that stops charging may need a new boost on its next start. if last_op_mode == 3 and not charging and boost_until_ms == nil then @@ -904,13 +926,22 @@ function driver_command(action, power_w, cmd) local amps = per_phase_amps(power_w, voltage, phases, max_a) local write_a = amps local boosting = false + local boost_end = nil if amps == EASEE_MIN_A then if boost_until_ms ~= nil then - -- Still within the boost: keep 7 A, do not restart the window. - write_a, boosting = START_BOOST_A, true - elseif start_boost_enabled and not boost_spent and last_op_mode ~= 3 and - max_a ~= nil and math.floor(max_a) >= START_BOOST_A and - device_limit_a ~= nil and device_limit_a >= START_BOOST_A then + -- Keep 7 A within the window while it still fits and the + -- charger has not started; do not restart the window. + -- Otherwise this write of 6 A ends the boost. + if now_ms >= boost_until_ms then + boost_end = "timeout" + elseif last_op_mode == 3 then + boost_end = "charging" + elseif not start_boost_fits(cmd, max_a) then + boost_end = "no room for 7 A" + else + write_a, boosting = START_BOOST_A, true + end + elseif start_boost_enabled and not boost_spent and last_op_mode ~= 3 and start_boost_fits(cmd, max_a) then write_a, boosting = START_BOOST_A, true end end @@ -923,6 +954,9 @@ function driver_command(action, power_w, cmd) host.log("info", "Easee: start boost 7 A for a 6 A start (Easee delays ~5 min at 6 A)") end else + if boost_end ~= nil then + host.log("info", "Easee: start boost ended (" .. boost_end .. "), dynamicChargerCurrent=6 A") + end boost_until_ms = nil if amps == 0 then boost_spent = false end end diff --git a/drivers/tests/lua_harness/test_easee_cloud_start_boost.lua b/drivers/tests/lua_harness/test_easee_cloud_start_boost.lua index dbb3404..b9ff128 100644 --- a/drivers/tests/lua_harness/test_easee_cloud_start_boost.lua +++ b/drivers/tests/lua_harness/test_easee_cloud_start_boost.lua @@ -1,4 +1,5 @@ --- A 6 A start is offered 7 A until the charger charges, then 6 A. +-- A 6 A start is offered 7 A until the charger charges, then 6 A, and only +-- while Core's cap_amps_per_phase says 7 A fits. dofile("drivers/tests/lua_harness/host_mock.lua") local driver = "drivers/lua/easee_cloud.lua" @@ -40,8 +41,17 @@ local function poll(mode, kw) }) driver_poll() end +local function poll_fails() + host._http_responses["/observations?ids="] = "not json" + driver_poll() +end +local function later(ms) host._millis_counter = host._millis_counter + ms end -local SIX = {phase_mode="3p", voltage=230, max_amps_per_phase=25} +-- Core's cap for this charger is exactly 7 A: floor(7) fits. +local SIX = {phase_mode="3p", voltage=230, max_amps_per_phase=25, cap_amps_per_phase=7} +local function six(cap, max) + return {phase_mode="3p", voltage=230, max_amps_per_phase=max or 25, cap_amps_per_phase=cap} +end local function order(w, cmd) return driver_command("ev_set_current", w, cmd or SIX) end -- 1. Bump on a 6 A start, hold through repeated orders, step down once charging. @@ -57,10 +67,10 @@ assert(last_amps() == 6, "no step-down to 6 A after charging was seen") assert(order(4140)); assert(last_amps() == 6, "boost repeated during a running session") assert(count_cmd("/commands/resume_charging") == 0, "boost sent a resume") --- 2. No boost when the fuse ceiling leaves no amp above 6. +-- 2. No boost when the fuse ceiling leaves no amp above 6, whatever the cap. boot() poll(2) -assert(order(4140, {phase_mode="3p", voltage=230, max_amps_per_phase=6.9})) +assert(order(4140, six(7, 6.9))) assert(last_amps() == 6, "boost ignored a 6 A fuse ceiling") -- ...nor when Core gives no ceiling at all. boot() @@ -118,7 +128,7 @@ assert(order(4140)); assert(last_amps() == 6, "start_boost=false ignored") -- 8. 1Φ: the boost is per phase too (6 A → 7 A on one phase). boot() poll(2) -assert(order(1380, {phase_mode="1p", voltage=230, max_amps_per_phase=16})) +assert(order(1380, {phase_mode="1p", voltage=230, max_amps_per_phase=16, cap_amps_per_phase=7.5})) assert(last_amps() == 7, "1p 6 A start not boosted") -- 9. Default mode clears the boost. @@ -131,4 +141,58 @@ host._millis_counter = host._millis_counter + 91000 poll(2) assert(last_amps() == 0, "default mode left a pending boost step-down") +-- 10. Core's cap decides. An older Core sends none: no boost. +boot() +poll(2) +assert(order(4140, six(nil))) +assert(last_amps() == 6, "boost without Core's cap") +-- Room for 6.4 A: 7 A would cross it. +boot() +poll(2) +assert(order(4140, six(6.4))) +assert(last_amps() == 6, "boost past Core's 6.4 A cap") +-- 7 A or more fits. +for _, cap in ipairs({7.9, 15}) do + boot() + poll(2) + assert(order(4140, six(cap))) + assert(last_amps() == 7, "no boost with a " .. cap .. " A cap") +end + +-- 11. The cap falls to 6 A during a boost: 6 A at once, and the boost ends. +boot() +poll(2) +assert(order(4140, six(8))); assert(last_amps() == 7) +assert(order(4140, six(6))) +assert(last_amps() == 6, "boost kept 7 A after Core's cap fell to 6 A") +assert(order(4140, six(8))) +assert(last_amps() == 6, "an ended boost started again in the same start") +later(91000) +poll(2) +assert(last_amps() == 6, "ended boost stepped down again") + +-- 12. The fuse ceiling falls to 6 A during a boost (Codex P1): 6 A at once. +boot() +poll(2) +assert(order(4140)); assert(last_amps() == 7) +assert(order(4140, six(6, 6))) +assert(last_amps() == 6, "boost kept 7 A after max_amps_per_phase fell to 6 A") + +-- 13. Observations fail past the window (Codex P2): the poll still writes +-- 6 A, and the next order does not bring 7 A back. +boot() +poll(2) +assert(order(4140)); assert(last_amps() == 7) +later(91000) +poll_fails() +assert(last_amps() == 6, "boost outlived its window while observations failed") +assert(order(4140)); assert(last_amps() == 6, "order after a failed poll wrote 7 A") + +-- 14. No poll at all past the window: the order itself ends the boost. +boot() +poll(2) +assert(order(4140)); assert(last_amps() == 7) +later(91000) +assert(order(4140)); assert(last_amps() == 6, "order past the window wrote 7 A") + print("easee_cloud start boost: ok") diff --git a/index.yaml b/index.yaml index 8ae0a68..4923a56 100644 --- a/index.yaml +++ b/index.yaml @@ -200,8 +200,8 @@ drivers: setup: [vendor_portal] ders: [ev] control: true - size_bytes: 44674 - sha256: "00ba73e690376331148e5a5841dff2fd257c93063ef3defbca9d988adc902c08" + size_bytes: 46180 + sha256: "26e4b146262a1416fc64f81e27cda6b7aebe0a8e1f7fb924912675c00f7f86f7" - name: "esphome_dsmr" version: "1.0.7" tier: core diff --git a/manifests/easee_cloud.yaml b/manifests/easee_cloud.yaml index 50d4c04..8af9af8 100644 --- a/manifests/easee_cloud.yaml +++ b/manifests/easee_cloud.yaml @@ -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: 44674 +size_bytes: 46180 dkb_id: "easee_cloud" -sha256: "00ba73e690376331148e5a5841dff2fd257c93063ef3defbca9d988adc902c08" +sha256: "26e4b146262a1416fc64f81e27cda6b7aebe0a8e1f7fb924912675c00f7f86f7" signature: "" bytecode_sha256: ""