diff --git a/CHANGELOG.md b/CHANGELOG.md index 50ebcc5..b54ee27 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,26 @@ ## [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 +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 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..7969e1b 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 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 +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 @@ -489,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") @@ -525,6 +572,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 @@ -592,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)) @@ -748,6 +804,17 @@ function driver_poll() end end + -- 6 A start boost: step down to the ordered 6 A as soon as the charger + -- 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 + 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 +858,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 +924,43 @@ 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 + local boost_end = nil + if amps == EASEE_MIN_A then + if boost_until_ms ~= nil 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 + 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 + 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 + 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 +1009,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..b9ff128 --- /dev/null +++ b/drivers/tests/lua_harness/test_easee_cloud_start_boost.lua @@ -0,0 +1,198 @@ +-- 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" + +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 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 + +-- 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. +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, whatever the cap. +boot() +poll(2) +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() +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") + +-- ...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) +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, cap_amps_per_phase=7.5})) +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") + +-- 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/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..4923a56 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: 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 b50d5a4..8af9af8 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: 46180 dkb_id: "easee_cloud" -sha256: "4f33149c0640c2e9d2c043e36fdf967d8b297f07da56aec934c21e81a397d22b" +sha256: "26e4b146262a1416fc64f81e27cda6b7aebe0a8e1f7fb924912675c00f7f86f7" 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": {