Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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/).
Expand Down
4 changes: 2 additions & 2 deletions SUPPORT_STATUS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
2 changes: 1 addition & 1 deletion devices.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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: ""
Expand Down
87 changes: 61 additions & 26 deletions drivers/lua/easee_cloud.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down Expand Up @@ -282,52 +282,70 @@ 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
return (value:gsub("%+00:00$", "Z"):gsub("%.0+Z$", "Z"))
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
Expand All @@ -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.
Comment on lines +368 to +369

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve lookup quota until charging can verify the session

Because modes 2, 4, and 6 now enter this lookup path unconditionally, a driver that boots while paused with temporarily mismatched observation and /sessions/ongoing data retries once per minute and consumes all ten hourly requests in about nine minutes. If charging then starts after the tenth attempt, the existing #recent >= 10 guard prevents session verification for roughly another 50 minutes, so Core receives no session identity for most or all of that charge. Spread retries across the hour or reserve an attempt for a transition to active charging.

Useful? React with 👍 / 👎.


-- The vendor allows ten requests per hour, not one per normal poll.
local now = host.millis()
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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]
Expand Down Expand Up @@ -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",
Expand Down
96 changes: 81 additions & 15 deletions drivers/tests/lua_harness/test_easee_cloud_session.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -63,28 +65,28 @@ 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
if call.func=="http_get" and call.args[1]:find("/sessions/ongoing",1,true) then lookups=lookups+1 end
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")

Expand Down Expand Up @@ -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")
6 changes: 3 additions & 3 deletions index.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions manifests/easee_cloud.yaml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
name: "easee_cloud"
version: "1.3.1"
version: "1.3.2"
tier: core
author: "Sourceful Labs AB"
protocol: http
Expand All @@ -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: ""
Expand Down
2 changes: 1 addition & 1 deletion support-status.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down