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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@

## [Unreleased]

### easee_cloud 1.3.10

- Support Core's two-phase car choice (`phase_mode: 2p`) and an explicit `phases: 2` driver setting. Use Easee Auto for that car and two phases for the current calculation. Easee's API value 2 means Auto; it is not a measured phase count.
- Keep whole-amp two-phase offers within the watt and per-phase current limits. Preserve the phase count in the power diagnostic and keep explicit one-phase charging available.
- Command and measurement tests use simulated responses. The change still needs an Easee and two-phase car test before hardware acceptance.

### easee_cloud 1.3.8

- Reject power observations that move backwards in source time, and conflicting values at the same physical time. Compare RFC3339 instants with UTC offsets and fractional precision normalized. An old cloud zero can no longer replace a newer sample, nor can an old positive sample resurrect an observed stop.
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.9 | ftw-core | not_assessed | — | not_recorded | not_assessed |
| easee_cloud | 1.3.9 | blixt-l1 | not_assessed | — | not_recorded | not_assessed |
| easee_cloud | 1.3.10 | ftw-core | not_assessed | — | not_recorded | not_assessed |
| easee_cloud | 1.3.10 | 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.9"
version: "1.3.10"
ders: [ev]
control: true
firmware_versions: ""
Expand Down
41 changes: 24 additions & 17 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.9",
version = "1.3.10",
protocols = { "http" },
capabilities = { "ev" },
description = "Easee Home/Charge via Cloud REST API. No local protocol needed.",
Expand All @@ -48,7 +48,8 @@ local refresh_token = nil
local token_expires_at = 0 -- millis
local charger_serial = nil
local phases = 3 -- populated from config.phases (if present) in driver_init
local last_sent_phases = nil -- tracks the last phaseMode posted to Easee
local configured_phases = nil
local last_sent_phases = nil -- charging count, not Easee's phaseMode enum
local last_phase_change_ms = 0 -- monotonic ms of the last phase flip (hysteresis)

-- After every phaseMode change, Easee resets dynamicChargerCurrent to
Expand Down Expand Up @@ -101,9 +102,9 @@ local EASEE_MAX_A = 32
-- 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
-- phases (1 or 3) to actually commit to this command.
-- phases (1, 2 or 3) to use for the current calculation.
--
-- * mode "1p" / "3p": locked, no hysteresis
-- * mode "1p" / "2p" / "3p": fixed count, no hysteresis
-- * mode "auto" or empty + missing fuse data: legacy 3Φ fallback
-- (preserves pre-switching behaviour for sites that don't pass
-- fuse data in the cmd)
Expand All @@ -120,6 +121,7 @@ local EASEE_MAX_A = 32
local function pick_phases(mode, want_w, voltage, max_a_per_phase, split_w, hold_s, now_ms)
local locked = mode
if locked == "1p" then return 1 end
if locked == "2p" then return 2 end
if locked ~= "auto" then return 3 end -- "" / "3p" / unknown

-- Effective split: operator override → site fuse → 230 V × 16 A default.
Expand Down Expand Up @@ -172,7 +174,9 @@ end
-- what the fuse tolerates.
local function per_phase_amps(power_w, voltage, p, max_a_per_phase)
local v = (voltage and voltage > 0) and voltage or 230
local raw = ((power_w or 0) / v / p) + 0.5
-- Two-phase offers use whole amps within the watt ceiling. Keep the
-- existing rounding for one- and three-phase installations.
local raw = ((power_w or 0) / v / p) + ((p == 2) and 0.000000001 or 0.5)
local amps = math.floor(raw)
if amps < 0 then amps = 0 end
if amps > EASEE_MAX_A then amps = EASEE_MAX_A end
Expand Down Expand Up @@ -592,7 +596,10 @@ function driver_init(config)
-- (silently halting the session).
if config and tonumber(config.phases) then
local p = math.floor(tonumber(config.phases))
if p == 1 or p == 2 or p == 3 then phases = p end
if p == 1 or p == 2 or p == 3 then
phases = p
configured_phases = p
end
end

