diff --git a/CHANGELOG.md b/CHANGELOG.md index b358dd3..93f1b1f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## easee_cloud 1.3.7 + +Resume a mid-session 1Φ↔3Φ flip only after a poll shows charging has stopped. +Easee applies a new `phaseMode` only when the session restarts, and +`resume_charging` in the same command continues that session, so the car stays +on the old phase count. On an Easee Home with a Tesla, `phaseMode=3` was +written mid-session and the car drew 15 A on one phase for hours until the +owner paused and resumed in the Easee app. A later `ev_set_current` resumes +once a poll has seen the charger leave charging. An explicit `ev_pause` or +`ev_resume` clears the wait. A failed poll clears the stop observation, so +auto-resume waits for a valid poll again. Not yet verified on hardware. + ## heishamon 0.8.0 Read `hp_power_w` from `main/Heat_Power_Consumption` (TOP16), with a diff --git a/SUPPORT_STATUS.md b/SUPPORT_STATUS.md index 40e329d..421b86e 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 592c11c..f9337aa 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..2c96a9a 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.", @@ -72,6 +72,17 @@ local last_amps_set = nil -- "easee_a=6, easee_chg=false, ev_w=0, reason=100/52" stuck states. local paused_state = false +-- A mid-session phase flip only takes effect when the charger restarts +-- the session. resume_charging in the same driver_command continues that +-- session, so the new phaseMode never latches. Field case, Easee Home + +-- Tesla: phaseMode=3 was written mid-session and the car drew 15 A on one +-- phase for hours until a pause/resume in the Easee app. Hold the +-- auto-resume until a later poll shows charging has stopped. +local phase_flip_awaiting_stop = false +local phase_flip_pause_ms = nil +local last_poll_ms = nil +local last_poll_charging = nil + -- command_stalled_since_ms tracks when we last wrote a non-zero amps -- offer that did NOT translate into actual charging. Used to surface -- a derived diagnostic for the controller / UI so a stuck Easee @@ -583,6 +594,10 @@ function driver_init(config) end function driver_poll() + -- A failed poll leaves the charger state unknown. A prior stop + -- observation must not allow a phase-flip resume through that gap. + last_poll_ms = nil + last_poll_charging = nil if not charger_serial or not email then return 10000 end @@ -603,6 +618,8 @@ function driver_poll() local session_wh = (obs[OBS_SESSION_ENERGY] or 0) * 1000 -- kWh → Wh local connected = (op_mode >= 2 and op_mode <= 6) local charging = (op_mode == 3) + last_poll_ms = host.millis() + last_poll_charging = charging -- op_mode 0 is the sentinel Easee emits when the cloud hasn't heard -- from the unit recently. Anything else means the charger itself is -- responsive even when no car is plugged in. @@ -787,15 +804,27 @@ function driver_command(action, power_w, cmd) if action == "ev_start" then local ok = post_command("/commands/start_charging") - if ok then paused_state = false end + if ok then + paused_state = false + phase_flip_awaiting_stop = false + phase_flip_pause_ms = nil + end 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 + phase_flip_awaiting_stop = false + phase_flip_pause_ms = nil + end return ok elseif action == "ev_resume" then local ok = post_command("/commands/resume_charging") - if ok then paused_state = false end + if ok then + paused_state = false + phase_flip_awaiting_stop = false + phase_flip_pause_ms = nil + end return ok elseif action == "ev_set_current" then -- Driver-level phase decision: read the operator's preferences @@ -821,14 +850,18 @@ function driver_command(action, power_w, cmd) -- contactor while a session is charging: the phase count is only -- latched when a session (re)starts. So on a real mid-session flip -- pause first; the phaseMode write reconfigures, and the auto- - -- resume below (amps > 0 && paused_state) re-closes the contactor - -- on the new phase count. Operator-confirmed: a manual - -- pause+resume was the only thing that flipped 1Φ→3Φ. Skip on the - -- first command of a session (last_sent_phases == nil) — there's no - -- live contactor to recycle. 2026-05-30. + -- resume below re-closes the contactor only after a later poll + -- shows charging has stopped. resume_charging in this same call + -- would continue the session, so 1Φ→3Φ would never latch. + -- Operator-confirmed: a manual pause+resume was the only thing + -- that flipped 1Φ→3Φ. Skip on the first command of a session + -- (last_sent_phases == nil) — there's no live contactor to + -- recycle. 2026-05-30. if last_sent_phases ~= nil then if post_command("/commands/pause_charging") then paused_state = true + phase_flip_awaiting_stop = true + phase_flip_pause_ms = now_ms host.log("info", "Easee: pause to flip phaseMode " .. tostring(last_sent_phases) .. "→" .. tostring(requested_phases)) end @@ -877,9 +910,16 @@ function driver_command(action, power_w, cmd) -- the new offer is > 0. Idempotent on the Easee side; -- a failure here doesn't fail the command (the offer -- itself succeeded, controller will retry next tick). - if amps > 0 and paused_state then + local flip_stopped = phase_flip_awaiting_stop + and last_poll_ms ~= nil + and phase_flip_pause_ms ~= nil + and last_poll_ms > phase_flip_pause_ms + and last_poll_charging == false + if amps > 0 and paused_state and (not phase_flip_awaiting_stop or flip_stopped) then if post_command("/commands/resume_charging") then paused_state = false + phase_flip_awaiting_stop = false + phase_flip_pause_ms = nil host.log("info", "Easee: auto-resumed after pause (offer=" .. tostring(amps) .. " A)") end diff --git a/drivers/tests/lua_harness/test_easee_cloud_phase_flip.lua b/drivers/tests/lua_harness/test_easee_cloud_phase_flip.lua new file mode 100644 index 0000000..97258af --- /dev/null +++ b/drivers/tests/lua_harness/test_easee_cloud_phase_flip.lua @@ -0,0 +1,76 @@ +dofile("drivers/tests/lua_harness/host_mock.lua") +-- A mid-session phase flip only takes effect when the charger restarts the +-- session. resume_charging in the same command as the pause continues that +-- session, so the car stays on one phase until a poll shows charging stopped. +local driver = "drivers/lua/easee_cloud.lua" +host.reset() +host._millis_step = 0 +host._http_responses["/accounts/login"] = '{"accessToken":"test","expiresIn":3600}' +host._http_responses["/config"] = '{}' +host._http_responses["/sessions/ongoing"] = '{}' +host._http_responses["/commands/"] = '{}' +host._http_responses["/settings"] = '{}' +dofile(driver) +driver_init({email="test@example.invalid",password="test",serial="TEST123"}) + +local function posts(path) + local n = 0 + for _, call in ipairs(host._calls) do + if call.func == "http_post" and string.find(call.args[1], path, 1, true) then + n = n + 1 + end + end + return n +end +local function offer(w) + local ok = driver_command("ev_set_current", w, + {phase_mode="auto", voltage=230, max_amps_per_phase=16}) + assert(ok, "ev_set_current failed at " .. tostring(w) .. " W") +end +local function advance(ms) host._millis_counter = host._millis_counter + ms end +local function poll(mode, power_kw) + host._http_responses["/observations?ids="] = host.json_encode({ + {id=109,value=mode,timestamp="2026-09-25T00:21:00Z"}, + {id=120,value=power_kw,timestamp="2026-09-25T00:21:20Z"}, + {id=121,value=1,timestamp="2026-09-25T00:21:20Z"}, + }) + driver_poll() +end + +advance(1000) +offer(3000) +assert(posts("pause_charging") == 0, "first command of a session paused the charger") + +advance(120000) -- past the 90 s phase hold +offer(9000) +assert(posts("pause_charging") == 1, "mid-session flip did not pause") +assert(posts("resume_charging") == 0, "resumed in the same command as the flip pause") + +advance(1000) +poll(3, 3.5) +offer(9000) +assert(posts("resume_charging") == 0, "resumed while the poll still showed charging") + +advance(1000) +poll(2, 0) +advance(1000) +poll(0, 0) +offer(9000) +assert(posts("resume_charging") == 0, "offline poll left an old stop observation valid") + +advance(1000) +poll(2, 0) +host._http_responses["/observations?ids="] = "not JSON" +driver_poll() +offer(9000) +assert(posts("resume_charging") == 0, "failed poll left an old stop observation valid") + +advance(1000) +poll(2, 0) +offer(9000) +assert(posts("resume_charging") == 1, "did not resume once a poll showed charging had stopped") +advance(5000) +offer(9000) +assert(posts("resume_charging") == 1, "resumed again after the flip completed") + +print("Easee phase flip: passed") diff --git a/drivers/tests/test_easee_cloud_phase_flip.py b/drivers/tests/test_easee_cloud_phase_flip.py new file mode 100644 index 0000000..17cd12d --- /dev/null +++ b/drivers/tests/test_easee_cloud_phase_flip.py @@ -0,0 +1,12 @@ +"""A mid-session Easee phase flip resumes only after charging has stopped.""" +from pathlib import Path +import subprocess + + +def test_easee_cloud_resumes_phase_flip_after_charging_stops(): + root = Path(__file__).resolve().parents[2] + result = subprocess.run( + [str(root / "lua55"), "drivers/tests/lua_harness/test_easee_cloud_phase_flip.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 3dff6c9..8638919 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: 42813 + sha256: "408b48baf27f1aa4e622e699dca77cea2b34074178bf4e86ce5ffdd8c045d2dd" - name: "esphome_dsmr" version: "1.0.7" tier: core diff --git a/manifests/easee_cloud.yaml b/manifests/easee_cloud.yaml index b50d5a4..bafe691 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: 42813 dkb_id: "easee_cloud" -sha256: "4f33149c0640c2e9d2c043e36fdf967d8b297f07da56aec934c21e81a397d22b" +sha256: "408b48baf27f1aa4e622e699dca77cea2b34074178bf4e86ce5ffdd8c045d2dd" signature: "" bytecode_sha256: "" diff --git a/support-status.json b/support-status.json index 5179564..1aa513e 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": {