diff --git a/CHANGELOG.md b/CHANGELOG.md index 52a8522..bac5784 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,48 @@ ## [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 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. 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). + +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 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. + +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..0efa6ce 100644 --- a/docs/WRITING-A-DRIVER.md +++ b/docs/WRITING-A-DRIVER.md @@ -425,6 +425,17 @@ 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, 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 + 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..1f5ced9 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,57 @@ 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). 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 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, 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 + local problem = nil + 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 value ~= accepted_power_value then + problem = "conflicting power at one source timestamp" + end + if problem then + 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 + 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 + 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 + +-- 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 +695,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 +815,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 +837,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 +851,23 @@ 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 +-- 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 local settings_poll_ms = 0 @@ -961,14 +1060,18 @@ 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) - 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 + held_back = true + 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 @@ -996,8 +1099,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 held_back then invalidate_session_proof() end return stream_state == "up" and STREAM_POLL_MS or 10000 end local obs, timestamps = latest, latest_at @@ -1093,6 +1197,54 @@ 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 + -- 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 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 + 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 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) + local own_limit = device_limit_a ~= nil and pilot_a >= device_limit_a - 0.5 + 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 + 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", { w = power_w, @@ -1123,6 +1275,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..09f7568 --- /dev/null +++ b/drivers/tests/lua_harness/test_easee_cloud_allocated_limit.lua @@ -0,0 +1,197 @@ +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, 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"] = 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} + for k, v in pairs(extra or {}) do cfg[k] = v end + 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" +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. +-- 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"}), + 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") + +-- 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. +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") +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. +-- 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") +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() +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. +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. 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 + 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) +order(15) +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..943c7b0 --- /dev/null +++ b/drivers/tests/lua_harness/test_easee_cloud_observation_order.lua @@ -0,0 +1,133 @@ +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") + +-- 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") +-- 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() +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/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..88407a6 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: 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 3b55ee8..5a0f11c 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: 72644 dkb_id: "easee_cloud" -sha256: "94653989702cb805c56db296be0651886b4b685a0d407bd69089450b86571abe" +sha256: "e4b82cf453816e251dbd28b02d4944965e30df3f2913bf4d945b4d11850db682" 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": {