-- Optional `max_charger_current` config: clamps the charger's
Expand Down Expand Up @@ -721,15 +728,11 @@ function driver_poll()
-- Operators wanting "is the EV actually drawing what we asked
-- for?" should compare actual_amps_per_phase against max_a.
--
-- Only computed for the canonical 1Φ / 3Φ configurations. Earlier
-- driver_init permitted config.phases=2 (a misconfigured site,
-- or a transitional state before the first successful phaseMode
-- write). Dividing by 2 there would yield a "per-phase" number
-- that doesn't correspond to any real cabling — better to omit
-- the field than mislead the operator with a fictitious value.
-- The charging phase count comes from Core or explicit configuration,
-- never from Easee's phaseMode: its value 2 means Auto, not two phases.
local actual_amps_per_phase = nil
local v_obs = obs[OBS_VOLTAGE]
if power_w > 0 and (phases == 1 or phases == 3) then
if power_w > 0 and (phases == 1 or phases == 2 or phases == 3) then
local vv = (v_obs and v_obs > 0) and v_obs or 230
actual_amps_per_phase = power_w / vv / phases
end
Expand Down Expand Up @@ -824,7 +827,7 @@ function driver_poll()
device_limit_age_s = limit_age_s,
max_a = dyn_current, -- last-set dynamic limit (echoes our write, may lag)
actual_amps_per_phase = actual_amps_per_phase, -- live per-phase A derived from totalPower
phases = phases, -- our committed phase count (1 or 3)
phases = phases, -- configured/commanded count, not a measurement
command_stalled = command_stalled, -- offer>0 but contactor open >30s on stall reason
})

Expand Down Expand Up @@ -919,6 +922,8 @@ function driver_command(action, power_w, cmd)
-- the requested W and the voltage we know about. The Go
-- controller does NOT pick phases; it just allocates power.
local mode = (cmd and type(cmd.phase_mode) == "string") and cmd.phase_mode or ""
if configured_phases == 2 and mode ~= "1p" then mode = "2p" end
if cmd and cmd.site_phases == 1 then mode = "1p" end
local split = (cmd and type(cmd.phase_split_w) == "number") and cmd.phase_split_w or 0
local hold_s = (cmd and type(cmd.min_phase_hold_s) == "number") and cmd.min_phase_hold_s or 0
local voltage = (cmd and type(cmd.voltage) == "number" and cmd.voltage > 0) and cmd.voltage or 230
Expand All @@ -929,8 +934,10 @@ function driver_command(action, power_w, cmd)

-- POST phaseMode FIRST when it differs. Easee's settings
-- endpoint accepts phaseMode = 1 (locked-1p), 2 (auto), 3
-- (locked-3p). We only ever lock — "auto" is our concern, not
-- the charger's.
-- (locked-3p). A two-phase car needs Easee Auto (2). Keep that
-- vendor enum separate from the charging count used for W→A.
-- https://developer.easee.com/docs/enumerations
local easee_phase_mode = ({[1]=1, [2]=2, [3]=3})[requested_phases]
local phase_changed = false
if last_sent_phases ~= requested_phases then
-- A live phase flip (notably 1Φ→3Φ) won't move the Easee
Expand All @@ -953,7 +960,7 @@ function driver_command(action, power_w, cmd)
tostring(last_sent_phases) .. "→" .. tostring(requested_phases))
end
end
local pm_err = write_setting(charger_serial, {phaseMode = requested_phases})
local pm_err = write_setting(charger_serial, {phaseMode = easee_phase_mode})
if pm_err == nil then
phases = requested_phases
last_sent_phases = requested_phases
Expand Down
57 changes: 57 additions & 0 deletions drivers/tests/lua_harness/test_easee_cloud_two_phase.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
dofile("drivers/tests/lua_harness/host_mock.lua")

