diff --git a/CHANGELOG.md b/CHANGELOG.md index a7b9417..291098d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## easee_cloud 1.3.2 + +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/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..3bc019a 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.", @@ -282,44 +282,59 @@ 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 = {} 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 + invalidate_session_proof() return nil, "missing charger state" 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 + 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 --- identity through pauses in this driver process until a completed session. --- Completion or a fresh process needs active 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 -local validated_session_id = nil -local observed_session_id = nil -local session_lookups_ms = {} -local last_session_lookup_ms = nil - +-- https://developer.easee.com/docs/api-command-and-control 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 @@ -327,7 +342,10 @@ 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 + 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 observed_session_id = nil return nil @@ -340,14 +358,15 @@ 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 ~= observed_session_id then + if identity == departed_session_id then return nil end + local identity_changed = identity ~= observed_session_id + if identity_changed then observed_session_id = identity 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() @@ -356,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 @@ -365,8 +387,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 @@ -562,7 +583,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 +600,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 +673,13 @@ function driver_poll() charging = charging, 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], 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..b5ea64e 100644 --- a/drivers/tests/lua_harness/test_easee_cloud_session.lua +++ b/drivers/tests/lua_harness/test_easee_cloud_session.lua @@ -26,15 +26,17 @@ 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") 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(3,current).session_id == nil,"closed API session passed as active") +assert(poll(2,current).session_id == canonical,"sessionEnd during a pause blocked restoration") +boot(true) +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 @@ -63,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) -for i=1,20 do +boot() +host._http_responses["/sessions/ongoing"] = host.json_encode({sessionId=99,sessionStart="2026-01-01T08:00:00Z"}) +for i=1,59 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 @@ -74,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") @@ -117,3 +119,67 @@ 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.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") +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") + +-- 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 86c2415..3927d0b 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: 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 335569f..8f5c10e 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: 38746 dkb_id: "easee_cloud" -sha256: "e28ae3a3805d8d13848dc6d9ce3f0f1c3ed51d9354bdd4d3329f323a02a36d27" +sha256: "c0b1988b8aed68cf4c67034e0247f23133c33f4cba491e98d92131ce2332d578" 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": {