From 2ff25235ff7c0a05683b70c6b3e1d7597f007552 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Sun, 13 Sep 2026 15:53:53 +0200 Subject: [PATCH 1/4] fix(easee): preserve session proof and observation times Signed-off-by: Fredrik Ahlgren --- CHANGELOG.md | 4 +++ SUPPORT_STATUS.md | 4 +-- devices.yaml | 2 +- drivers/lua/easee_cloud.lua | 29 ++++++++++----- .../lua_harness/test_easee_cloud_session.lua | 36 ++++++++++++++++++- index.yaml | 6 ++-- manifests/easee_cloud.yaml | 6 ++-- support-status.json | 2 +- 8 files changed, 70 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a7b9417..a4662ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## easee_cloud 1.3.2 + +Verify paused open sessions after restart, retain each measurement's source time, and omit an old no-current reason while fresh charging power flows. Cloud offline state no longer reports a cable unplug. + All notable changes to drivers in this repository are documented here. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). diff --git a/SUPPORT_STATUS.md b/SUPPORT_STATUS.md index 4fbd2b7..fd6ce68 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 | no | | easee | 1.0.4 | ftw-core | not_assessed | — | — | not_recorded | — | not_assessed | no | | easee | 1.0.4 | blixt-l1 | not_assessed | — | — | not_recorded | — | not_assessed | no | -| easee_cloud | 1.3.1 | ftw-core | not_assessed | — | — | not_recorded | — | not_assessed | no | -| easee_cloud | 1.3.1 | blixt-l1 | not_assessed | — | — | not_recorded | — | not_assessed | no | +| easee_cloud | 1.3.2 | ftw-core | not_assessed | — | — | not_recorded | — | not_assessed | no | +| easee_cloud | 1.3.2 | blixt-l1 | not_assessed | — | — | not_recorded | — | not_assessed | no | | esphome-dsmr | 1.0.3 | ftw-core | not_assessed | 1.0.2 | — | not_recorded | — | not_assessed | no | | esphome-dsmr | 1.0.3 | blixt-l1 | not_assessed | — | — | not_recorded | — | not_assessed | no | | esphome_dsmr | 1.0.3 | ftw-core | not_assessed | — | — | not_recorded | — | not_assessed | no | diff --git a/devices.yaml b/devices.yaml index 937502a..5808149 100644 --- a/devices.yaml +++ b/devices.yaml @@ -349,7 +349,7 @@ manufacturers: protocols: - protocol: http driver: "easee_cloud" - version: "1.3.1" + version: "1.3.2" ders: [ev] control: true firmware_versions: "" diff --git a/drivers/lua/easee_cloud.lua b/drivers/lua/easee_cloud.lua index 4db4761..0526020 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.1", + version = "1.3.2", protocols = { "http" }, capabilities = { "ev" }, description = "Easee Home/Charge via Cloud REST API. No local protocol needed.", @@ -290,27 +290,30 @@ local function get_observations(serial) if type(decoded) ~= "table" then return nil, "decode failed" end local list = decoded.observations or decoded local obs = {} + local timestamps = {} for _, item in ipairs(list) do if item.id then obs[item.id] = tonumber(item.value) or item.value + timestamps[item.id] = item.timestamp end end local mode = obs[OBS_OP_MODE] if type(mode) ~= "number" or mode < 0 or mode > 6 or mode % 1 ~= 0 then return nil, "missing charger state" end + if mode == 0 then return nil, "charger offline; connection unknown" end if mode >= 2 and (type(obs[OBS_TOTAL_POWER]) ~= "number" or type(obs[OBS_SESSION_ENERGY]) ~= "number" or obs[OBS_SESSION_ENERGY] < 0) then return nil, "missing session measurement" end - return obs, nil + return obs, nil, timestamps end -- Observation 223 identifies an energy session. That session can end before -- the cable is removed, so it is not enough to restore a prior car's level. --- Validate an active session against the sessions API once, then retain its +-- Validate an open session against the sessions API once, then retain its -- identity through pauses in this driver process until a completed session. --- Completion or a fresh process needs active proof again; an offline car may +-- Completion or a fresh process needs open-session proof again; an offline car may -- need its battery level confirmed. Neither endpoint proves a paused car's -- identity after an ended session. -- https://developer.easee.com/docs/charger-observation-ids @@ -345,9 +348,8 @@ local function current_session_id(obs, op_mode) validated_session_id = nil end if validated_session_id == identity then return identity end - -- A completed or awaiting session can belong to an earlier car. Do not - -- turn that into automatic battery-level restoration after restart. - if op_mode ~= 3 or (obs[OBS_TOTAL_POWER] or 0) < 0.1 then return nil end + -- A pause is still an open session. Ask the ongoing-session endpoint + -- before restoring its identity; observation 223 alone may be historical. -- The vendor allows ten requests per hour, not one per normal poll. local now = host.millis() @@ -562,7 +564,7 @@ function driver_poll() return 10000 end - local obs, err = get_observations(charger_serial) + 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)) return 10000 @@ -579,6 +581,13 @@ function driver_poll() local is_online = (op_mode ~= 0) local reason_code = obs[OBS_REASON_NO_CUR] + -- ReasonForNoCurrent describes a blocked offer. An older reason is not + -- a current fault while the charger reports both charging and power. + local reason_at = normalized_session_start(timestamps[OBS_REASON_NO_CUR]) + local power_at = normalized_session_start(timestamps[OBS_TOTAL_POWER]) + if charging and power_w > 100 and reason_at and power_at and reason_at <= power_at then + reason_code = nil + end local cable_locked = obs[OBS_CABLE_LOCKED] if cable_locked ~= nil then cable_locked = (cable_locked == 1 or cable_locked == true) end local dyn_current = obs[OBS_DYN_CURRENT] @@ -645,6 +654,10 @@ function driver_poll() charging = charging, request_active = request_active, session_wh = session_wh, + power_observed_at = timestamps[OBS_TOTAL_POWER], + energy_observed_at = timestamps[OBS_SESSION_ENERGY], + state_observed_at = timestamps[OBS_OP_MODE], + reason_observed_at = timestamps[OBS_REASON_NO_CUR], session_id = current_session_id(obs, op_mode), op_mode = op_mode, -- 1=disc,2=awaiting,3=charging,4=completed,5=error,6=ready state_label = OP_MODE_LABELS[op_mode] or "unknown", diff --git a/drivers/tests/lua_harness/test_easee_cloud_session.lua b/drivers/tests/lua_harness/test_easee_cloud_session.lua index 0c2d5f8..dbb43eb 100644 --- a/drivers/tests/lua_harness/test_easee_cloud_session.lua +++ b/drivers/tests/lua_harness/test_easee_cloud_session.lua @@ -32,7 +32,9 @@ assert(poll(3,current).session_id == canonical,"identity changed on driver resta assert(poll(2,current).session_id == canonical,"a pause lost the confirmed session") assert(poll(6,current).session_id == canonical,"ready mode lost the confirmed session") boot() -assert(poll(2,current).session_id == nil,"restart inferred a paused car's identity") +assert(poll(2,current).session_id == canonical,"paused open session was not verified after restart") +boot(true) +assert(poll(2,current).session_id == nil,"paused ended session restored a previous car") boot(true) assert(poll(3,current).session_id == nil,"closed API session passed as active") boot() @@ -117,3 +119,35 @@ for _, payload in ipairs({"true", "false", "42", '"error"', "null", "[]", "{}"}) assert(sample.connected and sample.w == 4300, "ongoing payload dropped fresh charger readings") end print("Easee current-session identity: passed") + +boot() +host._http_responses["/observations?ids="] = host.json_encode({ + {id=109,value=3,timestamp="2026-01-01T08:02:00Z"}, + {id=120,value=6.9,timestamp="2026-01-01T08:03:00Z"}, + {id=121,value=9,timestamp="2026-01-01T08:00:00Z"}, + {id=96,value=5,timestamp="2026-01-01T08:01:00Z"}, + {id=223,value=current}, +}) +driver_poll() +local sample=host._emitted.ev[#host._emitted.ev] +assert(sample.power_observed_at == "2026-01-01T08:03:00Z") +assert(sample.energy_observed_at == "2026-01-01T08:00:00Z") +assert(sample.state_observed_at == "2026-01-01T08:02:00Z") +assert(sample.reason_observed_at == "2026-01-01T08:01:00Z") +assert(sample.reason_no_current == nil and sample.reason_no_current_label == nil, "charging reported an old no-current reason") + +local emitted=#host._emitted.ev +host._http_responses["/observations?ids="] = host.json_encode({{id=109,value=0}}) +driver_poll() +assert(#host._emitted.ev == emitted,"cloud offline state invented an unplug") + +boot() +host._http_responses["/observations?ids="] = host.json_encode({ + {id=109,value=3,timestamp="2026-01-01T08:02:00Z"}, + {id=120,value=6.9,timestamp="2026-01-01T08:03:00Z"}, + {id=121,value=9,timestamp="2026-01-01T08:00:00Z"}, + {id=96,value=5,timestamp="2026-01-01T08:04:00Z"}, +}) +driver_poll() +sample=host._emitted.ev[#host._emitted.ev] +assert(sample.reason_no_current == 5, "old power hid a newer no-current reason") diff --git a/index.yaml b/index.yaml index 86c2415..f01af5f 100644 --- a/index.yaml +++ b/index.yaml @@ -193,15 +193,15 @@ drivers: size_bytes: 4054 sha256: "4a2cd1efb4a5583468ce897ebd88a000e348917295c40210e86ecf834c0ba543" - name: "easee_cloud" - version: "1.3.1" + version: "1.3.2" tier: core protocol: http connectivity: cloud setup: [vendor_portal] ders: [ev] control: true - size_bytes: 36986 - sha256: "e28ae3a3805d8d13848dc6d9ce3f0f1c3ed51d9354bdd4d3329f323a02a36d27" + size_bytes: 37774 + sha256: "68957fb2720bfbeed12b6e3e69501c98858ff4d0ec2291a23cdb06ad778532d2" - name: "esphome-dsmr" version: "1.0.3" tier: community diff --git a/manifests/easee_cloud.yaml b/manifests/easee_cloud.yaml index 335569f..f7b8962 100644 --- a/manifests/easee_cloud.yaml +++ b/manifests/easee_cloud.yaml @@ -1,5 +1,5 @@ name: "easee_cloud" -version: "1.3.1" +version: "1.3.2" 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: 36986 +size_bytes: 37774 dkb_id: "easee_cloud" -sha256: "e28ae3a3805d8d13848dc6d9ce3f0f1c3ed51d9354bdd4d3329f323a02a36d27" +sha256: "68957fb2720bfbeed12b6e3e69501c98858ff4d0ec2291a23cdb06ad778532d2" signature: "" bytecode_sha256: "" diff --git a/support-status.json b/support-status.json index 276ba55..56b1bd7 100644 --- a/support-status.json +++ b/support-status.json @@ -590,7 +590,7 @@ }, { "catalog_source": true, - "catalog_version": "1.3.1", + "catalog_version": "1.3.2", "driver_id": "easee_cloud", "package_id": null, "targets": { From 2c01c9b2fdf050de09065fbe6484213e65a4560f Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Sun, 13 Sep 2026 16:20:48 +0200 Subject: [PATCH 2/4] fix(easee): keep current session through vendor pause timestamps Signed-off-by: Fredrik Ahlgren --- CHANGELOG.md | 2 +- drivers/lua/easee_cloud.lua | 21 +++++++------- .../lua_harness/test_easee_cloud_session.lua | 28 +++++++++---------- index.yaml | 4 +-- manifests/easee_cloud.yaml | 4 +-- 5 files changed, 29 insertions(+), 30 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a4662ab..c699ad3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ## easee_cloud 1.3.2 -Verify paused open sessions after restart, retain each measurement's source time, and omit an old no-current reason while fresh charging power flows. Cloud offline state no longer reports a cable unplug. +Restore the current session after restart even when Easee fills sessionEnd during a pause. Keep the ID when the car stops drawing; revoke it on a real unplug. Retain each measurement's source time and omit an old no-current reason while fresh charging power flows. Cloud offline state no longer reports a cable unplug. All notable changes to drivers in this repository are documented here. diff --git a/drivers/lua/easee_cloud.lua b/drivers/lua/easee_cloud.lua index 0526020..835c065 100644 --- a/drivers/lua/easee_cloud.lua +++ b/drivers/lua/easee_cloud.lua @@ -309,17 +309,15 @@ local function get_observations(serial) return obs, nil, timestamps end --- Observation 223 identifies an energy session. That session can end before --- the cable is removed, so it is not enough to restore a prior car's level. --- Validate an open session against the sessions API once, then retain its --- identity through pauses in this driver process until a completed session. --- Completion or a fresh process needs open-session proof again; an offline car may --- need its battery level confirmed. Neither endpoint proves a paused car's --- identity after an ended session. --- https://developer.easee.com/docs/charger-observation-ids +-- Match observation 223 to the vendor's current-session endpoint. sessionEnd +-- can be populated while a car is merely paused; it is not cable-disconnect +-- proof (confirmed on hardware, 2026-09-13). Mode 4 also means the car paused +-- or stopped drawing. Keep the same identity until disconnect or a new ID. -- https://developer.easee.com/reference/chargers_getongoingsessiondetails +-- https://developer.easee.com/docs/api-command-and-control local validated_session_id = nil local observed_session_id = nil +local departed_session_id = nil local session_lookups_ms = {} local last_session_lookup_ms = nil @@ -330,7 +328,8 @@ local function normalized_session_start(value) end local function current_session_id(obs, op_mode) - if op_mode == 0 or op_mode == 1 or op_mode == 4 then + if op_mode == 0 or op_mode == 1 then + if op_mode == 1 then departed_session_id = observed_session_id or departed_session_id end validated_session_id = nil observed_session_id = nil return nil @@ -343,6 +342,7 @@ local function current_session_id(obs, op_mode) local start = normalized_session_start(session.Start) if not id or id <= 0 or id % 1 ~= 0 or not start then return nil end local identity = string.format("%.0f", id) .. ":" .. start + if identity == departed_session_id then return nil end if identity ~= observed_session_id then observed_session_id = identity validated_session_id = nil @@ -367,8 +367,7 @@ local function current_session_id(obs, op_mode) if err then return nil end local ongoing = safe_json_decode(body) if type(ongoing) ~= "table" or tonumber(ongoing.sessionId) ~= id or - normalized_session_start(ongoing.sessionStart) ~= start or - (ongoing.sessionEnd ~= nil and ongoing.sessionEnd ~= "") then return nil end + normalized_session_start(ongoing.sessionStart) ~= start then return nil end validated_session_id = identity return identity end diff --git a/drivers/tests/lua_harness/test_easee_cloud_session.lua b/drivers/tests/lua_harness/test_easee_cloud_session.lua index dbb43eb..d34b03a 100644 --- a/drivers/tests/lua_harness/test_easee_cloud_session.lua +++ b/drivers/tests/lua_harness/test_easee_cloud_session.lua @@ -26,7 +26,7 @@ local canonical = "100:2026-01-01T08:00:00Z" local current = host.json_encode({Id=100,Start=start,MeterValue=1000}) boot() assert(poll(3,current).session_id == canonical,"current session missing") -assert(poll(4,current).session_id == nil,"completed session retained active proof") +assert(poll(4,current).session_id == canonical,"car stopping erased its session") boot() assert(poll(3,current).session_id == canonical,"identity changed on driver restart") assert(poll(2,current).session_id == canonical,"a pause lost the confirmed session") @@ -34,9 +34,9 @@ assert(poll(6,current).session_id == canonical,"ready mode lost the confirmed se boot() assert(poll(2,current).session_id == canonical,"paused open session was not verified after restart") boot(true) -assert(poll(2,current).session_id == nil,"paused ended session restored a previous car") +assert(poll(2,current).session_id == canonical,"sessionEnd during a pause blocked restoration") boot(true) -assert(poll(3,current).session_id == nil,"closed API session passed as active") +assert(poll(3,current).session_id == canonical,"sessionEnd during charging blocked restoration") boot() assert(poll(3,current,9,1000).session_id == canonical,"lagging lifetime counter blocked a verified active session") for i=1,20 do assert(poll(3,current).session_id == canonical) end @@ -65,10 +65,11 @@ assert(#host._emitted.ev == before,"broken JSON invented fresh telemetry") host._http_responses["/observations?ids="] = nil driver_poll() assert(#host._emitted.ev == before,"failed read invented fresh telemetry") -boot(true) +boot() +host._http_responses["/sessions/ongoing"] = host.json_encode({sessionId=99,sessionStart="2026-01-01T08:00:00Z"}) for i=1,20 do host._millis_counter = host._millis_counter + 61000 - assert(poll(3,current).session_id == nil,"ended session accepted during retries") + assert(poll(3,current).session_id == nil,"mismatched session accepted during retries") end lookups=0 for _, call in ipairs(host._calls) do @@ -76,17 +77,16 @@ for _, call in ipairs(host._calls) do end assert(lookups==10,"ongoing-session API exceeded ten requests per hour: "..lookups) --- A car can be swapped between polls while observation 223 still describes --- its predecessor. Once completion was seen, pauses cannot reuse that proof. -boot() +-- Stopping energy flow is not removing the cable. A real observed unplug +-- does revoke this identity even if the cloud endpoints still carry old data. +boot(true) assert(poll(3,current).session_id == canonical) -host._http_responses["/sessions/ongoing"] = host.json_encode({sessionId=100,sessionStart="2026-01-01T08:00:00Z",sessionEnd="2026-01-01T09:00:00Z"}) -assert(poll(4,current).session_id == nil,"completion kept the previous car's proof") -assert(poll(2,current).session_id == nil,"awaiting car reused completed session") -assert(poll(6,current).session_id == nil,"ready car reused completed session") -host._millis_counter = host._millis_counter + 61000 -assert(poll(3,current).session_id == nil,"ended API session passed active revalidation") +assert(poll(4,current).session_id == canonical,"car pause lost its identity") +assert(poll(2,current).session_id == canonical,"waiting car lost its identity") +assert(poll(6,current).session_id == canonical,"ready car lost its identity") +assert(poll(1,current).session_id == nil,"unplug retained identity") host._millis_counter = host._millis_counter + 61000 +assert(poll(2,current).session_id == nil,"new connection reused departed session") host._http_responses["/sessions/ongoing"] = host.json_encode({sessionId=101,sessionStart="2026-01-02T08:00:00Z"}) assert(poll(3,next,1,1010).session_id == "101:2026-01-02T08:00:00Z","new active session did not regain proof") diff --git a/index.yaml b/index.yaml index f01af5f..6522ff9 100644 --- a/index.yaml +++ b/index.yaml @@ -200,8 +200,8 @@ drivers: setup: [vendor_portal] ders: [ev] control: true - size_bytes: 37774 - sha256: "68957fb2720bfbeed12b6e3e69501c98858ff4d0ec2291a23cdb06ad778532d2" + size_bytes: 37688 + sha256: "c4a7bb3adcaba5930c138d7c72092a5f409f168c5db769e3cb25b39601881841" - name: "esphome-dsmr" version: "1.0.3" tier: community diff --git a/manifests/easee_cloud.yaml b/manifests/easee_cloud.yaml index f7b8962..166e49d 100644 --- a/manifests/easee_cloud.yaml +++ b/manifests/easee_cloud.yaml @@ -16,9 +16,9 @@ tested_devices: notes: "Easee Home/Charge via Cloud REST API. No local protocol needed." min_driver_version: "1.0.1" min_host_version: "2.0.0" -size_bytes: 37774 +size_bytes: 37688 dkb_id: "easee_cloud" -sha256: "68957fb2720bfbeed12b6e3e69501c98858ff4d0ec2291a23cdb06ad778532d2" +sha256: "c4a7bb3adcaba5930c138d7c72092a5f409f168c5db769e3cb25b39601881841" signature: "" bytecode_sha256: "" From 2086b64f06c40e6277edc37d907512010b6c1871 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Sun, 13 Sep 2026 16:30:24 +0200 Subject: [PATCH 3/4] fix(easee): bound power estimates to observed reporting cadence Signed-off-by: Fredrik Ahlgren --- drivers/lua/easee_cloud.lua | 3 +++ drivers/tests/lua_harness/test_easee_cloud_session.lua | 1 + index.yaml | 4 ++-- manifests/easee_cloud.yaml | 4 ++-- 4 files changed, 8 insertions(+), 4 deletions(-) diff --git a/drivers/lua/easee_cloud.lua b/drivers/lua/easee_cloud.lua index 835c065..6ea13e6 100644 --- a/drivers/lua/easee_cloud.lua +++ b/drivers/lua/easee_cloud.lua @@ -654,6 +654,9 @@ function driver_poll() request_active = request_active, session_wh = session_wh, power_observed_at = timestamps[OBS_TOTAL_POWER], + -- Two-minute source updates were observed on hardware. One minute + -- of margin bounds the power estimate without refreshing its time. + power_max_age_s = 180, energy_observed_at = timestamps[OBS_SESSION_ENERGY], state_observed_at = timestamps[OBS_OP_MODE], reason_observed_at = timestamps[OBS_REASON_NO_CUR], diff --git a/drivers/tests/lua_harness/test_easee_cloud_session.lua b/drivers/tests/lua_harness/test_easee_cloud_session.lua index d34b03a..fcbef2c 100644 --- a/drivers/tests/lua_harness/test_easee_cloud_session.lua +++ b/drivers/tests/lua_harness/test_easee_cloud_session.lua @@ -131,6 +131,7 @@ host._http_responses["/observations?ids="] = host.json_encode({ driver_poll() local sample=host._emitted.ev[#host._emitted.ev] assert(sample.power_observed_at == "2026-01-01T08:03:00Z") +assert(sample.power_max_age_s == 180) assert(sample.energy_observed_at == "2026-01-01T08:00:00Z") assert(sample.state_observed_at == "2026-01-01T08:02:00Z") assert(sample.reason_observed_at == "2026-01-01T08:01:00Z") diff --git a/index.yaml b/index.yaml index 6522ff9..ef543d1 100644 --- a/index.yaml +++ b/index.yaml @@ -200,8 +200,8 @@ drivers: setup: [vendor_portal] ders: [ev] control: true - size_bytes: 37688 - sha256: "c4a7bb3adcaba5930c138d7c72092a5f409f168c5db769e3cb25b39601881841" + size_bytes: 37878 + sha256: "55380843ce8d32ec68a8247f7288c215ebbfa66b94097720e884e5cd04069641" - name: "esphome-dsmr" version: "1.0.3" tier: community diff --git a/manifests/easee_cloud.yaml b/manifests/easee_cloud.yaml index 166e49d..0a619e9 100644 --- a/manifests/easee_cloud.yaml +++ b/manifests/easee_cloud.yaml @@ -16,9 +16,9 @@ tested_devices: notes: "Easee Home/Charge via Cloud REST API. No local protocol needed." min_driver_version: "1.0.1" min_host_version: "2.0.0" -size_bytes: 37688 +size_bytes: 37878 dkb_id: "easee_cloud" -sha256: "c4a7bb3adcaba5930c138d7c72092a5f409f168c5db769e3cb25b39601881841" +sha256: "55380843ce8d32ec68a8247f7288c215ebbfa66b94097720e884e5cd04069641" signature: "" bytecode_sha256: "" From d44fda113f171b5824ecbe178073731dfcf1c031 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Sun, 13 Sep 2026 17:05:26 +0200 Subject: [PATCH 4/4] fix(easee): recheck proof after outages and preserve lookup quota Signed-off-by: Fredrik Ahlgren --- CHANGELOG.md | 2 + drivers/lua/easee_cloud.lua | 42 ++++++++++++++----- .../lua_harness/test_easee_cloud_session.lua | 33 ++++++++++++++- index.yaml | 4 +- manifests/easee_cloud.yaml | 4 +- 5 files changed, 69 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c699ad3..291098d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ Restore the current session after restart even when Easee fills sessionEnd during a pause. Keep the ID when the car stops drawing; revoke it on a real unplug. Retain each measurement's source time and omit an old no-current reason while fresh charging power flows. Cloud offline state no longer reports a cable unplug. +Recheck session identity after offline or unreadable observations. Spread failed session lookups across the hour, with an earlier attempt when charging starts or the session changes. + All notable changes to drivers in this repository are documented here. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). diff --git a/drivers/lua/easee_cloud.lua b/drivers/lua/easee_cloud.lua index 6ea13e6..3bc019a 100644 --- a/drivers/lua/easee_cloud.lua +++ b/drivers/lua/easee_cloud.lua @@ -282,12 +282,27 @@ local OBS_SESSION_START = 223 local OBS_IDS = "48,96,103,109,120,121,124,183,194,223" +local validated_session_id = nil +local observed_session_id = nil +local departed_session_id = nil +local session_lookups_ms = {} +local last_session_lookup_ms = nil +local last_session_mode = nil + +local function invalidate_session_proof() + -- An unreadable charger may have changed cars during the gap. Keep the + -- gap unknown, but require fresh proof when observations return. + validated_session_id = nil + observed_session_id = nil + last_session_mode = nil +end + 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()) - if err then return nil, err end + if err then invalidate_session_proof(); return nil, err end local decoded = safe_json_decode(resp) - if type(decoded) ~= "table" then return nil, "decode failed" end + if type(decoded) ~= "table" then invalidate_session_proof(); return nil, "decode failed" end local list = decoded.observations or decoded local obs = {} local timestamps = {} @@ -299,11 +314,16 @@ local function get_observations(serial) end local mode = obs[OBS_OP_MODE] if type(mode) ~= "number" or mode < 0 or mode > 6 or mode % 1 ~= 0 then + invalidate_session_proof() return nil, "missing charger state" end - if mode == 0 then return nil, "charger offline; connection unknown" end + if mode == 0 then + invalidate_session_proof() + return nil, "charger offline; connection unknown" + end if mode >= 2 and (type(obs[OBS_TOTAL_POWER]) ~= "number" or type(obs[OBS_SESSION_ENERGY]) ~= "number" or obs[OBS_SESSION_ENERGY] < 0) then + invalidate_session_proof() return nil, "missing session measurement" end return obs, nil, timestamps @@ -315,12 +335,6 @@ end -- or stopped drawing. Keep the same identity until disconnect or a new ID. -- https://developer.easee.com/reference/chargers_getongoingsessiondetails -- https://developer.easee.com/docs/api-command-and-control -local validated_session_id = nil -local observed_session_id = nil -local departed_session_id = nil -local session_lookups_ms = {} -local last_session_lookup_ms = nil - local function normalized_session_start(value) if type(value) ~= "string" or not value:match("^%d%d%d%d%-%d%d%-%d%dT%d%d:%d%d:%d%d") then return nil end @@ -328,6 +342,8 @@ local function normalized_session_start(value) end local function current_session_id(obs, op_mode) + local began_charging = op_mode == 3 and last_session_mode ~= 3 + last_session_mode = op_mode if op_mode == 0 or op_mode == 1 then if op_mode == 1 then departed_session_id = observed_session_id or departed_session_id end validated_session_id = nil @@ -343,7 +359,8 @@ local function current_session_id(obs, op_mode) if not id or id <= 0 or id % 1 ~= 0 or not start then return nil end local identity = string.format("%.0f", id) .. ":" .. start if identity == departed_session_id then return nil end - if identity ~= observed_session_id then + local identity_changed = identity ~= observed_session_id + if identity_changed then observed_session_id = identity validated_session_id = nil end @@ -358,7 +375,10 @@ local function current_session_id(obs, op_mode) if now - at < 3600000 then table.insert(recent, at) end end session_lookups_ms = recent - if #recent >= 10 or (last_session_lookup_ms and now - last_session_lookup_ms < 60000) then + -- Spread failed retries across the hour. A new session or a transition to + -- charging may try sooner, while retaining the minute and hourly limits. + local retry_ms = (identity_changed or began_charging) and 60000 or 360000 + if #recent >= 10 or (last_session_lookup_ms and now - last_session_lookup_ms < retry_ms) then return nil end last_session_lookup_ms = now diff --git a/drivers/tests/lua_harness/test_easee_cloud_session.lua b/drivers/tests/lua_harness/test_easee_cloud_session.lua index fcbef2c..b5ea64e 100644 --- a/drivers/tests/lua_harness/test_easee_cloud_session.lua +++ b/drivers/tests/lua_harness/test_easee_cloud_session.lua @@ -67,7 +67,7 @@ driver_poll() assert(#host._emitted.ev == before,"failed read invented fresh telemetry") boot() host._http_responses["/sessions/ongoing"] = host.json_encode({sessionId=99,sessionStart="2026-01-01T08:00:00Z"}) -for i=1,20 do +for i=1,59 do host._millis_counter = host._millis_counter + 61000 assert(poll(3,current).session_id == nil,"mismatched session accepted during retries") end @@ -152,3 +152,34 @@ host._http_responses["/observations?ids="] = host.json_encode({ driver_poll() sample=host._emitted.ev[#host._emitted.ev] assert(sample.reason_no_current == 5, "old power hid a newer no-current reason") + +-- A car may be replaced while the charger is offline. Historical observation +-- 223 must not reuse proof from before the outage when telemetry returns. +for _, failed in ipairs({host.json_encode({{id=109,value=0}}), 'not-json', '{}'}) do + boot() + assert(poll(3,current).session_id == canonical) + local count=#host._emitted.ev + host._http_responses["/observations?ids="] = failed + driver_poll() + assert(#host._emitted.ev == count,"outage emitted a new physical state") + host._millis_counter = host._millis_counter + 61000 + host._http_responses["/sessions/ongoing"] = host.json_encode({sessionId=101,sessionStart="2026-01-02T08:00:00Z"}) + assert(poll(2,current).session_id == nil,"offline proof restored an old vehicle") + host._millis_counter = host._millis_counter + 61000 + assert(poll(3,next,1,1010).session_id == "101:2026-01-02T08:00:00Z","current vehicle did not regain proof") +end + +-- Mismatched paused telemetry must leave quota for when charging starts. +boot() +host._http_responses["/sessions/ongoing"] = '{}' +for i=1,10 do + assert(poll(2,current).session_id == nil) + host._millis_counter = host._millis_counter + 61000 +end +lookups=0 +for _, call in ipairs(host._calls) do + if call.func=="http_get" and call.args[1]:find("/sessions/ongoing",1,true) then lookups=lookups+1 end +end +assert(lookups <= 2,"paused retries exhausted the lookup quota: "..lookups) +host._http_responses["/sessions/ongoing"] = host.json_encode({sessionId=100,sessionStart="2026-01-01T08:00:00Z"}) +assert(poll(3,current).session_id == canonical,"charging could not verify its session after paused retries") diff --git a/index.yaml b/index.yaml index ef543d1..3927d0b 100644 --- a/index.yaml +++ b/index.yaml @@ -200,8 +200,8 @@ drivers: setup: [vendor_portal] ders: [ev] control: true - size_bytes: 37878 - sha256: "55380843ce8d32ec68a8247f7288c215ebbfa66b94097720e884e5cd04069641" + size_bytes: 38746 + sha256: "c0b1988b8aed68cf4c67034e0247f23133c33f4cba491e98d92131ce2332d578" - name: "esphome-dsmr" version: "1.0.3" tier: community diff --git a/manifests/easee_cloud.yaml b/manifests/easee_cloud.yaml index 0a619e9..8f5c10e 100644 --- a/manifests/easee_cloud.yaml +++ b/manifests/easee_cloud.yaml @@ -16,9 +16,9 @@ tested_devices: notes: "Easee Home/Charge via Cloud REST API. No local protocol needed." min_driver_version: "1.0.1" min_host_version: "2.0.0" -size_bytes: 37878 +size_bytes: 38746 dkb_id: "easee_cloud" -sha256: "55380843ce8d32ec68a8247f7288c215ebbfa66b94097720e884e5cd04069641" +sha256: "c0b1988b8aed68cf4c67034e0247f23133c33f4cba491e98d92131ce2332d578" signature: "" bytecode_sha256: ""