local function offer(config_phases, mode, watts, voltage, max_a)
host.reset()
host._millis_step = 0
host._http_responses["/accounts/login"] = '{"accessToken":"test","expiresIn":3600}'
host._http_responses["/config"] = '{"phaseMode":2,"maxChargerCurrent":16}'
host._http_responses["/settings"] = '{}'
host._http_responses["/commands/"] = '{}'
dofile("drivers/lua/easee_cloud.lua")
driver_init({email="test@example.invalid", password="test", serial="TEST123", phases=config_phases})
assert(driver_command("ev_set_current", watts, {phase_mode=mode, voltage=voltage, max_amps_per_phase=max_a}))
local settings = {}
for _, call in ipairs(host._calls) do
if call.func == "http_post" and call.args[1]:match("/settings$") then
for k,v in pairs(host.json_decode(call.args[2])) do settings[k]=v end
end
end
return settings
end

local cases = {
{nil, "2p", 4600, 230, 16, 2, 10},
{2, "auto", 4600, 230, 16, 2, 10},
{2, "3p", 4600, 230, 16, 2, 10},
{nil, "2p", 6400, 230, 16, 2, 13}, -- whole amps must fit the watt ceiling
{nil, "2p", 10000, 240, 10, 2, 10},
{nil, "2p", 2760, 230, 16, 2, 6},
{nil, "2p", 2759, 230, 16, 2, 0},
{nil, "2p", 4600, 230, 5, 2, 0},
{2, "1p", 2300, 230, 16, 1, 10},
{nil, "3p", 6900, 230, 16, 3, 10},
}
for _, c in ipairs(cases) do
local settings = offer(c[1], c[2], c[3], c[4], c[5])
assert(settings.phaseMode == c[6], "wrong Easee phase mode for " .. c[2])
assert(settings.dynamicChargerCurrent == c[7], "wrong current for " .. c[2] .. ": " .. tostring(settings.dynamicChargerCurrent))
end

-- The car's count must remain two after a write and in the power diagnostic.
offer(nil, "2p", 4600, 230, 16)
host._http_responses["/sessions/ongoing"] = '{}'
host._http_responses["/observations?ids="] = host.json_encode({
{id=109, value=3, timestamp="2026-10-21T18:00:00Z"},
{id=120, value=4.6, timestamp="2026-10-21T18:00:00Z"},
{id=121, value=1, timestamp="2026-10-21T18:00:00Z"},
{id=194, value=230, timestamp="2026-10-21T18:00:00Z"},
})
driver_poll()
local ev = host._emitted.ev[#host._emitted.ev]
assert(ev.phases == 2 and math.abs(ev.actual_amps_per_phase - 10) < 0.000001,
"two-phase current diagnostic used three phases")

-- Auto read from Easee alone does not prove the car uses two phases.
offer(nil, "3p", 6900, 230, 16)
assert(driver_command("ev_set_current", 6900, {phase_mode="3p", voltage=230, max_amps_per_phase=16}))
print("Easee two-phase commands: passed")
12 changes: 12 additions & 0 deletions drivers/tests/test_easee_cloud_two_phase.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
"""Two-phase car current differs from the charger's phase-mode enum."""
from pathlib import Path
import subprocess


def test_easee_cloud_two_phase_commands():
root = Path(__file__).resolve().parents[2]
result = subprocess.run(
[str(root / "lua55"), "drivers/tests/lua_harness/test_easee_cloud_two_phase.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.9"
version: "1.3.10"
tier: core
protocol: http
connectivity: cloud
setup: [vendor_portal]
ders: [ev]
control: true
size_bytes: 47272
sha256: "d811a900e8298c8038b4e2f413b6b639ede2e3a1560747f3e5839498fb4165e6"
size_bytes: 47653
sha256: "b2094631f168a0bc05b0b9abf12585198ed7b4597dbbd097be1060a16d045b5c"
- 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.9"
version: "1.3.10"
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: 47272
size_bytes: 47653
dkb_id: "easee_cloud"
sha256: "d811a900e8298c8038b4e2f413b6b639ede2e3a1560747f3e5839498fb4165e6"
sha256: "b2094631f168a0bc05b0b9abf12585198ed7b4597dbbd097be1060a16d045b5c"
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.9",
"catalog_version": "1.3.10",
"driver_id": "easee_cloud",
"targets": {
"blixt-l1": {
Expand Down
Loading