diff --git a/CHANGELOG.md b/CHANGELOG.md index 2df9a69..d77d298 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,13 +2,50 @@ ## [Unreleased] -### easee_cloud 1.3.7 +### easee_cloud 1.3.9 + +- Reject power observations that move backwards in source time, and conflicting values at the same physical time. Compare RFC3339 instants with UTC offsets and fractional precision normalized. An old cloud zero can no longer replace a newer sample, nor can an old positive sample resurrect an observed stop. +- Connected chargers with missing or malformed power timestamps emit no fresh measurement. Unchanged valid values remain confirmable, and newer zero observations remain valid. A confirmed disconnect resets the session ordering. +- Reproduced with synthetic cloud responses; not yet verified on hardware. This does not prove that delayed packets caused every field balance mismatch. No command policy or charging limit is changed. - Keep a load-balancing ReasonForNoCurrent while the car charges, when the per-phase current is at least 2 A below the offer. Easee records the reason when it changes, so a limit that began before the last power change was dropped and FTW said the charger had given no reason. The offer is observation 48, or FTW's last write when that is missing. Other old reasons are still dropped while the car charges. - Report `current_limited_by = "load_balancer"` for codes 1–5, 10 and 25–30 while a car is connected. Core reads this name, not Easee's codes. The charger's own limits (77, 78) and the car are not the load balancer. - Match the labels for codes 6, 8–11, 25–30 and 77 to Easee's published table. Code 28 read "fuse limit reached"; Easee calls it "Current limited by Equalizer". The enumeration page joins `upstream_docs`. - Not yet seen on hardware. Easee's table defines the "Current limited by …" codes, but no raw observation from a load-balanced charger has been captured. +### easee_cloud 1.3.8 + +Two fixes to the 7 A start from 1.3.7, from its review. A reloaded driver +no longer offers 7 A before its first poll: the car may already charge at +6 A, so the driver now waits until a poll has seen the charger not charging. +A pause or the stand-down that clears a boost now also drops the boost's 7 A +from the current the driver re-writes after a phase change: a pause keeps +the 6 A order and the stand-down 0 A. Before, the next poll could write 7 A +again without the cap or the 90 s window. A pause keeps 6 A rather than +0 A because Core's contactor cycle sends a resume after a pause with no new +order between. Tested in the Lua harness; not yet on hardware. + +### easee_cloud 1.3.7 + +Start a 6 A order at 7 A. On an Easee Home with an Audi e-tron (three +phases), a resume at 6 A gave a positive pilot only 5 min 2 s to 5 min 47 s +later in four runs on 22 October 2026, also with a single resume; 7 A and +then 6 A started at once. evcc reports the same delay at 6 A. The driver now +offers 7 A per phase when a stopped charger gets a 6 A order, and only when +7 A fits: Core's `cap_amps_per_phase` for this charger, the order's +`max_amps_per_phase` and the charger's static limit, as read from its +config, must all allow 7 A. `max_amps_per_phase` is the fuse rating, not the +room left, so a 6 A order on a phase with room for 6.4 A would otherwise draw +7 A and Core would stop the car. A Core that does not send +`cap_amps_per_phase` (srcfl/ftw#1809) gets no boost. Every order during a +boost checks this again, and one that no longer fits writes 6 A at once. The +driver writes 6 A as soon as the charger reports charging, or after 90 s, +also when observations fail. One boost per start; a running session lowered +to 6 A is not raised, orders above 6 A are unchanged, and no extra resume is +sent. `start_boost: false` turns it off. Tested in the Lua harness; not yet +on hardware with this driver. + + ### home_assistant_vehicle 0.1.0 A new read-only driver for a car whose level Home Assistant already reads. A diff --git a/SUPPORT_STATUS.md b/SUPPORT_STATUS.md index f05acf0..e6578da 100644 --- a/SUPPORT_STATUS.md +++ b/SUPPORT_STATUS.md @@ -46,8 +46,8 @@ Catalog source is not proof that a target can install or run a driver. | deye | 2.1.1 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | | easee | 1.0.4 | ftw-core | not_assessed | — | not_recorded | not_assessed | | easee | 1.0.4 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | -| easee_cloud | 1.3.7 | ftw-core | not_assessed | — | not_recorded | not_assessed | -| easee_cloud | 1.3.7 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | +| easee_cloud | 1.3.9 | ftw-core | not_assessed | — | not_recorded | not_assessed | +| easee_cloud | 1.3.9 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | | 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 f0a2249..9468047 100644 --- a/devices.yaml +++ b/devices.yaml @@ -338,7 +338,7 @@ manufacturers: protocols: - protocol: http driver: "easee_cloud" - version: "1.3.7" + version: "1.3.9" ders: [ev] control: true firmware_versions: "" diff --git a/drivers/lua/easee_cloud.lua b/drivers/lua/easee_cloud.lua index aec0a84..a3a1a74 100644 --- a/drivers/lua/easee_cloud.lua +++ b/drivers/lua/easee_cloud.lua @@ -18,6 +18,7 @@ -- email: "user@example.com" -- password: "secret" -- serial: "EHHZBKPF" # optional — auto-detected if omitted +-- start_boost: false # optional — disable the 7 A start for 6 A orders DRIVER = { host_api_min = 1, @@ -25,7 +26,7 @@ DRIVER = { id = "easee_cloud", name = "Easee Cloud", manufacturer = "Easee", - version = "1.3.7", + version = "1.3.9", protocols = { "http" }, capabilities = { "ev" }, description = "Easee Home/Charge via Cloud REST API. No local protocol needed.", @@ -87,6 +88,23 @@ local command_stalled_since_ms = 0 local EASEE_MIN_A = 6 local EASEE_MAX_A = 32 +-- 6 A start boost. Field data (22 Oct 2026, Easee Home + Audi e-tron, 3Φ, +-- four runs): after resume_charging with dynamicChargerCurrent=6 the +-- charger reported a positive pilot only 5 min 2 s – 5 min 47 s later, even +-- with a single resume. Starting at 7 A and lowering to 6 A once the car +-- drew current started at once. evcc reports the same ~5 min firmware +-- delay and clamps the dynamic current to 7 A before its start command. +-- Here the boost is one amp per phase, held only until the charger reports +-- charging (op_mode 3) or START_BOOST_MAX_MS passes, then 6 A. It is only +-- used when 7 A fits (start_boost_fits), after a poll has seen the charger +-- not charging, and at most once per start. `start_boost: false` disables it. +local START_BOOST_A = EASEE_MIN_A + 1 +local START_BOOST_MAX_MS = 90000 +local start_boost_enabled = true +local boost_until_ms = nil -- non-nil while 7 A is offered for a 6 A order +local boost_spent = false -- one boost per start; reset by a 0 A order or a stop +local last_op_mode = nil -- op_mode from the latest successful poll; nil before the first + -- pick_phases is the driver-level phase decision: given the requested -- charging power, the per-phase fuse ceiling, the operator's mode -- preference, and the time since the last flip, return how many @@ -283,6 +301,45 @@ local OBS_SESSION_START = 223 local OBS_IDS = "48,96,103,109,120,121,124,183,194,223" local last_power_observed_at = nil +local accepted_power_key = nil +local accepted_power_value = nil + +-- Compare RFC3339 source instants independently of fractional precision and +-- UTC offset. Wall-clock receipt time must not make an old packet current. +local function power_time_key(stamp) + if type(stamp) ~= "string" then return nil end + 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 local validated_session_id = nil local observed_session_id = nil local departed_session_id = nil @@ -327,6 +384,26 @@ local function get_observations(serial) invalidate_session_proof() return nil, "missing session measurement" end + if mode >= 2 then + local key = power_time_key(timestamps[OBS_TOTAL_POWER]) + if not key then + invalidate_session_proof() + return nil, "missing or invalid power source timestamp" + end + if accepted_power_key and key < accepted_power_key then + invalidate_session_proof() + return nil, "out-of-order power observation" + end + if key == accepted_power_key and obs[OBS_TOTAL_POWER] ~= accepted_power_value then + invalidate_session_proof() + return nil, "conflicting power at one source timestamp" + end + accepted_power_key, accepted_power_value = key, obs[OBS_TOTAL_POWER] + elseif mode == 1 then + -- A confirmed disconnect ends the session. A subsequent connection + -- must establish its own source ordering (including a charger reboot). + accepted_power_key, accepted_power_value = nil, nil + end return obs, nil, timestamps end @@ -503,6 +580,45 @@ local function write_setting(serial, body_table) return err end +-- start_boost_fits: 7 A per phase fits when Core's cap for this charger +-- (cap_amps_per_phase: its room below the fuse and the import limit, after +-- other chargers' share), the order's max_amps_per_phase and the charger's +-- static limit, read from /config, all allow it. max_amps_per_phase alone is +-- the fuse rating, not the room left: a 6 A order on a phase with room for +-- 6.4 A would draw 7 A, Core would see an overage and stop the car, and the +-- next start would boost again. A Core that sends no cap gets no boost. +-- Checked on every order while a boost runs. +local function start_boost_fits(cmd, max_a) + local cap = cmd and type(cmd.cap_amps_per_phase) == "number" and cmd.cap_amps_per_phase or nil + return cap ~= nil and math.floor(cap) >= START_BOOST_A and + max_a ~= nil and math.floor(max_a) >= START_BOOST_A and + device_limit_a ~= nil and device_limit_a >= START_BOOST_A +end + +-- end_start_boost writes the ordered 6 A. A failed write leaves the boost +-- running, so the next poll or order tries again. +local function end_start_boost(why) + local werr = write_setting(charger_serial, {dynamicChargerCurrent = EASEE_MIN_A}) + if werr ~= nil then + host.log("warn", "Easee: start boost step-down failed (will retry): " .. redact_http_err(werr)) + return false + end + host.log("info", "Easee: start boost ended (" .. why .. "), dynamicChargerCurrent=6 A") + boost_until_ms = nil + last_amps_set = EASEE_MIN_A + return true +end + +-- clear_start_boost drops the boost without a write and re-arms it. A running +-- boost left 7 A in last_amps_set; it becomes hold_a, what the charger should +-- hold, so a pending phaseMode re-assert cannot write 7 A outside the window +-- and the cap check. +local function clear_start_boost(hold_a) + if boost_until_ms ~= nil then last_amps_set = hold_a end + boost_until_ms = nil + boost_spent = false +end + function driver_init(config) host.set_make("Easee") @@ -539,6 +655,10 @@ function driver_init(config) end end + if config and (config.start_boost == false or config.start_boost == "false") then + start_boost_enabled = false + end + if not email or not password then host.log("error", "Easee: email and password required in driver config") return @@ -606,6 +726,11 @@ function driver_poll() return 10000 end + -- The boost window ends on time even when observations fail. + if boost_until_ms ~= nil and host.millis() >= boost_until_ms then + end_start_boost("timeout") + end + local obs, err, timestamps = get_observations(charger_serial) if err or not obs then host.log("warn", "Easee: observations poll failed: " .. redact_http_err(err)) @@ -776,6 +901,17 @@ function driver_poll() end end + -- 6 A start boost: step down to the ordered 6 A as soon as the charger + -- reports charging. The timeout is checked above, before observations. + if boost_until_ms ~= nil and charging then + end_start_boost("charging") + end + -- A session that stops charging may need a new boost on its next start. + if last_op_mode == 3 and not charging and boost_until_ms == nil then + boost_spent = false + end + last_op_mode = op_mode + if obs[OBS_VOLTAGE] then host.emit_metric("ev_voltage_v", obs[OBS_VOLTAGE]) end @@ -819,7 +955,12 @@ function driver_command(action, power_w, cmd) return ok elseif action == "ev_pause" then local ok = post_command("/commands/pause_charging") - if ok then paused_state = true end + if ok then + paused_state = true + -- Keep the 6 A order, not 0 A: Core's contactor cycle sends + -- ev_resume after ev_pause with no new order in between. + clear_start_boost(EASEE_MIN_A) + end return ok elseif action == "ev_resume" then local ok = post_command("/commands/resume_charging") @@ -881,9 +1022,46 @@ function driver_command(action, power_w, cmd) end local amps = per_phase_amps(power_w, voltage, phases, max_a) - local err = write_setting(charger_serial, {dynamicChargerCurrent = amps}) + local write_a = amps + local boosting = false + local boost_end = nil + if amps == EASEE_MIN_A then + if boost_until_ms ~= nil then + -- Keep 7 A within the window while it still fits and the + -- charger has not started; do not restart the window. + -- Otherwise this write of 6 A ends the boost. + if now_ms >= boost_until_ms then + boost_end = "timeout" + elseif last_op_mode == 3 then + boost_end = "charging" + elseif not start_boost_fits(cmd, max_a) then + boost_end = "no room for 7 A" + else + write_a, boosting = START_BOOST_A, true + end + elseif start_boost_enabled and not boost_spent and start_boost_fits(cmd, max_a) and + last_op_mode ~= nil and last_op_mode ~= 3 then + -- An unknown op_mode may be charging: after a driver reload + -- the car can already charge at 6 A before the first poll. + write_a, boosting = START_BOOST_A, true + end + end + local err = write_setting(charger_serial, {dynamicChargerCurrent = write_a}) if err == nil then - last_amps_set = amps + if boosting then + if boost_until_ms == nil then + boost_until_ms = now_ms + START_BOOST_MAX_MS + boost_spent = true + host.log("info", "Easee: start boost 7 A for a 6 A start (Easee delays ~5 min at 6 A)") + end + else + if boost_end ~= nil then + host.log("info", "Easee: start boost ended (" .. boost_end .. "), dynamicChargerCurrent=6 A") + end + boost_until_ms = nil + if amps == 0 then boost_spent = false end + end + last_amps_set = write_a -- After a phaseMode change, schedule one re-write of -- dynamicChargerCurrent on the next driver_poll. Easee's -- cloud resets it to maxChargerCurrent during the phase @@ -932,6 +1110,7 @@ function driver_default_mode() if post_command("/commands/pause_charging") then paused_state = true end + clear_start_boost(0) local err = write_setting(charger_serial, {dynamicChargerCurrent = 0}) if err == nil then last_amps_set = 0 diff --git a/drivers/tests/lua_harness/test_easee_cloud_load_balancer.lua b/drivers/tests/lua_harness/test_easee_cloud_load_balancer.lua index fba1627..c90a018 100644 --- a/drivers/tests/lua_harness/test_easee_cloud_load_balancer.lua +++ b/drivers/tests/lua_harness/test_easee_cloud_load_balancer.lua @@ -17,10 +17,12 @@ local function boot() end -- reason_time before power_time: the reason predates the last power change. +local power_tick = 0 local function poll(mode, power_kw, reason, reason_time, offer_a) + power_tick = power_tick + 1 local obs = { {id=109, value=mode, timestamp="2026-10-08T05:00:00Z"}, - {id=120, value=power_kw, timestamp="2026-10-08T05:10:00Z"}, + {id=120, value=power_kw, timestamp=string.format("2026-10-08T05:10:00.%09dZ", power_tick)}, {id=121, value=12.5, timestamp="2026-10-08T05:10:00Z"}, {id=194, value=230, timestamp="2026-10-08T05:00:00Z"}, {id=96, value=reason, timestamp=reason_time}, 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 new file mode 100644 index 0000000..fa84586 --- /dev/null +++ b/drivers/tests/lua_harness/test_easee_cloud_start_boost.lua @@ -0,0 +1,268 @@ +-- A 6 A start is offered 7 A until the charger charges, then 6 A, and only +-- while Core's cap_amps_per_phase says 7 A fits. +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}' + host._http_responses["/settings"] = '{}' + host._http_responses["/commands/"] = '{}' + host._http_responses["/sessions/ongoing"] = '{}' + dofile(driver) + local c = {email="test@example.invalid", password="test", serial="TEST123"} + for k, v in pairs(cfg or {}) do c[k] = v end + driver_init(c) +end + +local function amps_writes() + local out = {} + for _, call in ipairs(host._calls) do + if call.func == "http_post" and call.args[1]:find("/settings", 1, true) then + local body = host.json_decode(call.args[2]) + if body.dynamicChargerCurrent ~= nil then table.insert(out, body.dynamicChargerCurrent) end + end + end + return out +end +local function last_amps() local w = amps_writes(); return w[#w] end +local function count_cmd(path) + local n = 0 + for _, call in ipairs(host._calls) do + if call.func == "http_post" and call.args[1]:find(path, 1, true) then n = n + 1 end + end + return n +end + +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,timestamp=stamp}, {id=121,value=1}, {id=124,value=100}, + }) + driver_poll() +end +local function poll_fails() + host._http_responses["/observations?ids="] = "not json" + driver_poll() +end +local function later(ms) host._millis_counter = host._millis_counter + ms end + +-- Core's cap for this charger is exactly 7 A: floor(7) fits. +local SIX = {phase_mode="3p", voltage=230, max_amps_per_phase=25, cap_amps_per_phase=7} +local function six(cap, max) + return {phase_mode="3p", voltage=230, max_amps_per_phase=max or 25, cap_amps_per_phase=cap} +end +local function order(w, cmd) return driver_command("ev_set_current", w, cmd or SIX) end + +-- 1. Bump on a 6 A start, hold through repeated orders, step down once charging. +boot() +poll(2) +assert(order(4140)) +assert(last_amps() == 7, "6 A start was not boosted to 7 A") +assert(order(4140)); assert(last_amps() == 7, "boost dropped before charging") +poll(2) +assert(last_amps() == 7, "boost dropped while still awaiting start") +poll(3, 4.8) +assert(last_amps() == 6, "no step-down to 6 A after charging was seen") +assert(order(4140)); assert(last_amps() == 6, "boost repeated during a running session") +assert(count_cmd("/commands/resume_charging") == 0, "boost sent a resume") + +-- 2. No boost when the fuse ceiling leaves no amp above 6, whatever the cap. +boot() +poll(2) +assert(order(4140, six(7, 6.9))) +assert(last_amps() == 6, "boost ignored a 6 A fuse ceiling") +-- ...nor when Core gives no ceiling at all. +boot() +poll(2) +assert(order(4140, {phase_mode="3p", voltage=230})) +assert(last_amps() == 6, "boost without a known fuse ceiling") +-- ...nor when the charger's own static limit is 6 A. +boot(nil, '{"maxChargerCurrent":6}') +poll(2) +assert(order(4140)) +assert(last_amps() == 6, "boost above the charger's static limit") + +-- ...nor when the charger's static limit could not be read. +boot(nil, '{}') +poll(2) +assert(order(4140)) +assert(last_amps() == 6, "boost with an unknown static limit") + +-- 3. No boost when more than 6 A is ordered. +boot() +poll(2) +assert(order(5520)); assert(last_amps() == 8, "8 A order changed") +assert(order(10350)); assert(last_amps() == 15, "15 A order changed") + +-- 4. Timeout: no charging within the window falls back to 6 A, once. +boot() +poll(2) +assert(order(4140)); assert(last_amps() == 7) +host._millis_counter = host._millis_counter + 91000 +poll(2) +assert(last_amps() == 6, "boost outlived its window") +assert(order(4140)); assert(last_amps() == 6, "boost restarted after timeout") + +-- 5. A higher order during the boost cancels it; a pause re-arms it. +boot() +poll(2) +assert(order(4140)); assert(last_amps() == 7) +assert(order(6900)); assert(last_amps() == 10, "higher order lost during boost") +host._millis_counter = host._millis_counter + 91000 +poll(2) +assert(last_amps() == 10, "cancelled boost still stepped down") +assert(driver_command("ev_pause", 0, {})) +assert(order(4140)); assert(last_amps() == 7, "pause did not re-arm the boost") + +-- 6. A charging session lowered to 6 A is not boosted. +boot() +poll(3, 6.9) +assert(order(4140)); assert(last_amps() == 6, "running session was boosted") + +-- 7. Disabled by config. +boot({start_boost=false}) +poll(2) +assert(order(4140)); assert(last_amps() == 6, "start_boost=false ignored") + +-- 8. 1Φ: the boost is per phase too (6 A → 7 A on one phase). +boot() +poll(2) +assert(order(1380, {phase_mode="1p", voltage=230, max_amps_per_phase=16, cap_amps_per_phase=7.5})) +assert(last_amps() == 7, "1p 6 A start not boosted") + +-- 9. Default mode clears the boost. +boot() +poll(2) +assert(order(4140)); assert(last_amps() == 7) +driver_default_mode() +assert(last_amps() == 0) +host._millis_counter = host._millis_counter + 91000 +poll(2) +assert(last_amps() == 0, "default mode left a pending boost step-down") + +-- 10. Core's cap decides. An older Core sends none: no boost. +boot() +poll(2) +assert(order(4140, six(nil))) +assert(last_amps() == 6, "boost without Core's cap") +-- Room for 6.4 A: 7 A would cross it. +boot() +poll(2) +assert(order(4140, six(6.4))) +assert(last_amps() == 6, "boost past Core's 6.4 A cap") +-- 7 A or more fits. +for _, cap in ipairs({7.9, 15}) do + boot() + poll(2) + assert(order(4140, six(cap))) + assert(last_amps() == 7, "no boost with a " .. cap .. " A cap") +end + +-- 11. The cap falls to 6 A during a boost: 6 A at once, and the boost ends. +boot() +poll(2) +assert(order(4140, six(8))); assert(last_amps() == 7) +assert(order(4140, six(6))) +assert(last_amps() == 6, "boost kept 7 A after Core's cap fell to 6 A") +assert(order(4140, six(8))) +assert(last_amps() == 6, "an ended boost started again in the same start") +later(91000) +poll(2) +assert(last_amps() == 6, "ended boost stepped down again") + +-- 12. The fuse ceiling falls to 6 A during a boost (Codex P1): 6 A at once. +boot() +poll(2) +assert(order(4140)); assert(last_amps() == 7) +assert(order(4140, six(6, 6))) +assert(last_amps() == 6, "boost kept 7 A after max_amps_per_phase fell to 6 A") + +-- 13. Observations fail past the window (Codex P2): the poll still writes +-- 6 A, and the next order does not bring 7 A back. +boot() +poll(2) +assert(order(4140)); assert(last_amps() == 7) +later(91000) +poll_fails() +assert(last_amps() == 6, "boost outlived its window while observations failed") +assert(order(4140)); assert(last_amps() == 6, "order after a failed poll wrote 7 A") + +-- 14. No poll at all past the window: the order itself ends the boost. +boot() +poll(2) +assert(order(4140)); assert(last_amps() == 7) +later(91000) +assert(order(4140)); assert(last_amps() == 6, "order past the window wrote 7 A") + +-- 15. No boost before a poll has seen the charger not charging. After a +-- driver reload the car may already charge at 6 A. +boot() +assert(order(4140)); assert(last_amps() == 6, "boost before the first poll") +poll_fails() +assert(order(4140)); assert(last_amps() == 6, "boost after only a failed poll") +poll(3, 4.1) +assert(order(4140)); assert(last_amps() == 6, "reloaded driver boosted a running session") +poll(2) +assert(order(4140)); assert(last_amps() == 7, "no boost once a poll saw the charger stop") +-- A first poll that sees the charger waiting allows the boost. +boot() +assert(order(4140)); assert(last_amps() == 6) +poll(2) +assert(order(4140)); assert(last_amps() == 7, "no boost after a poll saw the charger waiting") + +-- 16. A pause clears the boost. The phaseMode re-assert that the first order +-- scheduled then writes the 6 A order, not the boost's 7 A. +boot() +poll(2) +assert(order(4140)); assert(last_amps() == 7) +assert(driver_command("ev_pause", 0, {})) +later(5000) +poll(2) +assert(last_amps() == 6, "re-assert after a pause wrote the cleared boost's 7 A") + +-- 17. Default mode clears the boost even when its 0 A write fails; the +-- re-assert then retries 0 A instead of writing 7 A. +boot() +poll(2) +assert(order(4140)); assert(last_amps() == 7) +local http_post = host.http_post +host.http_post = function(url, ...) + if url:find("/settings", 1, true) then return nil, "HTTP 503" end + return http_post(url, ...) +end +driver_default_mode() +host.http_post = http_post +assert(last_amps() == 7) +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/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/drivers/tests/test_easee_cloud_start_boost.py b/drivers/tests/test_easee_cloud_start_boost.py new file mode 100644 index 0000000..cfba893 --- /dev/null +++ b/drivers/tests/test_easee_cloud_start_boost.py @@ -0,0 +1,12 @@ +"""A 6 A Easee start is offered 7 A until it charges, then 6 A.""" +from pathlib import Path +import subprocess + + +def test_easee_cloud_start_boost(): + root = Path(__file__).resolve().parents[2] + result = subprocess.run( + [str(root / "lua55"), "drivers/tests/lua_harness/test_easee_cloud_start_boost.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 fe9f348..c502c4f 100644 --- a/index.yaml +++ b/index.yaml @@ -193,15 +193,15 @@ drivers: size_bytes: 4054 sha256: "4a2cd1efb4a5583468ce897ebd88a000e348917295c40210e86ecf834c0ba543" - name: "easee_cloud" - version: "1.3.7" + version: "1.3.9" tier: core protocol: http connectivity: cloud setup: [vendor_portal] ders: [ev] control: true - size_bytes: 42628 - sha256: "8b1b9f980178af96fb897f275fbcd3b6a7f9a8df340f4907a9216f1596c849e4" + size_bytes: 51413 + sha256: "7b6d3033f85af88d2233ceaca7b64e341e8a9114fce68989b165495700db6a59" - name: "esphome_dsmr" version: "1.0.7" tier: core diff --git a/manifests/easee_cloud.yaml b/manifests/easee_cloud.yaml index 8836f4b..dfcac8c 100644 --- a/manifests/easee_cloud.yaml +++ b/manifests/easee_cloud.yaml @@ -1,5 +1,5 @@ name: "easee_cloud" -version: "1.3.7" +version: "1.3.9" 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: 42628 +size_bytes: 51413 dkb_id: "easee_cloud" -sha256: "8b1b9f980178af96fb897f275fbcd3b6a7f9a8df340f4907a9216f1596c849e4" +sha256: "7b6d3033f85af88d2233ceaca7b64e341e8a9114fce68989b165495700db6a59" signature: "" bytecode_sha256: "" diff --git a/support-status.json b/support-status.json index c9af28f..6ef485b 100644 --- a/support-status.json +++ b/support-status.json @@ -443,7 +443,7 @@ }, { "catalog_source": true, - "catalog_version": "1.3.7", + "catalog_version": "1.3.9", "driver_id": "easee_cloud", "targets": { "blixt-l1": {