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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ Driver versions follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html

### Changed

- **`easee_cloud` 1.3.0** — verify an active energy session before core can retain a confirmed battery level across restart. Keep that identity through pauses in the same driver process, but clear it on completion until a new active session proves its identity. An offline car may need its battery level confirmed after completion or restart; do not reuse another car's level. Match equivalent UTC timestamp formats from both session APIs. Failed or empty observations emit no telemetry. Invalid session payloads omit the identity while keeping fresh charger readings.

- **easee_cloud** 1.2.0 — emit `request_active`: false only when the vehicle
side has explicitly stopped requesting current (Easee `reasonForNoCurrent`
50, or `op_mode` 4 "completed"); every box-ordered pause (52/53/100, pending
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.2.0 | ftw-core | not_assessed | — | — | not_recorded | — | not_assessed | no |
| easee_cloud | 1.2.0 | blixt-l1 | not_assessed | — | — | not_recorded | — | not_assessed | no |
| easee_cloud | 1.3.0 | ftw-core | not_assessed | — | — | not_recorded | — | not_assessed | no |
| easee_cloud | 1.3.0 | 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.2.0"
version: "1.3.0"
ders: [ev]
control: true
firmware_versions: ""
Expand Down
81 changes: 78 additions & 3 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.2.0",
version = "1.3.0",
protocols = { "http" },
capabilities = { "ev" },
description = "Easee Home/Charge via Cloud REST API. No local protocol needed.",
Expand Down Expand Up @@ -277,25 +277,99 @@ local OBS_SESSION_ENERGY = 121
local OBS_LIFETIME_ENERGY = 124
local OBS_CURRENT = 183
local OBS_VOLTAGE = 194
local OBS_SESSION_START = 223

local OBS_IDS = "48,96,103,109,120,121,124,183,194"
local OBS_IDS = "48,96,103,109,120,121,124,183,194,223"

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
local decoded = safe_json_decode(resp)
if not decoded then return nil, "decode failed" end
if type(decoded) ~= "table" then return nil, "decode failed" end
local list = decoded.observations or decoded
local obs = {}
for _, item in ipairs(list) do
if item.id then
obs[item.id] = tonumber(item.value) or item.value
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 >= 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
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
-- 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

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
Comment thread
frahlg marked this conversation as resolved.

local function current_session_id(obs, op_mode)
if op_mode == 0 or op_mode == 1 or op_mode == 4 then
validated_session_id = nil
observed_session_id = nil
return nil
end
local raw = obs[OBS_SESSION_START]
local session = type(raw) == "table" and raw or nil
if type(raw) == "string" then session = safe_json_decode(raw) end
if type(session) ~= "table" then return nil end
local id = tonumber(session.Id)
Comment thread
frahlg marked this conversation as resolved.
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
observed_session_id = identity
validated_session_id = nil
end
if validated_session_id == identity then return identity end
Comment thread
frahlg marked this conversation as resolved.
-- 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

-- The vendor allows ten requests per hour, not one per normal poll.
local now = host.millis()
local recent = {}
for _, at in ipairs(session_lookups_ms) do
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
return nil
end
last_session_lookup_ms = now
table.insert(session_lookups_ms, now)
local body, err = safe_http_get(BASE_URL .. "/chargers/" .. charger_serial .. "/sessions/ongoing", auth_headers())
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
validated_session_id = identity
return identity
end

-- ---- State mapping ----
-- Easee chargerOpMode (observation 109):
-- 1 = disconnected (standby)
Expand Down Expand Up @@ -570,6 +644,7 @@ function driver_poll()
charging = charging,
request_active = request_active,
session_wh = session_wh,
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",
reason_no_current = reason_code, -- int: 0=ok; why NOT drawing current
Expand Down
119 changes: 119 additions & 0 deletions drivers/tests/lua_harness/test_easee_cloud_session.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
dofile("drivers/tests/lua_harness/host_mock.lua")
local driver = "drivers/lua/easee_cloud.lua"
local serial = "TEST123"
local start = "2026-01-01T08:00:00.000Z"
local function boot(ended)
host.reset()
host._http_responses["/accounts/login"] = '{"accessToken":"test","expiresIn":3600}'
host._http_responses["/config"] = '{}'
host._http_responses["/sessions/ongoing"] = host.json_encode({sessionId=100,sessionStart="2026-01-01T08:00:00Z",sessionEnd=ended and "2026-01-01T09:00:00Z" or nil})
dofile(driver)
driver_init({email="test@example.invalid",password="test",serial=serial})
end
local function poll(mode, session, energy, lifetime)
local obs = {
{id=109,value=mode}, {id=120,value=mode == 3 and 4.3 or 0},
{id=121,value=energy or 9}, {id=124,value=lifetime or 1009},
}
if session ~= nil then table.insert(obs,{id=223,value=session}) end
host._http_responses["/observations?ids="] = host.json_encode(obs)
driver_poll()
local rows=host._emitted.ev
assert(rows and #rows>0, "no EV sample")
return rows[#rows]
end
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")
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")
boot(true)
assert(poll(3,current).session_id == nil,"closed API session passed as active")
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
local 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==1,"validated session repeatedly queried the rate-limited API")
local next = host.json_encode({Id=101,Start="2026-01-02T08:00:00.000Z",MeterValue=1009})
host._millis_counter = host._millis_counter + 60000
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 session did not get its own verified identity")
assert(poll(1,current).session_id == nil,"disconnected charger has session identity")
assert(poll(3,current,1,1010).session_id == nil,"a prior session bypassed active-session confirmation")
assert(poll(3,nil).session_id == nil,"missing identity invented")
assert(poll(3,'not-json').session_id == nil,"malformed identity accepted")
assert(poll(3,'{"Id":100,"Start":"yesterday","MeterValue":1000}').session_id == nil,"invalid session time accepted")

local before = #host._emitted.ev
host._http_responses["/observations?ids="] = '{}'
driver_poll()
assert(#host._emitted.ev == before,"empty observations invented an unplug")
host._http_responses["/observations?ids="] = 'not-json'
driver_poll()
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
host._millis_counter = host._millis_counter + 61000
assert(poll(3,current).session_id == nil,"ended 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()
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")
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(3,next,1,1010).session_id == "101:2026-01-02T08:00:00Z","new active session did not regain proof")

-- The two APIs may encode the same UTC instant differently.
for _, pair in ipairs({
{observation="2026-01-01T08:00:00.000+00:00", ongoing="2026-01-01T08:00:00Z"},
{observation="2026-01-01T08:00:00Z", ongoing="2026-01-01T08:00:00.000+00:00"},
}) do
boot()
host._http_responses["/sessions/ongoing"] = host.json_encode({sessionId=100,sessionStart=pair.ongoing})
local observation = host.json_encode({Id=100,Start=pair.observation})
assert(poll(3,observation).session_id == canonical,"equivalent UTC times did not match")
end
boot()
host._http_responses["/sessions/ongoing"] = host.json_encode({sessionId=100,sessionStart="2026-01-01T08:00:01.000+00:00"})
assert(poll(3,current).session_id == nil,"different start times passed session proof")

-- Valid JSON can still have the wrong shape. Keep fresh charger readings
-- when either session endpoint sends a scalar or a document without an ID.
for _, payload in ipairs({"true", "false", "42", '"error"', "null", "[]", "{}"}) do
boot()
local sample = poll(3, payload)
assert(sample.session_id == nil, "wrong-shaped observation accepted: " .. payload)
assert(sample.connected and sample.w == 4300, "session payload dropped fresh charger readings")

boot()
host._http_responses["/sessions/ongoing"] = payload
sample = poll(3, current)
assert(sample.session_id == nil, "wrong-shaped ongoing session accepted: " .. payload)
assert(sample.connected and sample.w == 4300, "ongoing payload dropped fresh charger readings")
end
print("Easee current-session identity: passed")
12 changes: 12 additions & 0 deletions drivers/tests/test_easee_cloud_session.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
"""A saved battery level needs the current hardware session, not its history."""
from pathlib import Path
import subprocess


def test_easee_cloud_current_session_identity():
root = Path(__file__).resolve().parents[2]
result = subprocess.run(
[str(root / "lua55"), "drivers/tests/lua_harness/test_easee_cloud_session.lua"],
cwd=root, text=True, capture_output=True, check=False,
)
assert result.returncode == 0, result.stdout + result.stderr
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.2.0"
version: "1.3.0"
tier: core
protocol: http
connectivity: cloud
setup: [vendor_portal]
ders: [ev]
control: true
size_bytes: 32848
sha256: "ea3c8b158ebef1ad2f8a3f94a82e2826438231e8fe04c5957546ee836b370ec8"
size_bytes: 36344
sha256: "f127426d39ccf831d132f401d0b7d676efa76568974eed3096b6c4569492ee23"
- name: "esphome-dsmr"
version: "1.0.3"
tier: community
Expand Down
16 changes: 13 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.2.0"
version: "1.3.0"
tier: core
author: "Sourceful Labs AB"
protocol: http
Expand All @@ -16,9 +16,19 @@ 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: 32848
size_bytes: 36344
dkb_id: "easee_cloud"
sha256: "ea3c8b158ebef1ad2f8a3f94a82e2826438231e8fe04c5957546ee836b370ec8"
sha256: "f127426d39ccf831d132f401d0b7d676efa76568974eed3096b6c4569492ee23"
signature: ""

bytecode_sha256: ""

upstream_docs:
- url: "https://developer.easee.com/docs/charger-observation-ids"
title: "Easee charger observations"
kind: api_docs
url_stability: stable
- url: "https://developer.easee.com/reference/chargers_getongoingsessiondetails"
title: "Easee ongoing session API"
kind: api_docs
url_stability: stable
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.2.0",
"catalog_version": "1.3.0",
"driver_id": "easee_cloud",
"package_id": null,
"targets": {
Expand Down