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
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
4 changes: 2 additions & 2 deletions SUPPORT_STATUS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
2 changes: 1 addition & 1 deletion devices.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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: ""
Expand Down
60 changes: 50 additions & 10 deletions drivers/lua/easee_cloud.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
76 changes: 76 additions & 0 deletions drivers/tests/lua_harness/test_easee_cloud_phase_flip.lua
Original file line number Diff line number Diff line change
@@ -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")
12 changes: 12 additions & 0 deletions drivers/tests/test_easee_cloud_phase_flip.py
Original file line number Diff line number Diff line change
@@ -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
6 changes: 3 additions & 3 deletions index.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions manifests/easee_cloud.yaml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
name: "easee_cloud"
version: "1.3.6"
version: "1.3.7"
tier: core
author: "Sourceful Labs AB"
protocol: http
Expand All @@ -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: ""
Expand Down
2 changes: 1 addition & 1 deletion support-status.json
Original file line number Diff line number Diff line change
Expand Up @@ -443,7 +443,7 @@
},
{
"catalog_source": true,
"catalog_version": "1.3.6",
"catalog_version": "1.3.7",
"driver_id": "easee_cloud",
"targets": {
"blixt-l1": {
Expand Down
Loading