diff --git a/CHANGELOG.md b/CHANGELOG.md index 6fc2818..147d9e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/SUPPORT_STATUS.md b/SUPPORT_STATUS.md index e6578da..417e24f 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.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 | diff --git a/devices.yaml b/devices.yaml index 9468047..894441c 100644 --- a/devices.yaml +++ b/devices.yaml @@ -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: "" diff --git a/drivers/lua/easee_cloud.lua b/drivers/lua/easee_cloud.lua index 9057f3a..c433111 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.9", + version = "1.3.10", protocols = { "http" }, capabilities = { "ev" }, description = "Easee Home/Charge via Cloud REST API. No local protocol needed.", @@ -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 @@ -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) @@ -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. @@ -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 @@ -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 @@ -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 @@ -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 }) @@ -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 @@ -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 @@ -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 diff --git a/drivers/tests/lua_harness/test_easee_cloud_two_phase.lua b/drivers/tests/lua_harness/test_easee_cloud_two_phase.lua new file mode 100644 index 0000000..ee6accd --- /dev/null +++ b/drivers/tests/lua_harness/test_easee_cloud_two_phase.lua @@ -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") diff --git a/drivers/tests/test_easee_cloud_two_phase.py b/drivers/tests/test_easee_cloud_two_phase.py new file mode 100644 index 0000000..c97fd2c --- /dev/null +++ b/drivers/tests/test_easee_cloud_two_phase.py @@ -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 diff --git a/index.yaml b/index.yaml index 7836d1b..2c0c136 100644 --- a/index.yaml +++ b/index.yaml @@ -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 diff --git a/manifests/easee_cloud.yaml b/manifests/easee_cloud.yaml index 545ef7d..3fdccaa 100644 --- a/manifests/easee_cloud.yaml +++ b/manifests/easee_cloud.yaml @@ -1,5 +1,5 @@ name: "easee_cloud" -version: "1.3.9" +version: "1.3.10" 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: 47272 +size_bytes: 47653 dkb_id: "easee_cloud" -sha256: "d811a900e8298c8038b4e2f413b6b639ede2e3a1560747f3e5839498fb4165e6" +sha256: "b2094631f168a0bc05b0b9abf12585198ed7b4597dbbd097be1060a16d045b5c" signature: "" bytecode_sha256: "" diff --git a/support-status.json b/support-status.json index 6ef485b..b136927 100644 --- a/support-status.json +++ b/support-status.json @@ -443,7 +443,7 @@ }, { "catalog_source": true, - "catalog_version": "1.3.9", + "catalog_version": "1.3.10", "driver_id": "easee_cloud", "targets": { "blixt-l1": {