From 3e0af590a237b8aaf5124793a1ff3235336e7d6f Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Sun, 25 Oct 2026 19:11:46 +0100 Subject: [PATCH 1/4] fix(easee_cloud): report Easee's load balancing below FTW's order (1.4.1) A tester's trace (srcfl/ftw#1763, 23 Oct 2026) showed FTW ordering 15 A and the charger reading back 15 A, while Easee's circuit allocation (observations 70-72) and the current it signalled to the car (114) fell to 12, 14 and 13 A. FTW said "Not following". Read 70-72 and 114. Emit current_limit_a, the signalled current, while the car charges and it is more than half an amp below the order the charger holds (48), and only when it answers that order: recorded at or after it, or unchanged for 30 s since. Emit current_limited_by = "load_balancer" only when the allocation is below the order too and no higher than the signalled current. ReasonForNoCurrent is not used as a cause. Reason labels follow Easee's table (from #172). Port the REST ordering check from #183: while a car is connected, a reply whose power time goes back, conflicts at one instant or cannot be read is not used. Times compare as instants across offsets. The order starts again on disconnect or after 30 s of replies that go back, so a stamp from the future cannot hold the driver silent. Co-authored-by: Segran Co-Authored-By: Claude Opus 5.5 Signed-off-by: Fredrik Ahlgren --- CHANGELOG.md | 32 ++++ SUPPORT_STATUS.md | 4 +- devices.yaml | 2 +- docs/WRITING-A-DRIVER.md | 9 + drivers/lua/easee_cloud.lua | 158 +++++++++++++++--- .../test_easee_cloud_allocated_limit.lua | 143 ++++++++++++++++ .../test_easee_cloud_observation_order.lua | 41 +++++ .../lua_harness/test_easee_cloud_session.lua | 5 +- .../test_easee_cloud_start_boost.lua | 31 +++- .../lua_harness/test_easee_cloud_stream.lua | 13 +- .../tests/test_easee_cloud_allocated_limit.py | 12 ++ .../test_easee_cloud_observation_order.py | 12 ++ index.yaml | 6 +- manifests/easee_cloud.yaml | 10 +- spec/host-api-profile.json | 4 +- support-status.json | 2 +- 16 files changed, 444 insertions(+), 40 deletions(-) create mode 100644 drivers/tests/lua_harness/test_easee_cloud_allocated_limit.lua create mode 100644 drivers/tests/lua_harness/test_easee_cloud_observation_order.lua create mode 100644 drivers/tests/test_easee_cloud_allocated_limit.py create mode 100644 drivers/tests/test_easee_cloud_observation_order.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 52a8522..36e7067 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,38 @@ ## [Unreleased] +### easee_cloud 1.4.1 + +Report when Easee's own load balancing holds the car below FTW's order. A +tester's trace (srcfl/ftw#1763, 23 October 2026) showed FTW ordering 15 A +and the charger reading back 15 A, while Easee's circuit allocation +(observations 70–72) and the current it signalled to the car (114) fell to +12, 14 and 13 A. The car took 8.2–8.9 kW instead of 10.4 kW, and FTW said +"Not following". The driver now reads 70–72 and 114 and emits +`current_limit_a`, the signalled current, while it is below the order the +charger holds (48) by more than half an amp and the car charges. A value +counts only when it answers that order: Easee recorded it at or after the +order, or the order has stood 30 s and the value has not moved. It emits +`current_limited_by = "load_balancer"` only when the allocation is below +the order too and no higher than the signalled current; a three-phase +charger uses the lowest phase, a one-phase charger the highest. +ReasonForNoCurrent is not used as a cause: in that trace reason 77 was +older than the cuts. Labels for reasons 6, 8–11, 25–30 and 77 now match +Easee's published table (from srcfl/device-drivers#172). + +A REST reply is no longer used when, while a car is connected, its power +time goes back from the last reply used, it gives another power value at +the same instant, or its power time is missing or unreadable. A late +cached reply once turned a measured 10.242 kW into 0 W. Times compare as +instants across UTC offsets and fractional precision. A disconnect starts +the order again, and so do replies that have gone back for 30 s, so a +wrong stamp from the future cannot keep the driver silent. The check and +its tests come from Segran in srcfl/device-drivers#183. The stream's own +order is unchanged. + +Not yet seen against a load-balanced Easee with this code. Core reads the +new fields from srcfl/ftw#1537. + ### easee_cloud 1.4.0 Read Easee's SignalR stream when the host grants a WebSocket. Easee diff --git a/SUPPORT_STATUS.md b/SUPPORT_STATUS.md index 3cff62e..a88e8a2 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.4.0 | ftw-core | not_assessed | — | not_recorded | not_assessed | -| easee_cloud | 1.4.0 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | +| easee_cloud | 1.4.1 | ftw-core | not_assessed | — | not_recorded | not_assessed | +| easee_cloud | 1.4.1 | 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 478a570..445a41b 100644 --- a/devices.yaml +++ b/devices.yaml @@ -338,7 +338,7 @@ manufacturers: protocols: - protocol: http driver: "easee_cloud" - version: "1.4.0" + version: "1.4.1" ders: [ev] control: true firmware_versions: "" diff --git a/docs/WRITING-A-DRIVER.md b/docs/WRITING-A-DRIVER.md index 9bbf6e8..cf1aeb5 100644 --- a/docs/WRITING-A-DRIVER.md +++ b/docs/WRITING-A-DRIVER.md @@ -425,6 +425,15 @@ all modes. They never grant control or replace a safety limit: from the dynamic offer in `max_a`. `device_limit_age_s` is time since the successful settings read. Failed reads must not reset its age. Core stops treating it as a current limit after two minutes. +- `current_limit_a`: the current per phase the charger lets the car take + now, when it is below the order the charger holds. Set it only from a + value that answers that order: one the charger reported after it took + the order, or one that has not moved since. Leave it nil otherwise. +- `current_limited_by`: `"load_balancer"` when the charger's own load + balancing sets `current_limit_a`, shown by an allocation that is below + the order and explains the limit. Leave it nil when the cause is not + shown. An older vendor reason code is not proof of a cause. Core says + the car is limited only when its measured power also falls short. A device reading makes a response measured, nothing more. Core calls it confirmed only when an identified, separate site meter shows a matching diff --git a/drivers/lua/easee_cloud.lua b/drivers/lua/easee_cloud.lua index 8ff13ef..bd98a89 100644 --- a/drivers/lua/easee_cloud.lua +++ b/drivers/lua/easee_cloud.lua @@ -29,7 +29,7 @@ DRIVER = { id = "easee_cloud", name = "Easee Cloud", manufacturer = "Easee", - version = "1.4.0", + version = "1.4.1", protocols = { "http" }, capabilities = { "ev" }, description = "Easee Home/Charge via Cloud REST API. No local protocol needed.", @@ -292,9 +292,11 @@ end -- Observation IDs (from developer.easee.com/docs/charger-observation-ids) local OBS_DYN_CURRENT = 48 +local OBS_ALLOCATED_L1 = 70 -- circuit total allocated current L1; L2 71, L3 72 local OBS_REASON_NO_CUR = 96 local OBS_CABLE_LOCKED = 103 local OBS_OP_MODE = 109 +local OBS_OUTPUT_CURRENT = 114 -- current signalled to the car with the pilot local OBS_TOTAL_POWER = 120 local OBS_SESSION_ENERGY = 121 local OBS_LIFETIME_ENERGY = 124 @@ -302,7 +304,7 @@ local OBS_CURRENT = 183 local OBS_VOLTAGE = 194 local OBS_SESSION_START = 223 -local OBS_IDS = "48,96,103,109,120,121,124,183,194,223" +local OBS_IDS = "48,70,71,72,96,103,109,114,120,121,124,183,194,223" local OBS_WANTED = {} for id in OBS_IDS:gmatch("%d+") do OBS_WANTED[tonumber(id)] = true end @@ -311,14 +313,44 @@ for id in OBS_IDS:gmatch("%d+") do OBS_WANTED[tonumber(id)] = true end -- stays current until a newer one arrives. local latest, latest_at, latest_pushed = {}, {}, {} --- source_time_key makes an RFC 3339 UTC time comparable as a string, --- whatever its fractional precision: "…:05Z" and "…:05.120+00:00" become --- "…:05.120000000" and "…:05.120000000". Other offsets give nil. +-- source_time_key makes an RFC 3339 time comparable as a string: seconds +-- since 1970 and nine fractional digits, whatever the offset and precision, +-- so "…03:49:08.1Z" and "…05:49:08.1000000+02:00" give the same key. A time +-- that is not RFC 3339, or names a day that does not exist, gives nil. +-- Written by Segran for srcfl/device-drivers#183. local function source_time_key(stamp) if type(stamp) ~= "string" then return nil end - local base, frac, zone = stamp:match("^(%d%d%d%d%-%d%d%-%d%dT%d%d:%d%d:%d%d)(%.?%d*)(.*)$") - if not base or (zone ~= "Z" and zone ~= "+00:00") then return nil end - return base .. "." .. (frac:sub(2) .. "000000000"):sub(1, 9) + local year, month, day, hour, minute, second, suffix = stamp:match( + "^(%d%d%d%d)%-(%d%d)%-(%d%d)T(%d%d):(%d%d):(%d%d)(.*)$") + if not year then return nil end + year, month, day = tonumber(year), tonumber(month), tonumber(day) + hour, minute, second = tonumber(hour), tonumber(minute), tonumber(second) + if year < 1970 or month < 1 or month > 12 or hour > 23 or minute > 59 or second > 59 then return nil end + local leap = year % 4 == 0 and (year % 100 ~= 0 or year % 400 == 0) + local lengths = {31, leap and 29 or 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31} + if day < 1 or day > lengths[month] then return nil end + local fraction = "" + if suffix:sub(1, 1) == "." then + local digits, zone = suffix:match("^%.(%d+)(.*)$") + if not digits or #digits > 9 then return nil end + fraction, suffix = digits, zone + end + local offset = 0 + if suffix ~= "Z" then + local sign, oh, om = suffix:match("^([+-])(%d%d):(%d%d)$") + if not sign then return nil end + oh, om = tonumber(oh), tonumber(om) + if oh > 23 or om > 59 then return nil end + offset = (oh * 60 + om) * 60 * (sign == "+" and 1 or -1) + end + local function leaps(y) + return math.floor(y / 4) - math.floor(y / 100) + math.floor(y / 400) + end + local days = 365 * (year - 1970) + leaps(year - 1) - leaps(1969) + day - 1 + for m = 1, month - 1 do days = days + lengths[m] end + local seconds = days * 86400 + hour * 3600 + minute * 60 + second - offset + if seconds < 0 then return nil end + return string.format("%012d", seconds) .. "." .. fraction .. string.rep("0", 9 - #fraction) end -- note_observation records a sample. SignalR delivers one connection's @@ -386,7 +418,40 @@ local function state_problem(obs) return nil end --- get_observations reads REST. The caller checks the reply with state_problem. +-- REST can answer from an older cache after a newer reply: a late reply once +-- turned a measured 10.242 kW into 0 W (srcfl/device-drivers#183). While a +-- car is connected, a REST reply is not used when its power time goes back +-- from the last one used, gives another value at the same instant, or +-- cannot be read. A disconnect starts the order again, and so do replies +-- that have gone back for UNORDERED_RESET_MS: then the newer time was wrong, +-- for example a stamp from the future, and must not hold the driver silent. +local UNORDERED_RESET_MS = 30000 +local accepted_power_key, accepted_power_value = nil, nil +local unordered_since_ms = nil +local function power_order_problem(obs, stamps, now) + if obs[OBS_OP_MODE] == 1 then + accepted_power_key, accepted_power_value, unordered_since_ms = nil, nil, nil + return nil + end + local key = source_time_key(stamps[OBS_TOTAL_POWER]) + if not key then return "missing or invalid power source timestamp" end + local problem = nil + if accepted_power_key and key < accepted_power_key then + problem = "out-of-order power observation" + elseif key == accepted_power_key and obs[OBS_TOTAL_POWER] ~= accepted_power_value then + problem = "conflicting power at one source timestamp" + end + if problem then + unordered_since_ms = unordered_since_ms or now + if now - unordered_since_ms < UNORDERED_RESET_MS then return problem end + host.log("warn", "Easee: power times went back for 30 s; using the newest reply") + end + accepted_power_key, accepted_power_value, unordered_since_ms = key, obs[OBS_TOTAL_POWER], nil + return nil +end + +-- get_observations reads REST. The caller checks the reply with state_problem +-- and power_order_problem. local function get_observations(serial) local url = "https://api.easee.com/state/" .. serial .. "/observations?ids=" .. OBS_IDS local resp, err = safe_http_get(url, auth_headers()) @@ -613,7 +678,7 @@ local function owe(id, key, value, now, why) end -- stream_update records a pushed sample. One Core could not read, with a --- time that is not RFC 3339 UTC, is skipped, and so is a state, power or +-- time that is not RFC 3339, is skipped, and so is a state, power or -- energy reading that is not a number; the stream then owes that reading. -- Before the subscription completes the stream replays its current state, -- which can be older than a REST read just before it; the newer one stays. @@ -733,18 +798,18 @@ local REASON_LABELS = { [3] = "offline fallback circuit current too low", [4] = "circuit fuse too low", [5] = "waiting in queue", - [6] = "waiting (other cars fully charged)", + [6] = "waiting in fully charged queue", [7] = "illegal grid type", - [8] = "no current request from primary", - [9] = "max dynamic charger current too low", - [10] = "phase imbalance", - [11] = "equalizer communication lost", - [25] = "equalizer dynamic limit too low", - [26] = "equalizer static limit too low", - [27] = "offline fallback equalizer too low", - [28] = "fuse limit reached", - [29] = "current limited by equalizer", - [30] = "current limited by offline equalizer", + [8] = "no current request from car", + [9] = "master communication lost", + [10] = "equalizer current too low", + [11] = "phase not connected", + [25] = "current limited by circuit fuse", + [26] = "current limited by circuit max current", + [27] = "current limited by dynamic circuit current", + [28] = "current limited by equalizer", + [29] = "current limited by circuit load balancing", + [30] = "current limited by offline settings", [50] = "secondary unit not requesting current", [51] = "max charger current too low", [52] = "max dynamic charger current too low", @@ -755,7 +820,7 @@ local REASON_LABELS = { [57] = "erratic EV", [75] = "limited by cable rating", [76] = "limited by schedule", - [77] = "limited by charger current", + [77] = "limited by charger max current", [78] = "limited by dynamic charger current", [79] = "car not drawing current", [80] = "current ramping", @@ -769,6 +834,17 @@ local REASON_LABELS = { [100] = "EV not accepting current", } +-- Easee's own load balancing can hold the car below FTW's order. In a field +-- trace (srcfl/ftw#1763, 23 Oct 2026) FTW ordered 15 A and the charger read +-- back 15 A (observation 48), while the circuit's allocated current (70-72) +-- and the current signalled to the car (114) fell to 12, 14 and 13 A; the +-- car took 8.2-8.9 kW instead of 10.4 kW. Easee records a value when it +-- changes, so a value answers the order when it was recorded at or after +-- the order, or when the order has stood ORDER_SETTLE_MS since the driver +-- saw it and the value has not moved. +local ORDER_SETTLE_MS = 30000 +local order_seen_a, order_seen_ms = nil, 0 + local email, password, configured_max_a local device_limit_a, device_limit_read_ms local settings_poll_ms = 0 @@ -964,11 +1040,14 @@ function driver_poll() local rest_read = false if rest_due then local problem = err or state_problem(values) - rest_next_ms = now + (problem and 10000 or REST_CONFIRM_MS) + local unordered = not problem and power_order_problem(values, stamps, now) + rest_next_ms = now + ((problem or unordered) and 10000 or REST_CONFIRM_MS) if problem then host.log("warn", "Easee: observations poll failed: " .. (err and redact_http_err(err) or problem)) -- The cloud answered that it does not hear the charger. if not err then heard_ms = nil end + elseif unordered then + host.log("warn", "Easee: observations reply not used: " .. unordered) else -- Without the stream the reply is the whole state, as before. if not streaming then latest, latest_at, latest_pushed = {}, {}, {} end @@ -1093,6 +1172,37 @@ function driver_poll() request_active = false end + -- current_limit_a is the current signalled to the car while it is below + -- the order the charger holds and answers it. current_limited_by names + -- the load balancer only when the circuit's allocation is below the order + -- too and no higher than that current, so the allocation explains it. + -- ReasonForNoCurrent is not used here: an older reason is not proof. + if dyn_current ~= order_seen_a then order_seen_a, order_seen_ms = dyn_current, now end + local order_at = source_time_key(timestamps[OBS_DYN_CURRENT]) + local order_settled = now - order_seen_ms >= ORDER_SETTLE_MS + local function answering(id) + local value, value_at = obs[id], source_time_key(timestamps[id]) + if type(value) ~= "number" then return nil end + if order_settled or (value_at and order_at and value_at >= order_at) then return value end + return nil + end + local current_limit_a, current_limited_by = nil, nil + local pilot_a = answering(OBS_OUTPUT_CURRENT) + if charging and type(dyn_current) == "number" and pilot_a and pilot_a > 0 and pilot_a < dyn_current - 0.5 then + current_limit_a = pilot_a + local low, high, n = nil, nil, 0 + for id = OBS_ALLOCATED_L1, OBS_ALLOCATED_L1 + 2 do + local a = answering(id) + if a then low, high, n = math.min(low or a, a), math.max(high or a, a), n + 1 end + end + -- A three-phase car gets the lowest phase's share. A one-phase car + -- uses one phase, which the driver does not know, so the highest. + local allocated_a = (phases == 1) and high or (n == 3 and low or nil) + if allocated_a and allocated_a < dyn_current - 0.5 and allocated_a <= pilot_a + 0.5 then + current_limited_by = "load_balancer" + end + end + local limit_age_s = device_limit_read_ms and (host.millis() - device_limit_read_ms) / 1000 host.emit("ev", { w = power_w, @@ -1123,6 +1233,8 @@ function driver_poll() cable_locked = cable_locked, device_limit_a = device_limit_a, device_limit_age_s = limit_age_s, + current_limit_a = current_limit_a, -- A signalled to the car, below the order + current_limited_by = current_limited_by, -- "load_balancer" or nil 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) diff --git a/drivers/tests/lua_harness/test_easee_cloud_allocated_limit.lua b/drivers/tests/lua_harness/test_easee_cloud_allocated_limit.lua new file mode 100644 index 0000000..f5df8c1 --- /dev/null +++ b/drivers/tests/lua_harness/test_easee_cloud_allocated_limit.lua @@ -0,0 +1,143 @@ +dofile("drivers/tests/lua_harness/host_mock.lua") +-- Easee's own load balancing can hold the car below FTW's order. Field +-- trace (srcfl/ftw#1763, 23 Oct 2026, UTC): FTW ordered 15 A and the +-- charger read back 15 A (observation 48), while the circuit's allocated +-- current (70-72) and the current signalled to the car (114) were 12 A at +-- the start, then 14 A and 13 A; the car took 8.2-8.9 kW, not 10.4 kW. +local driver = "drivers/lua/easee_cloud.lua" +local RS = "\30" +local SERIAL = "EH123456" + +local function later(ms) host._millis_counter = host._millis_counter + ms end + +local function boot(extra, ws) + host.reset() + host._millis_step = 0 + host._ws_granted = ws or false + host._http_responses["/accounts/login"] = '{"accessToken":"test","expiresIn":3600}' + host._http_responses["/config"] = '{"maxChargerCurrent":16}' + host._http_responses["/sessions/ongoing"] = '{}' + dofile(driver) + local cfg = {email="test@example.invalid", password="test", serial=SERIAL} + for k, v in pairs(extra or {}) do cfg[k] = v end + driver_init(cfg) +end + +-- The trace crosses midnight UTC: 23:xx is 22 October, the rest 23 October. +local function at(clock) + return (clock:sub(1, 2) == "23" and "2026-10-22T" or "2026-10-23T") .. clock .. "Z" +end + +-- obs builds a REST reply. o.alloc is {L1, L2, L3}; false leaves a value out. +local function obs(o) + local list = { + {id=109, value=o.mode or 3, timestamp=at("23:53:00")}, + {id=120, value=o.kw or 8.3, timestamp=at(o.power_at or "23:54:30")}, + {id=121, value=1.5, timestamp=at(o.power_at or "23:54:30")}, + } + if o.order ~= false then + table.insert(list, {id=48, value=o.order or 15, timestamp=at(o.order_at or "23:50:00")}) + end + if o.pilot ~= false then + table.insert(list, {id=114, value=o.pilot or 12, timestamp=at(o.pilot_at or "23:54:22")}) + end + local alloc = o.alloc == nil and {12, 12, 12} or o.alloc + for i = 1, 3 do + if alloc and alloc[i] ~= false and alloc[i] ~= nil then + table.insert(list, {id=69 + i, value=alloc[i], timestamp=at(o.alloc_at or o.pilot_at or "23:54:22")}) + end + end + if o.reason then table.insert(list, {id=96, value=o.reason, timestamp=at(o.reason_at)}) end + return list +end + +local function poll(o) + host._http_responses["/observations?ids="] = host.json_encode(obs(o or {})) + driver_poll() + local rows = host._emitted.ev or {} + return rows[#rows], #rows +end + +local function expect(ev, limit, by, what) + assert(ev, what .. ": no EV sample") + assert(ev.current_limit_a == limit and ev.current_limited_by == by, + string.format("%s: got %s/%s, want %s/%s", what, tostring(ev.current_limit_a), + tostring(ev.current_limited_by), tostring(limit), tostring(by))) +end + +-- 1. The field trace: allocation and pilot below the 15 A order and newer +-- than it. The allocation explains the pilot, so the load balancer is named. +boot() +expect(poll(), 12, "load_balancer", "start at 12 A") +expect(poll({pilot=15, alloc={15, 15, 15}, pilot_at="23:55:14", kw=10.37, power_at="23:55:30"}), + nil, nil, "back at the order") +expect(poll({pilot=14, alloc={14, 14, 14}, pilot_at="00:00:21", kw=9.6, power_at="00:00:25"}), + 14, "load_balancer", "fall to 14 A") +expect(poll({pilot=13, alloc={13, 13, 13}, pilot_at="00:00:32", kw=8.85, power_at="00:00:40"}), + 13, "load_balancer", "fall to 13 A") + +-- 2. Below the order, but nothing says why: the current is reported, the +-- cause is not. An allocation above the pilot does not explain it, and an +-- old ReasonForNoCurrent is not proof either. +boot() +expect(poll({pilot=13, alloc={15, 15, 15}}), 13, nil, "allocation at the order") +expect(poll({pilot=13, alloc=false}), 13, nil, "no allocation") +expect(poll({pilot=13, alloc=false, reason=29, reason_at="23:40:00"}), 13, nil, "old load-balancing reason") +expect(poll({pilot=13, alloc=false, reason=77, reason_at="23:56:25"}), 13, nil, "charger reason") +expect(poll({pilot=12, alloc={13, 14, 14}}), 12, nil, "allocation above the pilot") + +-- 3. A three-phase car takes the lowest phase's share. A one-phase charger +-- uses one phase, which the driver does not know, so the highest counts. +boot() +expect(poll({pilot=12, alloc={12, 14, 14}}), 12, "load_balancer", "lowest phase") +expect(poll({pilot=12, alloc={12, 12, false}}), 12, nil, "three-phase with a phase missing") +boot({phases=1}) +expect(poll({pilot=12, alloc={12, 0, 0}}), 12, "load_balancer", "one phase") + +-- 4. At or near the order, not charging, or no positive pilot: no limit. +boot() +expect(poll({pilot=15, alloc={15, 15, 15}}), nil, nil, "at the order") +expect(poll({pilot=14.6, alloc={14, 14, 14}, order=15}), nil, nil, "within half an amp") +expect(poll({mode=2, kw=0, pilot=0, alloc={0, 0, 0}, power_at="23:55:00"}), nil, nil, "waiting to start") +expect(poll({mode=3, kw=0, pilot=0, alloc={0, 0, 0}, power_at="23:55:00"}), nil, nil, "zero pilot") +expect(poll({order=false, power_at="23:55:00", kw=0}), nil, nil, "no readback of the order") + +-- 5. Right after FTW raises the order, an older pilot has not answered it +-- yet. It counts once it changes after the order, or once the order has +-- stood 30 s without the pilot moving. +boot() +expect(poll({order=10, pilot=10, alloc={10, 10, 10}}), nil, nil, "at the old order") +expect(poll({order=15, order_at="23:58:00", pilot=10, alloc={10, 10, 10}}), nil, nil, "not answered yet") +later(29000) +expect(poll({order=15, order_at="23:58:00", pilot=10, alloc={10, 10, 10}}), nil, nil, "29 s") +later(1000) +expect(poll({order=15, order_at="23:58:00", pilot=10, alloc={10, 10, 10}}), 10, "load_balancer", "30 s") +expect(poll({order=16, order_at="23:59:00", pilot=12, pilot_at="23:59:04", alloc={12, 12, 12}}), + 12, "load_balancer", "answered at once") +expect(poll({order=16, order_at="23:59:00", pilot=12, pilot_at="23:59:04", alloc={12, 12, 12}, + alloc_at="23:58:30"}), 12, nil, "allocation older than the new order") + +-- 6. The stream: pushed allocation and pilot reach the next sample. +local function push(...) + local frame = "" + for _, m in ipairs({...}) do frame = frame .. (type(m) == "string" and m or host.json_encode(m)) .. RS end + table.insert(host._ws_inbox, frame) +end +local function update(id, value, clock) + return {type=1, target="ProductUpdate", arguments={{mid=SERIAL, dataType=3, id=id, timestamp=at(clock), value=value}}} +end +boot(nil, true) +host._http_responses["/observations?ids="] = host.json_encode(obs({pilot=15, alloc={15, 15, 15}, kw=10.37})) +driver_poll() +push("{}") +later(5000); driver_poll() +push({type=3, invocationId="0"}) +later(5000) +assert(driver_poll() == 2000, "stream did not come up") +push(update(70, "12", "00:00:21"), update(71, "12", "00:00:21"), update(72, "12", "00:00:21"), + update(114, "12", "00:00:21")) +later(2000); driver_poll() +local rows = host._emitted.ev +expect(rows[#rows], 12, "load_balancer", "pushed allocation and pilot") + +print("easee_cloud allocated limit: ok") diff --git a/drivers/tests/lua_harness/test_easee_cloud_observation_order.lua b/drivers/tests/lua_harness/test_easee_cloud_observation_order.lua new file mode 100644 index 0000000..2faa2a0 --- /dev/null +++ b/drivers/tests/lua_harness/test_easee_cloud_observation_order.lua @@ -0,0 +1,41 @@ +dofile("drivers/tests/lua_harness/host_mock.lua") +host.reset() +host._millis_step = 0 +host._http_responses["/accounts/login"] = '{"accessToken":"test","expiresIn":3600}' +host._http_responses["/config"] = '{"maxChargerCurrent":16}' +host._http_responses["/sessions/ongoing"] = '{}' +dofile("drivers/lua/easee_cloud.lua") +driver_init({email="test@example.invalid",password="test",serial="TEST123"}) +local function poll(w, stamp, mode) + host._http_responses["/observations?ids="] = host.json_encode({ + {id=109,value=mode or 3,timestamp="2026-10-17T03:49:10Z"}, + {id=120,value=w,timestamp=stamp}, + {id=121,value=12.5,timestamp="2026-10-17T03:49:10Z"}, + }) + driver_poll() + local rows=host._emitted.ev or {} + return #rows, rows[#rows] +end +local count, row = poll(10.242,"2026-10-17T03:49:08.1Z") +assert(count==1 and row.w==10242,"initial measured power missing") +local n = poll(0,"2026-10-17T03:49:07Z") +assert(n==count,"older cloud zero replaced a newer power observation") +n = poll(0,"2026-10-17T05:49:08.1000000+02:00") +assert(n==count,"contradictory value at the same physical time was accepted") +n = poll(0,nil) +assert(n==count,"missing source time became fresh zero power") +n = poll(0,"invalid") +assert(n==count,"malformed source time became fresh power") +n = poll(0,"2026-02-30T03:49:08Z") +assert(n==count,"impossible date was accepted") +n,row = poll(10.242,"2026-10-17T05:49:08.1000000+02:00") +assert(n==count+1 and row.control_power_confirmed,"unchanged valid sample no longer confirmed") +count=n +n,row = poll(0,"2026-10-17T03:49:09.1Z",4) +assert(n==count+1 and row.w==0,"a newer observed stop was ignored") +count=n +n = poll(10.242,"2026-10-17T03:49:08.1Z") +assert(n==count,"old positive power resurrected a stopped charge") +n,row = poll(6.2,"2026-10-17T03:49:10Z") +assert(n==count+1 and row.w==6200,"fresh subsequent power did not recover") +print("Easee observation ordering: passed") diff --git a/drivers/tests/lua_harness/test_easee_cloud_session.lua b/drivers/tests/lua_harness/test_easee_cloud_session.lua index b5ea64e..0b67cc5 100644 --- a/drivers/tests/lua_harness/test_easee_cloud_session.lua +++ b/drivers/tests/lua_harness/test_easee_cloud_session.lua @@ -10,9 +10,12 @@ local function boot(ended) dofile(driver) driver_init({email="test@example.invalid",password="test",serial=serial}) end +local poll_tick = 0 local function poll(mode, session, energy, lifetime) + poll_tick = poll_tick + 1 + local power_stamp = string.format("2026-01-03T00:%02d:%02dZ", math.floor(poll_tick / 60), poll_tick % 60) local obs = { - {id=109,value=mode}, {id=120,value=mode == 3 and 4.3 or 0}, + {id=109,value=mode}, {id=120,value=mode == 3 and 4.3 or 0,timestamp=power_stamp}, {id=121,value=energy or 9}, {id=124,value=lifetime or 1009}, } if session ~= nil then table.insert(obs,{id=223,value=session}) end diff --git a/drivers/tests/lua_harness/test_easee_cloud_start_boost.lua b/drivers/tests/lua_harness/test_easee_cloud_start_boost.lua index 38ab7c5..fa84586 100644 --- a/drivers/tests/lua_harness/test_easee_cloud_start_boost.lua +++ b/drivers/tests/lua_harness/test_easee_cloud_start_boost.lua @@ -3,7 +3,9 @@ dofile("drivers/tests/lua_harness/host_mock.lua") local driver = "drivers/lua/easee_cloud.lua" +local sample = 0 local function boot(cfg, config_body) + sample = 0 host.reset() host._http_responses["/accounts/login"] = '{"accessToken":"test","expiresIn":3600}' host._http_responses["/config"] = config_body or '{"maxChargerCurrent":16}' @@ -35,9 +37,11 @@ local function count_cmd(path) return n end -local function poll(mode, kw) +local function poll(mode, kw, stamp) + sample = sample + 1 + stamp = stamp or string.format("2026-10-23T10:00:%02dZ", sample) 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}, + {id=109,value=mode}, {id=120,value=kw or 0,timestamp=stamp}, {id=121,value=1}, {id=124,value=100}, }) driver_poll() end @@ -238,4 +242,27 @@ later(5000) poll(2) assert(last_amps() == 0, "re-assert after default mode wrote the cleared boost's 7 A") +-- 18. A rejected power timestamp cannot establish a stopped charger after +-- reload, or falsely report charging and end a boost. Fresh data recovers. +boot() +poll(2, 0, "invalid") +assert(order(4140)); assert(last_amps() == 6, "invalid first observation enabled boost") +poll(2) +assert(order(4140)); assert(last_amps() == 7) +local rows = #(host._emitted.ev or {}) +poll(3, 4.8, "2026-10-23T10:00:01Z") +assert(#(host._emitted.ev or {}) == rows, "stale power emitted during boost") +assert(last_amps() == 7, "stale charging observation ended boost") +poll(3, 4.8) +assert(last_amps() == 6, "fresh charging observation did not end boost") + +-- 19. Rejecting old data must not prevent the bounded boost timing out. +boot() +poll(2) +assert(order(4140)); assert(last_amps() == 7) +later(91000) +poll(2, 0, "2026-10-23T10:00:00Z") +assert(last_amps() == 6, "rejected observation extended boost past timeout") +assert(order(4140)); assert(last_amps() == 6, "boost restarted after rejection and timeout") + print("easee_cloud start boost: ok") diff --git a/drivers/tests/lua_harness/test_easee_cloud_stream.lua b/drivers/tests/lua_harness/test_easee_cloud_stream.lua index f59152b..f633cc5 100644 --- a/drivers/tests/lua_harness/test_easee_cloud_stream.lua +++ b/drivers/tests/lua_harness/test_easee_cloud_stream.lua @@ -275,8 +275,9 @@ assert(rest_reads() == reads + 1, "REST stopped after a refused subscription") assert(not host._ws_is_open, "a refused stream stayed open") -- 10. Without the stream a REST reply is the whole state, as in 1.3.8: an --- id it leaves out does not keep an earlier reply's value, and a stamp from --- the future does not hold a value. +-- id it leaves out does not keep an earlier reply's value. A reply whose +-- power time goes back is not used (srcfl/device-drivers#183), but a stamp +-- from the future holds a value for 30 s at most. setup(false) poll() host._http_responses["/observations?ids="] = host.json_encode({ @@ -290,8 +291,14 @@ assert(ev.control_power_available == false and ev.control_power_confirmed == fal rest(3, 7.2, "2026-10-24T13:00:00Z") poll(5000) rest(3, 0, "2026-10-24T10:05:00Z") +local _, held = samples() poll(5000) -assert(samples().w == 0, "a REST stamp from the future held its value") +local _, after_back = samples() +assert(after_back == held, "a reply whose power time went back was used") +for _ = 1, 5 do poll(5000) end +assert(samples().w == 7200, "the order started again before 30 s") +poll(5000) +assert(samples().w == 0, "a REST stamp from the future held its value for over 30 s") -- 11. A change REST sees first is owed by the stream. A push still on its -- way keeps the stream up; one that never comes stops it after 10 s. diff --git a/drivers/tests/test_easee_cloud_allocated_limit.py b/drivers/tests/test_easee_cloud_allocated_limit.py new file mode 100644 index 0000000..0a881f6 --- /dev/null +++ b/drivers/tests/test_easee_cloud_allocated_limit.py @@ -0,0 +1,12 @@ +"""Easee's load balancing below FTW's order is reported as a limit.""" +from pathlib import Path +import subprocess + + +def test_easee_cloud_reports_allocated_limit(): + root = Path(__file__).resolve().parents[2] + result = subprocess.run( + [str(root / "lua55"), "drivers/tests/lua_harness/test_easee_cloud_allocated_limit.lua"], + cwd=root, text=True, capture_output=True, check=False, + ) + assert result.returncode == 0, result.stdout + result.stderr diff --git a/drivers/tests/test_easee_cloud_observation_order.py b/drivers/tests/test_easee_cloud_observation_order.py new file mode 100644 index 0000000..dbc168c --- /dev/null +++ b/drivers/tests/test_easee_cloud_observation_order.py @@ -0,0 +1,12 @@ +"""Late cloud packets must not replace a newer power observation.""" +from pathlib import Path +import subprocess + + +def test_easee_cloud_preserves_power_source_order(): + root = Path(__file__).resolve().parents[2] + result = subprocess.run( + [str(root / "lua55"), "drivers/tests/lua_harness/test_easee_cloud_observation_order.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 e2744f1..b4a8afa 100644 --- a/index.yaml +++ b/index.yaml @@ -193,15 +193,15 @@ drivers: size_bytes: 4054 sha256: "4a2cd1efb4a5583468ce897ebd88a000e348917295c40210e86ecf834c0ba543" - name: "easee_cloud" - version: "1.4.0" + version: "1.4.1" tier: core protocol: http connectivity: cloud setup: [vendor_portal] ders: [ev] control: true - size_bytes: 64126 - sha256: "94653989702cb805c56db296be0651886b4b685a0d407bd69089450b86571abe" + size_bytes: 70388 + sha256: "60118da1fd48f26635e4dcee70101aab835c87d94dc311c1418a7aeb2ce2e4d2" - name: "esphome_dsmr" version: "1.0.7" tier: core diff --git a/manifests/easee_cloud.yaml b/manifests/easee_cloud.yaml index 3b55ee8..5cb12c2 100644 --- a/manifests/easee_cloud.yaml +++ b/manifests/easee_cloud.yaml @@ -1,5 +1,5 @@ name: "easee_cloud" -version: "1.4.0" +version: "1.4.1" 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: 64126 +size_bytes: 70388 dkb_id: "easee_cloud" -sha256: "94653989702cb805c56db296be0651886b4b685a0d407bd69089450b86571abe" +sha256: "60118da1fd48f26635e4dcee70101aab835c87d94dc311c1418a7aeb2ce2e4d2" signature: "" bytecode_sha256: "" @@ -28,6 +28,10 @@ upstream_docs: title: "Easee charger observations" kind: api_docs url_stability: stable + - url: "https://developer.easee.com/docs/enumerations" + title: "Easee enumerations (ReasonForNoCurrent)" + kind: api_docs + url_stability: stable - url: "https://developer.easee.com/reference/chargers_getongoingsessiondetails" title: "Easee ongoing session API" kind: api_docs diff --git a/spec/host-api-profile.json b/spec/host-api-profile.json index 92d2e4a..7550b2b 100644 --- a/spec/host-api-profile.json +++ b/spec/host-api-profile.json @@ -214,7 +214,9 @@ "control_power_observed_at", "control_power_confirmed", "device_limit_a", - "device_limit_age_s" + "device_limit_age_s", + "current_limit_a", + "current_limited_by" ] }, "repeating_structures": "A repeating structure is a plural-named array, not numbered keys. pv.mppts is a list of {V, A, W} whose length is what the device physically has. The catalog's mppt1_v/mppt2_v cannot represent a four-MPPT inverter at all.", diff --git a/support-status.json b/support-status.json index 054edf7..2c8268f 100644 --- a/support-status.json +++ b/support-status.json @@ -443,7 +443,7 @@ }, { "catalog_source": true, - "catalog_version": "1.4.0", + "catalog_version": "1.4.1", "driver_id": "easee_cloud", "targets": { "blixt-l1": { From 4297ee6e6fe3b04054e1821082eb946235193e4c Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Sun, 25 Oct 2026 19:46:35 +0100 Subject: [PATCH 2/4] fix(easee_cloud): review fixes to the limit and the ordering check - An unreadable power time no longer silences the driver for good: after 30 s of such replies they are used as they come, as before, until a readable time arrives. - A late "disconnected" reply is held to the same order as any other. - A failed poll ends a run of held-back replies, so a rejection before an outage does not count toward the 30 s. - A held-back reply does not drop the session proof, which cost one of Easee's ten session checks an hour each time. - Report a limit only while the charger reads back FTW's last order; do not name the load balancer when the charger's own static limit explains the pilot; a phase with no allocation is not one in use. - Tests for each, all failing on the previous commit, plus the one-phase highest-allocation rule, a finished car, a pilot recorded in the same second as the order, and an impossible first date. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Fredrik Ahlgren --- CHANGELOG.md | 38 +++++---- docs/WRITING-A-DRIVER.md | 8 +- drivers/lua/easee_cloud.lua | 71 +++++++++++----- .../test_easee_cloud_allocated_limit.lua | 45 +++++++++- .../test_easee_cloud_observation_order.lua | 83 +++++++++++++++++++ index.yaml | 4 +- manifests/easee_cloud.yaml | 4 +- 7 files changed, 203 insertions(+), 50 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 36e7067..2e6c8e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,26 +10,32 @@ and the charger reading back 15 A, while Easee's circuit allocation (observations 70–72) and the current it signalled to the car (114) fell to 12, 14 and 13 A. The car took 8.2–8.9 kW instead of 10.4 kW, and FTW said "Not following". The driver now reads 70–72 and 114 and emits -`current_limit_a`, the signalled current, while it is below the order the -charger holds (48) by more than half an amp and the car charges. A value -counts only when it answers that order: Easee recorded it at or after the -order, or the order has stood 30 s and the value has not moved. It emits -`current_limited_by = "load_balancer"` only when the allocation is below -the order too and no higher than the signalled current; a three-phase -charger uses the lowest phase, a one-phase charger the highest. +`current_limit_a`, the signalled current, while the car charges, the +charger reads back FTW's last order (48) and the signalled current is +more than half an amp below it. A value counts only when it answers that +order: Easee recorded it at or after the order, or the order has stood +30 s. It emits `current_limited_by = "load_balancer"` only when the +allocation is below the order too and no higher than the signalled +current, and the charger's own static limit does not explain it. A +three-phase charger uses the lowest allocation of the phases in use (a +phase with none is not in use); a one-phase charger the highest. ReasonForNoCurrent is not used as a cause: in that trace reason 77 was older than the cuts. Labels for reasons 6, 8–11, 25–30 and 77 now match Easee's published table (from srcfl/device-drivers#172). -A REST reply is no longer used when, while a car is connected, its power -time goes back from the last reply used, it gives another power value at -the same instant, or its power time is missing or unreadable. A late -cached reply once turned a measured 10.242 kW into 0 W. Times compare as -instants across UTC offsets and fractional precision. A disconnect starts -the order again, and so do replies that have gone back for 30 s, so a -wrong stamp from the future cannot keep the driver silent. The check and -its tests come from Segran in srcfl/device-drivers#183. The stream's own -order is unchanged. +A REST reply is no longer used when its power time goes back from the +last reply used or gives another power value at the same instant, or, +while a car is connected, when its power time is missing or unreadable. A +late cached reply once turned a measured 10.242 kW into 0 W. Times +compare as instants across UTC offsets and fractional precision. A +disconnect starts the order again. Such replies one after another for +30 s mean the time itself is wrong: an older time then starts the order +again, and unreadable times are used as they come until a readable one +arrives, so a stamp from the future or an odd format cannot keep the +driver silent. A failed poll ends such a run, and a held-back reply does +not use up one of the ten session checks an hour. The check and its tests +come from Segran in srcfl/device-drivers#183. The stream's own order is +unchanged. Not yet seen against a load-balanced Easee with this code. Core reads the new fields from srcfl/ftw#1537. diff --git a/docs/WRITING-A-DRIVER.md b/docs/WRITING-A-DRIVER.md index cf1aeb5..0efa6ce 100644 --- a/docs/WRITING-A-DRIVER.md +++ b/docs/WRITING-A-DRIVER.md @@ -426,9 +426,11 @@ all modes. They never grant control or replace a safety limit: successful settings read. Failed reads must not reset its age. Core stops treating it as a current limit after two minutes. - `current_limit_a`: the current per phase the charger lets the car take - now, when it is below the order the charger holds. Set it only from a - value that answers that order: one the charger reported after it took - the order, or one that has not moved since. Leave it nil otherwise. + now, while the car charges and that current is below the host's last + order, which the charger reads back. Set it only from a value that + answers that order: one the charger reported at or after it took the + order, or any once the order has stood long enough for the value to + move. Leave it nil otherwise. - `current_limited_by`: `"load_balancer"` when the charger's own load balancing sets `current_limit_a`, shown by an allocation that is below the order and explains the limit. Leave it nil when the cause is not diff --git a/drivers/lua/easee_cloud.lua b/drivers/lua/easee_cloud.lua index bd98a89..e561e27 100644 --- a/drivers/lua/easee_cloud.lua +++ b/drivers/lua/easee_cloud.lua @@ -419,34 +419,47 @@ local function state_problem(obs) end -- REST can answer from an older cache after a newer reply: a late reply once --- turned a measured 10.242 kW into 0 W (srcfl/device-drivers#183). While a --- car is connected, a REST reply is not used when its power time goes back --- from the last one used, gives another value at the same instant, or --- cannot be read. A disconnect starts the order again, and so do replies --- that have gone back for UNORDERED_RESET_MS: then the newer time was wrong, --- for example a stamp from the future, and must not hold the driver silent. +-- turned a measured 10.242 kW into 0 W (srcfl/device-drivers#183). A REST +-- reply is not used when its power time goes back from the last one used, +-- gives another value at the same instant, or, while a car is connected, +-- cannot be read. Such replies one after another for UNORDERED_RESET_MS +-- mean the time itself is wrong, for example a stamp from the future or a +-- format this parser does not know, so the driver uses them rather than go +-- silent: an older time starts the order again, and unreadable ones are +-- used as they come until a readable time arrives. A disconnect starts the +-- order again too. A failed poll ends the run of rejected replies. local UNORDERED_RESET_MS = 30000 local accepted_power_key, accepted_power_value = nil, nil -local unordered_since_ms = nil +local unordered_since_ms, unreadable_logged = nil, false local function power_order_problem(obs, stamps, now) - if obs[OBS_OP_MODE] == 1 then - accepted_power_key, accepted_power_value, unordered_since_ms = nil, nil, nil - return nil - end - local key = source_time_key(stamps[OBS_TOTAL_POWER]) - if not key then return "missing or invalid power source timestamp" end + local value, key = obs[OBS_TOTAL_POWER], source_time_key(stamps[OBS_TOTAL_POWER]) + local disconnected = obs[OBS_OP_MODE] == 1 local problem = nil - if accepted_power_key and key < accepted_power_key then + if not key then + if not disconnected then problem = "missing or invalid power source timestamp" end + elseif accepted_power_key and key < accepted_power_key then problem = "out-of-order power observation" - elseif key == accepted_power_key and obs[OBS_TOTAL_POWER] ~= accepted_power_value then + elseif key == accepted_power_key and value ~= accepted_power_value then problem = "conflicting power at one source timestamp" end if problem then unordered_since_ms = unordered_since_ms or now if now - unordered_since_ms < UNORDERED_RESET_MS then return problem end + if not key then + if not unreadable_logged then + host.log("warn", "Easee: power times unreadable for 30 s; using replies as they come") + end + unreadable_logged = true + return nil + end host.log("warn", "Easee: power times went back for 30 s; using the newest reply") end - accepted_power_key, accepted_power_value, unordered_since_ms = key, obs[OBS_TOTAL_POWER], nil + unordered_since_ms, unreadable_logged = nil, false + if disconnected then + accepted_power_key, accepted_power_value = nil, nil + else + accepted_power_key, accepted_power_value = key, value + end return nil end @@ -1043,6 +1056,7 @@ function driver_poll() local unordered = not problem and power_order_problem(values, stamps, now) rest_next_ms = now + ((problem or unordered) and 10000 or REST_CONFIRM_MS) if problem then + unordered_since_ms = nil host.log("warn", "Easee: observations poll failed: " .. (err and redact_http_err(err) or problem)) -- The cloud answered that it does not hear the charger. if not err then heard_ms = nil end @@ -1075,8 +1089,9 @@ function driver_poll() end last_blocked = blocked or nil if not vouched or blocked then - -- An unreadable charger may have changed cars during the gap. - invalidate_session_proof() + -- An unreadable charger may have changed cars during the gap. A + -- reply held back for its order is not a gap. + if not unordered_since_ms then invalidate_session_proof() end return stream_state == "up" and STREAM_POLL_MS or 10000 end local obs, timestamps = latest, latest_at @@ -1186,19 +1201,29 @@ function driver_poll() if order_settled or (value_at and order_at and value_at >= order_at) then return value end return nil end + -- The charger must hold FTW's last order: while it reads back an older + -- one, the pilot may be following the new order. The charger's own + -- static limit is device_limit_a, not its load balancing. local current_limit_a, current_limited_by = nil, nil local pilot_a = answering(OBS_OUTPUT_CURRENT) - if charging and type(dyn_current) == "number" and pilot_a and pilot_a > 0 and pilot_a < dyn_current - 0.5 then + if charging and type(dyn_current) == "number" and last_amps_set ~= nil and + math.abs(last_amps_set - dyn_current) <= 0.5 and + pilot_a and pilot_a > 0 and pilot_a < dyn_current - 0.5 then current_limit_a = pilot_a local low, high, n = nil, nil, 0 for id = OBS_ALLOCATED_L1, OBS_ALLOCATED_L1 + 2 do local a = answering(id) - if a then low, high, n = math.min(low or a, a), math.max(high or a, a), n + 1 end + if a then + n, high = n + 1, math.max(high or a, a) + if a > 0 then low = math.min(low or a, a) end + end end - -- A three-phase car gets the lowest phase's share. A one-phase car - -- uses one phase, which the driver does not know, so the highest. + -- A three-phase car gets the lowest share of the phases it uses; a + -- phase with none is not one of them. A one-phase car uses one + -- phase, which the driver does not know, so the highest. local allocated_a = (phases == 1) and high or (n == 3 and low or nil) - if allocated_a and allocated_a < dyn_current - 0.5 and allocated_a <= pilot_a + 0.5 then + local own_limit = device_limit_a ~= nil and pilot_a >= device_limit_a - 0.5 + if allocated_a and allocated_a < dyn_current - 0.5 and allocated_a <= pilot_a + 0.5 and not own_limit then current_limited_by = "load_balancer" end end diff --git a/drivers/tests/lua_harness/test_easee_cloud_allocated_limit.lua b/drivers/tests/lua_harness/test_easee_cloud_allocated_limit.lua index f5df8c1..2612bfd 100644 --- a/drivers/tests/lua_harness/test_easee_cloud_allocated_limit.lua +++ b/drivers/tests/lua_harness/test_easee_cloud_allocated_limit.lua @@ -10,12 +10,14 @@ local SERIAL = "EH123456" local function later(ms) host._millis_counter = host._millis_counter + ms end -local function boot(extra, ws) +local function boot(extra, ws, config_body) host.reset() host._millis_step = 0 host._ws_granted = ws or false host._http_responses["/accounts/login"] = '{"accessToken":"test","expiresIn":3600}' - host._http_responses["/config"] = '{"maxChargerCurrent":16}' + 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 cfg = {email="test@example.invalid", password="test", serial=SERIAL} @@ -23,6 +25,13 @@ local function boot(extra, ws) driver_init(cfg) end +-- order sends FTW's order, as Core does every tick; amps per phase. +local function order(a, phase_mode) + local p = phase_mode == "1p" and 1 or 3 + assert(driver_command("ev_set_current", a * 230 * p, + {phase_mode=phase_mode or "3p", voltage=230, max_amps_per_phase=25, cap_amps_per_phase=25})) +end + -- The trace crosses midnight UTC: 23:xx is 22 October, the rest 23 October. local function at(clock) return (clock:sub(1, 2) == "23" and "2026-10-22T" or "2026-10-23T") .. clock .. "Z" @@ -68,6 +77,7 @@ end -- 1. The field trace: allocation and pilot below the 15 A order and newer -- than it. The allocation explains the pilot, so the load balancer is named. boot() +order(15) expect(poll(), 12, "load_balancer", "start at 12 A") expect(poll({pilot=15, alloc={15, 15, 15}, pilot_at="23:55:14", kw=10.37, power_at="23:55:30"}), nil, nil, "back at the order") @@ -80,6 +90,7 @@ expect(poll({pilot=13, alloc={13, 13, 13}, pilot_at="00:00:32", kw=8.85, power_a -- cause is not. An allocation above the pilot does not explain it, and an -- old ReasonForNoCurrent is not proof either. boot() +order(15) expect(poll({pilot=13, alloc={15, 15, 15}}), 13, nil, "allocation at the order") expect(poll({pilot=13, alloc=false}), 13, nil, "no allocation") expect(poll({pilot=13, alloc=false, reason=29, reason_at="23:40:00"}), 13, nil, "old load-balancing reason") @@ -88,36 +99,61 @@ expect(poll({pilot=12, alloc={13, 14, 14}}), 12, nil, "allocation above the pilo -- 3. A three-phase car takes the lowest phase's share. A one-phase charger -- uses one phase, which the driver does not know, so the highest counts. +-- A phase with no allocation is not one the car uses. boot() +order(15) expect(poll({pilot=12, alloc={12, 14, 14}}), 12, "load_balancer", "lowest phase") expect(poll({pilot=12, alloc={12, 12, false}}), 12, nil, "three-phase with a phase missing") +expect(poll({pilot=12, alloc={12, 0, 0}}), 12, "load_balancer", "one-phase wiring, three-phase setting") +expect(poll({pilot=8, alloc={12, 0, 0}}), 8, nil, "unused phases do not explain a lower pilot") boot({phases=1}) +order(15, "1p") expect(poll({pilot=12, alloc={12, 0, 0}}), 12, "load_balancer", "one phase") +expect(poll({pilot=12, alloc={12, 16, 16}}), 12, nil, "one phase, the charger's phase unknown") -- 4. At or near the order, not charging, or no positive pilot: no limit. boot() +order(15) expect(poll({pilot=15, alloc={15, 15, 15}}), nil, nil, "at the order") expect(poll({pilot=14.6, alloc={14, 14, 14}, order=15}), nil, nil, "within half an amp") expect(poll({mode=2, kw=0, pilot=0, alloc={0, 0, 0}, power_at="23:55:00"}), nil, nil, "waiting to start") expect(poll({mode=3, kw=0, pilot=0, alloc={0, 0, 0}, power_at="23:55:00"}), nil, nil, "zero pilot") +expect(poll({mode=4, kw=0, pilot=12, alloc={12, 12, 12}, power_at="23:55:00"}), nil, nil, "car finished") expect(poll({order=false, power_at="23:55:00", kw=0}), nil, nil, "no readback of the order") -- 5. Right after FTW raises the order, an older pilot has not answered it -- yet. It counts once it changes after the order, or once the order has --- stood 30 s without the pilot moving. +-- stood 30 s. boot() +order(10) expect(poll({order=10, pilot=10, alloc={10, 10, 10}}), nil, nil, "at the old order") +order(15) expect(poll({order=15, order_at="23:58:00", pilot=10, alloc={10, 10, 10}}), nil, nil, "not answered yet") later(29000) expect(poll({order=15, order_at="23:58:00", pilot=10, alloc={10, 10, 10}}), nil, nil, "29 s") later(1000) expect(poll({order=15, order_at="23:58:00", pilot=10, alloc={10, 10, 10}}), 10, "load_balancer", "30 s") +order(16) expect(poll({order=16, order_at="23:59:00", pilot=12, pilot_at="23:59:04", alloc={12, 12, 12}}), 12, "load_balancer", "answered at once") +expect(poll({order=16, order_at="23:59:00", pilot=12, pilot_at="23:59:00", alloc={12, 12, 12}}), + 12, "load_balancer", "recorded in the same second as the order") expect(poll({order=16, order_at="23:59:00", pilot=12, pilot_at="23:59:04", alloc={12, 12, 12}, alloc_at="23:58:30"}), 12, nil, "allocation older than the new order") --- 6. The stream: pushed allocation and pilot reach the next sample. +-- 6. While the charger still reads back an older order than FTW's last, the +-- pilot may be following the new one: no limit. Nor is the charger's own +-- static limit its load balancing. +boot() +order(15) +later(60000) +order(10) +expect(poll({order=15, pilot=10, pilot_at="23:58:00", alloc={10, 10, 10}}), nil, nil, "readback still at the old order") +boot(nil, false, '{"maxChargerCurrent":10}') +order(15) +expect(poll({pilot=10, alloc={10, 10, 10}}), 10, nil, "the charger's own 10 A limit") + +-- 7. The stream: pushed allocation and pilot reach the next sample. local function push(...) local frame = "" for _, m in ipairs({...}) do frame = frame .. (type(m) == "string" and m or host.json_encode(m)) .. RS end @@ -127,6 +163,7 @@ local function update(id, value, clock) return {type=1, target="ProductUpdate", arguments={{mid=SERIAL, dataType=3, id=id, timestamp=at(clock), value=value}}} end boot(nil, true) +order(15) host._http_responses["/observations?ids="] = host.json_encode(obs({pilot=15, alloc={15, 15, 15}, kw=10.37})) driver_poll() push("{}") diff --git a/drivers/tests/lua_harness/test_easee_cloud_observation_order.lua b/drivers/tests/lua_harness/test_easee_cloud_observation_order.lua index 2faa2a0..0985ed7 100644 --- a/drivers/tests/lua_harness/test_easee_cloud_observation_order.lua +++ b/drivers/tests/lua_harness/test_easee_cloud_observation_order.lua @@ -38,4 +38,87 @@ n = poll(10.242,"2026-10-17T03:49:08.1Z") assert(n==count,"old positive power resurrected a stopped charge") n,row = poll(6.2,"2026-10-17T03:49:10Z") assert(n==count+1 and row.w==6200,"fresh subsequent power did not recover") + +-- Additions in easee_cloud 1.4.1. Each reply below is five seconds apart. +local function later(ms) host._millis_counter = host._millis_counter + ms end +local function fresh() + host.reset() + host._millis_step = 0 + host._http_responses["/accounts/login"] = '{"accessToken":"test","expiresIn":3600}' + host._http_responses["/config"] = '{"maxChargerCurrent":16}' + host._http_responses["/sessions/ongoing"] = '{}' + dofile("drivers/lua/easee_cloud.lua") + driver_init({email="test@example.invalid",password="test",serial="TEST123"}) +end +local function emitted() return #(host._emitted.ev or {}) end + +-- An impossible date is unreadable, not just older. +fresh() +poll(4.2,"2026-02-30T03:49:08Z") +assert(emitted()==0,"an impossible date was read as a time") + +-- A time Core cannot read is not used for 30 s, then used as before, so an +-- odd stamp format cannot keep the driver silent. +fresh() +count = poll(10.242,"2026-10-17T03:49:08Z") +for i=1,6 do later(5000); n = poll(10.242,"2026-10-17T03:49:20") end +assert(n==count,"an unreadable time was used before 30 s") +later(5000); n = poll(10.242,"2026-10-17T03:49:20") +assert(n==count+1,"an unreadable time kept the driver silent past 30 s") +later(5000); n = poll(9.1,"2026-10-17T03:49:25") +assert(n==count+2,"replies stopped again while the time stays unreadable") +later(5000); n = poll(9.0,"2026-10-17T03:49:30Z") +assert(n==count+3,"a readable time after them was not used") + +-- A late "disconnected" reply is held to the same order. +fresh() +count = poll(10.242,"2026-10-17T03:49:08Z") +later(5000); n, row = poll(0,"2026-10-17T03:40:00Z",1) +assert(n==count,"a late disconnected reply was used") +-- A real disconnect starts the order again: after a reboot the charger's +-- next session may carry older times. +later(5000); n, row = poll(0,"2026-10-17T03:49:30Z",1) +assert(n==count+1 and row.connected==false,"a disconnect was not used") +later(5000); n = poll(4.1,"2026-10-17T03:30:00Z") +assert(n==count+2,"the order did not start again after a disconnect") + +-- The 30 s count is of replies that go back one after another: a good reply +-- in between, or a failed poll, starts it again. +fresh() +count = poll(10.242,"2026-10-17T03:49:08Z") +later(5000); n = poll(0,"2026-10-17T03:49:00Z") +assert(n==count) +later(10000); n = poll(10.242,"2026-10-17T03:49:08Z") +assert(n==count+1) +later(20000); n = poll(0,"2026-10-17T03:49:00Z") +assert(n==count+1,"an earlier rejection counted toward the 30 s after a good reply") +host._http_responses["/observations?ids="] = nil +for i=1,12 do later(5000); driver_poll() end +later(5000); n = poll(0,"2026-10-17T03:49:00Z") +assert(n==count+1,"a rejection before an outage counted toward the 30 s") + +-- A rejected reply does not cost one of the ten session checks an hour. +fresh() +local session = host.json_encode({Id=7,Start="2026-10-17T01:00:00.000Z",MeterValue=1}) +host._http_responses["/sessions/ongoing"] = host.json_encode({sessionId=7,sessionStart="2026-10-17T01:00:00Z"}) +local function with_session(w, stamp) + host._http_responses["/observations?ids="] = host.json_encode({ + {id=109,value=3,timestamp="2026-10-17T03:49:10Z"}, {id=120,value=w,timestamp=stamp}, + {id=121,value=12.5,timestamp="2026-10-17T03:49:10Z"}, {id=223,value=session}, + }) + driver_poll() +end +local function lookups() + local k=0 + for _, call in ipairs(host._calls) do + if call.func=="http_get" and tostring(call.args[1]):find("/sessions/ongoing",1,true) then k=k+1 end + end + return k +end +with_session(10.242,"2026-10-17T03:49:08Z") +for i=1,4 do + later(5000); with_session(0,"2026-10-17T03:49:00Z") + later(60000); with_session(10.242,"2026-10-17T03:49:08Z") +end +assert(lookups()==1,"rejected replies made the driver check the session again: "..lookups()) print("Easee observation ordering: passed") diff --git a/index.yaml b/index.yaml index b4a8afa..ad77942 100644 --- a/index.yaml +++ b/index.yaml @@ -200,8 +200,8 @@ drivers: setup: [vendor_portal] ders: [ev] control: true - size_bytes: 70388 - sha256: "60118da1fd48f26635e4dcee70101aab835c87d94dc311c1418a7aeb2ce2e4d2" + size_bytes: 71615 + sha256: "d20af68ffa1c85015152d53f40507d715b2034dcc4ca6d0984c73f284ff85d1b" - name: "esphome_dsmr" version: "1.0.7" tier: core diff --git a/manifests/easee_cloud.yaml b/manifests/easee_cloud.yaml index 5cb12c2..38ef966 100644 --- a/manifests/easee_cloud.yaml +++ b/manifests/easee_cloud.yaml @@ -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: 70388 +size_bytes: 71615 dkb_id: "easee_cloud" -sha256: "60118da1fd48f26635e4dcee70101aab835c87d94dc311c1418a7aeb2ce2e4d2" +sha256: "d20af68ffa1c85015152d53f40507d715b2034dcc4ca6d0984c73f284ff85d1b" signature: "" bytecode_sha256: "" From d869571b0c97d1f3ae6b341de1ebebb42a423846 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Sun, 25 Oct 2026 19:51:39 +0100 Subject: [PATCH 3/4] fix(easee_cloud): second review pass on the escape and one-phase cause - A failed poll no longer restarts the 30 s count; a gap of more than 30 s between held-back replies does. Occasional HTTP errors among unreadable replies kept the driver silent. - Skip dropping the session proof only on a poll whose reply was held back, not whenever the count is running. - A one-phase charger with no allocation on any phase does not name the load balancer. - Tests: both cases, and no limit before FTW's first order. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Fredrik Ahlgren --- CHANGELOG.md | 5 +++-- drivers/lua/easee_cloud.lua | 19 ++++++++++++------- .../test_easee_cloud_allocated_limit.lua | 3 +++ .../test_easee_cloud_observation_order.lua | 9 +++++++++ index.yaml | 4 ++-- manifests/easee_cloud.yaml | 4 ++-- 6 files changed, 31 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e6c8e7..814dac6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,8 +32,9 @@ disconnect starts the order again. Such replies one after another for 30 s mean the time itself is wrong: an older time then starts the order again, and unreadable times are used as they come until a readable one arrives, so a stamp from the future or an odd format cannot keep the -driver silent. A failed poll ends such a run, and a held-back reply does -not use up one of the ten session checks an hour. The check and its tests +driver silent. A gap of more than 30 s between such replies, such as an +outage, starts the count again, and a held-back reply does not use up one +of the ten session checks an hour. The check and its tests come from Segran in srcfl/device-drivers#183. The stream's own order is unchanged. diff --git a/drivers/lua/easee_cloud.lua b/drivers/lua/easee_cloud.lua index e561e27..4368d57 100644 --- a/drivers/lua/easee_cloud.lua +++ b/drivers/lua/easee_cloud.lua @@ -427,10 +427,11 @@ end -- format this parser does not know, so the driver uses them rather than go -- silent: an older time starts the order again, and unreadable ones are -- used as they come until a readable time arrives. A disconnect starts the --- order again too. A failed poll ends the run of rejected replies. +-- order again too. A run of rejected replies ends after a gap of more than +-- UNORDERED_RESET_MS between them, such as an outage. local UNORDERED_RESET_MS = 30000 local accepted_power_key, accepted_power_value = nil, nil -local unordered_since_ms, unreadable_logged = nil, false +local unordered_since_ms, unordered_last_ms, unreadable_logged = nil, nil, false local function power_order_problem(obs, stamps, now) local value, key = obs[OBS_TOTAL_POWER], source_time_key(stamps[OBS_TOTAL_POWER]) local disconnected = obs[OBS_OP_MODE] == 1 @@ -443,7 +444,10 @@ local function power_order_problem(obs, stamps, now) problem = "conflicting power at one source timestamp" end if problem then - unordered_since_ms = unordered_since_ms or now + if not unordered_since_ms or now - unordered_last_ms > UNORDERED_RESET_MS then + unordered_since_ms = now + end + unordered_last_ms = now if now - unordered_since_ms < UNORDERED_RESET_MS then return problem end if not key then if not unreadable_logged then @@ -1050,17 +1054,17 @@ function driver_poll() local values, err, stamps if rest_due then values, err, stamps = get_observations(charger_serial) end local now = host.millis() - local rest_read = false + local rest_read, held_back = false, false if rest_due then local problem = err or state_problem(values) local unordered = not problem and power_order_problem(values, stamps, now) rest_next_ms = now + ((problem or unordered) and 10000 or REST_CONFIRM_MS) if problem then - unordered_since_ms = nil host.log("warn", "Easee: observations poll failed: " .. (err and redact_http_err(err) or problem)) -- The cloud answered that it does not hear the charger. if not err then heard_ms = nil end elseif unordered then + held_back = true host.log("warn", "Easee: observations reply not used: " .. unordered) else -- Without the stream the reply is the whole state, as before. @@ -1091,7 +1095,7 @@ function driver_poll() if not vouched or blocked then -- An unreadable charger may have changed cars during the gap. A -- reply held back for its order is not a gap. - if not unordered_since_ms then invalidate_session_proof() end + if not held_back then invalidate_session_proof() end return stream_state == "up" and STREAM_POLL_MS or 10000 end local obs, timestamps = latest, latest_at @@ -1223,7 +1227,8 @@ function driver_poll() -- phase, which the driver does not know, so the highest. local allocated_a = (phases == 1) and high or (n == 3 and low or nil) local own_limit = device_limit_a ~= nil and pilot_a >= device_limit_a - 0.5 - if allocated_a and allocated_a < dyn_current - 0.5 and allocated_a <= pilot_a + 0.5 and not own_limit then + if allocated_a and allocated_a > 0 and allocated_a < dyn_current - 0.5 and allocated_a <= pilot_a + 0.5 and + not own_limit then current_limited_by = "load_balancer" end end diff --git a/drivers/tests/lua_harness/test_easee_cloud_allocated_limit.lua b/drivers/tests/lua_harness/test_easee_cloud_allocated_limit.lua index 2612bfd..7d72175 100644 --- a/drivers/tests/lua_harness/test_easee_cloud_allocated_limit.lua +++ b/drivers/tests/lua_harness/test_easee_cloud_allocated_limit.lua @@ -76,7 +76,9 @@ end -- 1. The field trace: allocation and pilot below the 15 A order and newer -- than it. The allocation explains the pilot, so the load balancer is named. +-- Before FTW's first order after a restart there is no order to be below. boot() +expect(poll(), nil, nil, "no order from FTW yet") order(15) expect(poll(), 12, "load_balancer", "start at 12 A") expect(poll({pilot=15, alloc={15, 15, 15}, pilot_at="23:55:14", kw=10.37, power_at="23:55:30"}), @@ -110,6 +112,7 @@ boot({phases=1}) order(15, "1p") expect(poll({pilot=12, alloc={12, 0, 0}}), 12, "load_balancer", "one phase") expect(poll({pilot=12, alloc={12, 16, 16}}), 12, nil, "one phase, the charger's phase unknown") +expect(poll({pilot=12, alloc={0, 0, 0}}), 12, nil, "one phase, no allocation") -- 4. At or near the order, not charging, or no positive pilot: no limit. boot() diff --git a/drivers/tests/lua_harness/test_easee_cloud_observation_order.lua b/drivers/tests/lua_harness/test_easee_cloud_observation_order.lua index 0985ed7..943c7b0 100644 --- a/drivers/tests/lua_harness/test_easee_cloud_observation_order.lua +++ b/drivers/tests/lua_harness/test_easee_cloud_observation_order.lua @@ -69,6 +69,15 @@ later(5000); n = poll(9.1,"2026-10-17T03:49:25") assert(n==count+2,"replies stopped again while the time stays unreadable") later(5000); n = poll(9.0,"2026-10-17T03:49:30Z") assert(n==count+3,"a readable time after them was not used") +-- Now and then a failed poll among them does not hold the driver silent. +fresh() +count = poll(10.242,"2026-10-17T03:49:08Z") +for i=1,12 do + later(5000) + if i % 4 == 0 then host._http_responses["/observations?ids="] = nil; driver_poll() + else n = poll(10.242,"2026-10-17T03:49:20") end +end +assert(n>count,"failed polls among unreadable replies kept the driver silent") -- A late "disconnected" reply is held to the same order. fresh() diff --git a/index.yaml b/index.yaml index ad77942..7908689 100644 --- a/index.yaml +++ b/index.yaml @@ -200,8 +200,8 @@ drivers: setup: [vendor_portal] ders: [ev] control: true - size_bytes: 71615 - sha256: "d20af68ffa1c85015152d53f40507d715b2034dcc4ca6d0984c73f284ff85d1b" + size_bytes: 71847 + sha256: "6349a2971f0f8c80cd58ab26b61b2f3a9c6ffda450cef0252daa8259c7f022c2" - name: "esphome_dsmr" version: "1.0.7" tier: core diff --git a/manifests/easee_cloud.yaml b/manifests/easee_cloud.yaml index 38ef966..5af4493 100644 --- a/manifests/easee_cloud.yaml +++ b/manifests/easee_cloud.yaml @@ -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: 71615 +size_bytes: 71847 dkb_id: "easee_cloud" -sha256: "d20af68ffa1c85015152d53f40507d715b2034dcc4ca6d0984c73f284ff85d1b" +sha256: "6349a2971f0f8c80cd58ab26b61b2f3a9c6ffda450cef0252daa8259c7f022c2" signature: "" bytecode_sha256: "" From 34347fb92b08474b52e6240b28e78498110ba580 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Sun, 25 Oct 2026 19:53:48 +0100 Subject: [PATCH 4/4] fix(easee_cloud): keep the last limit while the car catches up When Easee lets the car take the order again, the car needs a few seconds to draw it and the cloud to report that. Core then saw no limit and a car still below the order, and said "Not following" until the new draw arrived. Keep the last limit for up to 20 s while the car charges below the order. Test fails without it. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Fredrik Ahlgren --- CHANGELOG.md | 5 ++++- drivers/lua/easee_cloud.lua | 12 ++++++++++++ .../test_easee_cloud_allocated_limit.lua | 14 ++++++++++++++ index.yaml | 4 ++-- manifests/easee_cloud.yaml | 4 ++-- 5 files changed, 34 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 814dac6..bac5784 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,7 +18,10 @@ order: Easee recorded it at or after the order, or the order has stood allocation is below the order too and no higher than the signalled current, and the charger's own static limit does not explain it. A three-phase charger uses the lowest allocation of the phases in use (a -phase with none is not in use); a one-phase charger the highest. +phase with none is not in use); a one-phase charger the highest. When +Easee lets the car take the order again, the last limit stays for up to +20 s while the car still draws below the order, since the cloud reports +the car's new draw a few seconds later. ReasonForNoCurrent is not used as a cause: in that trace reason 77 was older than the cuts. Labels for reasons 6, 8–11, 25–30 and 77 now match Easee's published table (from srcfl/device-drivers#172). diff --git a/drivers/lua/easee_cloud.lua b/drivers/lua/easee_cloud.lua index 4368d57..1f5ced9 100644 --- a/drivers/lua/easee_cloud.lua +++ b/drivers/lua/easee_cloud.lua @@ -861,6 +861,12 @@ local REASON_LABELS = { -- saw it and the value has not moved. local ORDER_SETTLE_MS = 30000 local order_seen_a, order_seen_ms = nil, 0 +-- When Easee lets the car take the order again, the car needs a few seconds +-- to draw it and the cloud to report that. Until then the reading still +-- shows the old limit's draw, so the old limit is kept for up to +-- LIMIT_RELEASE_HOLD_MS while the car charges below the order. +local LIMIT_RELEASE_HOLD_MS = 20000 +local last_limit_a, last_limited_by, last_limit_ms = nil, nil, nil local email, password, configured_max_a local device_limit_a, device_limit_read_ms @@ -1232,6 +1238,12 @@ function driver_poll() current_limited_by = "load_balancer" end end + if current_limit_a then + last_limit_a, last_limited_by, last_limit_ms = current_limit_a, current_limited_by, now + elseif last_limit_ms and now - last_limit_ms < LIMIT_RELEASE_HOLD_MS and charging and + type(dyn_current) == "number" and actual_amps_per_phase and actual_amps_per_phase < dyn_current - 1 then + current_limit_a, current_limited_by = last_limit_a, last_limited_by + end local limit_age_s = device_limit_read_ms and (host.millis() - device_limit_read_ms) / 1000 host.emit("ev", { diff --git a/drivers/tests/lua_harness/test_easee_cloud_allocated_limit.lua b/drivers/tests/lua_harness/test_easee_cloud_allocated_limit.lua index 7d72175..09f7568 100644 --- a/drivers/tests/lua_harness/test_easee_cloud_allocated_limit.lua +++ b/drivers/tests/lua_harness/test_easee_cloud_allocated_limit.lua @@ -88,6 +88,20 @@ expect(poll({pilot=14, alloc={14, 14, 14}, pilot_at="00:00:21", kw=9.6, power_at expect(poll({pilot=13, alloc={13, 13, 13}, pilot_at="00:00:32", kw=8.85, power_at="00:00:40"}), 13, "load_balancer", "fall to 13 A") +-- When Easee lets the car take the order again, the cloud reports the +-- car's old draw for a few seconds; the old limit still applies to it. +later(5000) +expect(poll({pilot=15, alloc={15, 15, 15}, pilot_at="00:01:00", kw=8.85, power_at="00:00:40"}), + 13, "load_balancer", "released, the car not up yet") +later(15000) +expect(poll({pilot=15, alloc={15, 15, 15}, pilot_at="00:01:00", kw=8.85, power_at="00:00:40"}), + nil, nil, "released 20 s ago") +boot() +order(15) +expect(poll(), 12, "load_balancer", "limited again") +expect(poll({pilot=15, alloc={15, 15, 15}, pilot_at="00:01:00", kw=10.37, power_at="00:01:05"}), + nil, nil, "released, the car at the order") + -- 2. Below the order, but nothing says why: the current is reported, the -- cause is not. An allocation above the pilot does not explain it, and an -- old ReasonForNoCurrent is not proof either. diff --git a/index.yaml b/index.yaml index 7908689..88407a6 100644 --- a/index.yaml +++ b/index.yaml @@ -200,8 +200,8 @@ drivers: setup: [vendor_portal] ders: [ev] control: true - size_bytes: 71847 - sha256: "6349a2971f0f8c80cd58ab26b61b2f3a9c6ffda450cef0252daa8259c7f022c2" + size_bytes: 72644 + sha256: "e4b82cf453816e251dbd28b02d4944965e30df3f2913bf4d945b4d11850db682" - name: "esphome_dsmr" version: "1.0.7" tier: core diff --git a/manifests/easee_cloud.yaml b/manifests/easee_cloud.yaml index 5af4493..5a0f11c 100644 --- a/manifests/easee_cloud.yaml +++ b/manifests/easee_cloud.yaml @@ -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: 71847 +size_bytes: 72644 dkb_id: "easee_cloud" -sha256: "6349a2971f0f8c80cd58ab26b61b2f3a9c6ffda450cef0252daa8259c7f022c2" +sha256: "e4b82cf453816e251dbd28b02d4944965e30df3f2913bf4d945b4d11850db682" signature: "" bytecode_sha256: ""