From 529683728d8a6cce9582ef8ddb2f491942f52e0c Mon Sep 17 00:00:00 2001 From: Segran Date: Sat, 17 Oct 2026 17:31:09 +0200 Subject: [PATCH 1/4] fix(easee_cloud): reject out-of-order power observations Signed-off-by: Segran --- CHANGELOG.md | 8 +++ SUPPORT_STATUS.md | 4 +- devices.yaml | 2 +- drivers/lua/easee_cloud.lua | 61 ++++++++++++++++++- .../test_easee_cloud_load_balancer.lua | 4 +- .../test_easee_cloud_observation_order.lua | 41 +++++++++++++ .../lua_harness/test_easee_cloud_session.lua | 5 +- .../test_easee_cloud_observation_order.py | 12 ++++ index.yaml | 6 +- manifests/easee_cloud.yaml | 6 +- support-status.json | 2 +- 11 files changed, 138 insertions(+), 13 deletions(-) create mode 100644 drivers/tests/lua_harness/test_easee_cloud_observation_order.lua create mode 100644 drivers/tests/test_easee_cloud_observation_order.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 709e8ad..bb55afc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## [Unreleased] + +### easee_cloud 1.3.8 + +- Reject power observations that move backwards in source time, and conflicting values at the same physical time. Compare RFC3339 instants with UTC offsets and fractional precision normalized. An old cloud zero can no longer replace a newer sample, nor can an old positive sample resurrect an observed stop. +- 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. + ## easee_cloud 1.3.7 - 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. diff --git a/SUPPORT_STATUS.md b/SUPPORT_STATUS.md index 684bb0e..583f61b 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.8 | ftw-core | not_assessed | — | not_recorded | not_assessed | +| easee_cloud | 1.3.8 | 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 d67b63d..c26aa67 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.8" ders: [ev] control: true firmware_versions: "" diff --git a/drivers/lua/easee_cloud.lua b/drivers/lua/easee_cloud.lua index aec0a84..82f6d3e 100644 --- a/drivers/lua/easee_cloud.lua +++ b/drivers/lua/easee_cloud.lua @@ -25,7 +25,7 @@ DRIVER = { id = "easee_cloud", name = "Easee Cloud", manufacturer = "Easee", - version = "1.3.7", + version = "1.3.8", protocols = { "http" }, capabilities = { "ev" }, description = "Easee Home/Charge via Cloud REST API. No local protocol needed.", @@ -283,6 +283,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 +366,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 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/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 9790fbb..e992730 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.8" tier: core protocol: http connectivity: cloud setup: [vendor_portal] ders: [ev] control: true - size_bytes: 42628 - sha256: "8b1b9f980178af96fb897f275fbcd3b6a7f9a8df340f4907a9216f1596c849e4" + size_bytes: 45529 + sha256: "6225f6f2b42ac5790c2ee74024daf560cb4cae7b0d7f8f8bc4aa8d0cbe8dae55" - name: "esphome_dsmr" version: "1.0.7" tier: core diff --git a/manifests/easee_cloud.yaml b/manifests/easee_cloud.yaml index 8836f4b..0d35a72 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.8" 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: 45529 dkb_id: "easee_cloud" -sha256: "8b1b9f980178af96fb897f275fbcd3b6a7f9a8df340f4907a9216f1596c849e4" +sha256: "6225f6f2b42ac5790c2ee74024daf560cb4cae7b0d7f8f8bc4aa8d0cbe8dae55" signature: "" bytecode_sha256: "" diff --git a/support-status.json b/support-status.json index b708e19..0661ad1 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.8", "driver_id": "easee_cloud", "targets": { "blixt-l1": { From 04c7e2e64eab328e3183babc274f18227b8c1281 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Fri, 23 Oct 2026 00:38:15 +0200 Subject: [PATCH 2/4] fix(easee_cloud): start a 6 A order at 7 A (1.3.7) (#190) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(easee_cloud): start a 6 A order at 7 A (1.3.7) Signed-off-by: Lödkolven * fix(easee_cloud): boost only with a read static limit (Codex review) Signed-off-by: Lödkolven * docs(changelog): wrap easee_cloud 1.3.7 Signed-off-by: Lödkolven * fix(easee_cloud): boost only when Core's cap allows 7 A The 7 A start for a 6 A order now needs Core's cap_amps_per_phase (srcfl/ftw#1809) to allow 7 A, besides max_amps_per_phase and the charger's static limit. max_amps_per_phase is the fuse rating, not the room left: on a phase with room for 6.4 A the car drew 7 A, Core cut to 0 W, and the next start boosted again. A Core that sends no cap gets no boost. Review findings on #190: - P1: every 6 A order during a boost checks the gate again; one that no longer fits writes 6 A at once and ends the boost. - P2: the 90 s window ends in driver_command too, and driver_poll ends it before reading observations, so a failed read cannot keep 7 A. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Fredrik Ahlgren --------- Signed-off-by: Lödkolven Signed-off-by: Fredrik Ahlgren Co-authored-by: Lödkolven Co-authored-by: Claude Opus 5.5 --- CHANGELOG.md | 20 ++ SUPPORT_STATUS.md | 4 +- devices.yaml | 2 +- drivers/lua/easee_cloud.lua | 115 +++++++++- .../test_easee_cloud_start_boost.lua | 198 ++++++++++++++++++ drivers/tests/test_easee_cloud_start_boost.py | 12 ++ index.yaml | 6 +- manifests/easee_cloud.yaml | 6 +- support-status.json | 2 +- 9 files changed, 351 insertions(+), 14 deletions(-) create mode 100644 drivers/tests/lua_harness/test_easee_cloud_start_boost.lua create mode 100644 drivers/tests/test_easee_cloud_start_boost.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 50ebcc5..b54ee27 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,26 @@ ## [Unreleased] +### 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 ab20856..f05acf0 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.6 | ftw-core | not_assessed | — | not_recorded | not_assessed | -| easee_cloud | 1.3.6 | 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 | | 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 0d32524..f0a2249 100644 --- a/devices.yaml +++ b/devices.yaml @@ -338,7 +338,7 @@ manufacturers: protocols: - protocol: http driver: "easee_cloud" - version: "1.3.6" + version: "1.3.7" ders: [ev] control: true firmware_versions: "" diff --git a/drivers/lua/easee_cloud.lua b/drivers/lua/easee_cloud.lua index f8a04fe..7969e1b 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.6", + version = "1.3.7", 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), 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 + -- 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 @@ -489,6 +507,35 @@ 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 + function driver_init(config) host.set_make("Easee") @@ -525,6 +572,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 @@ -592,6 +643,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)) @@ -748,6 +804,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 @@ -791,7 +858,11 @@ 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 + boost_until_ms = nil + boost_spent = false + end return ok elseif action == "ev_resume" then local ok = post_command("/commands/resume_charging") @@ -853,9 +924,43 @@ 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 last_op_mode ~= 3 and start_boost_fits(cmd, max_a) then + 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 @@ -904,6 +1009,8 @@ function driver_default_mode() if post_command("/commands/pause_charging") then paused_state = true end + boost_until_ms = nil + boost_spent = false 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_start_boost.lua b/drivers/tests/lua_harness/test_easee_cloud_start_boost.lua new file mode 100644 index 0000000..b9ff128 --- /dev/null +++ b/drivers/tests/lua_harness/test_easee_cloud_start_boost.lua @@ -0,0 +1,198 @@ +-- 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 function boot(cfg, config_body) + 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) + 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}, + }) + 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") + +print("easee_cloud start boost: ok") 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 e853918..4923a56 100644 --- a/index.yaml +++ b/index.yaml @@ -193,15 +193,15 @@ drivers: size_bytes: 4054 sha256: "4a2cd1efb4a5583468ce897ebd88a000e348917295c40210e86ecf834c0ba543" - name: "easee_cloud" - version: "1.3.6" + version: "1.3.7" tier: core protocol: http connectivity: cloud setup: [vendor_portal] ders: [ev] control: true - size_bytes: 41070 - sha256: "4f33149c0640c2e9d2c043e36fdf967d8b297f07da56aec934c21e81a397d22b" + size_bytes: 46180 + sha256: "26e4b146262a1416fc64f81e27cda6b7aebe0a8e1f7fb924912675c00f7f86f7" - name: "esphome_dsmr" version: "1.0.7" tier: core diff --git a/manifests/easee_cloud.yaml b/manifests/easee_cloud.yaml index b50d5a4..8af9af8 100644 --- a/manifests/easee_cloud.yaml +++ b/manifests/easee_cloud.yaml @@ -1,5 +1,5 @@ name: "easee_cloud" -version: "1.3.6" +version: "1.3.7" 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: 41070 +size_bytes: 46180 dkb_id: "easee_cloud" -sha256: "4f33149c0640c2e9d2c043e36fdf967d8b297f07da56aec934c21e81a397d22b" +sha256: "26e4b146262a1416fc64f81e27cda6b7aebe0a8e1f7fb924912675c00f7f86f7" signature: "" bytecode_sha256: "" diff --git a/support-status.json b/support-status.json index b511b34..c9af28f 100644 --- a/support-status.json +++ b/support-status.json @@ -443,7 +443,7 @@ }, { "catalog_source": true, - "catalog_version": "1.3.6", + "catalog_version": "1.3.7", "driver_id": "easee_cloud", "targets": { "blixt-l1": { From 219c462ed69a68a096caaafa073e2971ae0a9311 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Fri, 23 Oct 2026 06:04:17 +0200 Subject: [PATCH 3/4] fix(easee_cloud): two review fixes to the 7 A start (1.3.8) (#191) Findings L1 and L2 from the review of #190. L1: before the first poll last_op_mode is nil, so a reloaded driver offered 7 A to a car that may already charge at 6 A. A boost now needs a poll that saw the charger not charging. L2: a pause or the stand-down cleared the boost but left 7 A in last_amps_set, so the phaseMode re-assert on the next poll wrote 7 A without the cap or the 90 s window. Clearing a running boost now sets last_amps_set to what the charger should hold: the 6 A order after a pause, 0 A after the stand-down. A pause keeps 6 A, not 0 A, because Core's contactor cycle sends ev_resume after ev_pause with no new order between. Harness cases 15-17 fail on 1.3.7 and pass here. Signed-off-by: Fredrik Ahlgren Co-authored-by: Claude Opus 5.5 --- CHANGELOG.md | 12 ++++++ SUPPORT_STATUS.md | 4 +- devices.yaml | 2 +- drivers/lua/easee_cloud.lua | 31 +++++++++---- .../test_easee_cloud_start_boost.lua | 43 +++++++++++++++++++ index.yaml | 6 +-- manifests/easee_cloud.yaml | 6 +-- support-status.json | 2 +- 8 files changed, 87 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b54ee27..5c9df36 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,18 @@ ## [Unreleased] +### 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 diff --git a/SUPPORT_STATUS.md b/SUPPORT_STATUS.md index f05acf0..309df45 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.8 | ftw-core | not_assessed | — | not_recorded | not_assessed | +| easee_cloud | 1.3.8 | 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..adbf916 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.8" ders: [ev] control: true firmware_versions: "" diff --git a/drivers/lua/easee_cloud.lua b/drivers/lua/easee_cloud.lua index 7969e1b..dfefe03 100644 --- a/drivers/lua/easee_cloud.lua +++ b/drivers/lua/easee_cloud.lua @@ -26,7 +26,7 @@ DRIVER = { id = "easee_cloud", name = "Easee Cloud", manufacturer = "Easee", - version = "1.3.7", + version = "1.3.8", protocols = { "http" }, capabilities = { "ev" }, description = "Easee Home/Charge via Cloud REST API. No local protocol needed.", @@ -96,14 +96,14 @@ local EASEE_MAX_A = 32 -- 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), and at most once per start. --- `start_boost: false` disables it. +-- 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 +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 @@ -536,6 +536,16 @@ local function end_start_boost(why) 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") @@ -860,8 +870,9 @@ function driver_command(action, power_w, cmd) local ok = post_command("/commands/pause_charging") if ok then paused_state = true - boost_until_ms = nil - boost_spent = false + -- 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 @@ -941,7 +952,10 @@ function driver_command(action, power_w, cmd) else write_a, boosting = START_BOOST_A, true end - elseif start_boost_enabled and not boost_spent and last_op_mode ~= 3 and start_boost_fits(cmd, max_a) then + 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 @@ -1009,8 +1023,7 @@ function driver_default_mode() if post_command("/commands/pause_charging") then paused_state = true end - boost_until_ms = nil - boost_spent = false + 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_start_boost.lua b/drivers/tests/lua_harness/test_easee_cloud_start_boost.lua index b9ff128..38ab7c5 100644 --- a/drivers/tests/lua_harness/test_easee_cloud_start_boost.lua +++ b/drivers/tests/lua_harness/test_easee_cloud_start_boost.lua @@ -195,4 +195,47 @@ 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") + print("easee_cloud start boost: ok") diff --git a/index.yaml b/index.yaml index 4923a56..36094da 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.8" tier: core protocol: http connectivity: cloud setup: [vendor_portal] ders: [ev] control: true - size_bytes: 46180 - sha256: "26e4b146262a1416fc64f81e27cda6b7aebe0a8e1f7fb924912675c00f7f86f7" + size_bytes: 46954 + sha256: "652708cfe0d97027b97a3be407df49e20b74f1f30f429df20a24df46e9afc806" - name: "esphome_dsmr" version: "1.0.7" tier: core diff --git a/manifests/easee_cloud.yaml b/manifests/easee_cloud.yaml index 8af9af8..28b22f9 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.8" 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: 46180 +size_bytes: 46954 dkb_id: "easee_cloud" -sha256: "26e4b146262a1416fc64f81e27cda6b7aebe0a8e1f7fb924912675c00f7f86f7" +sha256: "652708cfe0d97027b97a3be407df49e20b74f1f30f429df20a24df46e9afc806" signature: "" bytecode_sha256: "" diff --git a/support-status.json b/support-status.json index c9af28f..903ad1f 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.8", "driver_id": "easee_cloud", "targets": { "blixt-l1": { From ef2fb61ba83b9b43065acb37e3e15b2ad50aad20 Mon Sep 17 00:00:00 2001 From: Segran Date: Fri, 23 Oct 2026 17:38:33 +0200 Subject: [PATCH 4/4] fix(easee_cloud): advance observation-order candidate beyond published version Signed-off-by: Segran --- CHANGELOG.md | 2 +- SUPPORT_STATUS.md | 4 ++-- devices.yaml | 2 +- drivers/lua/easee_cloud.lua | 2 +- index.yaml | 4 ++-- manifests/easee_cloud.yaml | 4 ++-- support-status.json | 2 +- 7 files changed, 10 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fc22391..0c693e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ## [Unreleased] -### easee_cloud 1.3.8 +### 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. diff --git a/SUPPORT_STATUS.md b/SUPPORT_STATUS.md index 309df45..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.8 | ftw-core | not_assessed | — | not_recorded | not_assessed | -| easee_cloud | 1.3.8 | 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 adbf916..9468047 100644 --- a/devices.yaml +++ b/devices.yaml @@ -338,7 +338,7 @@ manufacturers: protocols: - protocol: http driver: "easee_cloud" - version: "1.3.8" + 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 82f6d3e..1be93b0 100644 --- a/drivers/lua/easee_cloud.lua +++ b/drivers/lua/easee_cloud.lua @@ -25,7 +25,7 @@ DRIVER = { id = "easee_cloud", name = "Easee Cloud", manufacturer = "Easee", - version = "1.3.8", + version = "1.3.9", protocols = { "http" }, capabilities = { "ev" }, description = "Easee Home/Charge via Cloud REST API. No local protocol needed.", diff --git a/index.yaml b/index.yaml index 08b701c..603e170 100644 --- a/index.yaml +++ b/index.yaml @@ -193,7 +193,7 @@ drivers: size_bytes: 4054 sha256: "4a2cd1efb4a5583468ce897ebd88a000e348917295c40210e86ecf834c0ba543" - name: "easee_cloud" - version: "1.3.8" + version: "1.3.9" tier: core protocol: http connectivity: cloud @@ -201,7 +201,7 @@ drivers: ders: [ev] control: true size_bytes: 45529 - sha256: "6225f6f2b42ac5790c2ee74024daf560cb4cae7b0d7f8f8bc4aa8d0cbe8dae55" + sha256: "a2f95164716ddc2735bd15a2de0a1177bf49737ff4233203b4893c653f272f20" - name: "esphome_dsmr" version: "1.0.7" tier: core diff --git a/manifests/easee_cloud.yaml b/manifests/easee_cloud.yaml index 0d35a72..3166890 100644 --- a/manifests/easee_cloud.yaml +++ b/manifests/easee_cloud.yaml @@ -1,5 +1,5 @@ name: "easee_cloud" -version: "1.3.8" +version: "1.3.9" tier: core author: "Sourceful Labs AB" protocol: http @@ -18,7 +18,7 @@ tested_devices: min_host_version: "2.0.0" size_bytes: 45529 dkb_id: "easee_cloud" -sha256: "6225f6f2b42ac5790c2ee74024daf560cb4cae7b0d7f8f8bc4aa8d0cbe8dae55" +sha256: "a2f95164716ddc2735bd15a2de0a1177bf49737ff4233203b4893c653f272f20" signature: "" bytecode_sha256: "" diff --git a/support-status.json b/support-status.json index 903ad1f..6ef485b 100644 --- a/support-status.json +++ b/support-status.json @@ -443,7 +443,7 @@ }, { "catalog_source": true, - "catalog_version": "1.3.8", + "catalog_version": "1.3.9", "driver_id": "easee_cloud", "targets": { "blixt-l1": {