Skip to content
Draft
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
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,17 @@

## [Unreleased]

### easee_cloud 1.5.0

Expose the actual pilot current and its source/change timestamp separately
from the dynamic current setting. Record a fresh confirmation only when
that observation is accepted from REST or SignalR; a poll of other values
does not refresh the pilot. Reject unusable, future or expired pilot and
allocation evidence before it can explain a charging shortfall. Unchanged
pilot values may retain an old source timestamp when freshly confirmed.
Paired Core feedback changes distinguish a proven load-balancing limit
from an otherwise unexplained pilot limit (srcfl/ftw#1874).

### easee_cloud 1.4.1

Report when Easee's own load balancing holds the car below FTW's order. A
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 |
| 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.4.1 | ftw-core | not_assessed | — | not_recorded | not_assessed |
| easee_cloud | 1.4.1 | blixt-l1 | not_assessed | — | not_recorded | not_assessed |
| easee_cloud | 1.5.0 | ftw-core | not_assessed | — | not_recorded | not_assessed |
| easee_cloud | 1.5.0 | 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 |
Expand Down
2 changes: 1 addition & 1 deletion devices.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -338,7 +338,7 @@ manufacturers:
protocols:
- protocol: http
driver: "easee_cloud"
version: "1.4.1"
version: "1.5.0"
ders: [ev]
control: true
firmware_versions: ""
Expand Down
11 changes: 11 additions & 0 deletions docs/WRITING-A-DRIVER.md
Original file line number Diff line number Diff line change
Expand Up @@ -450,3 +450,14 @@ for power calculated from the inverter's own battery, PV or load readings.
Use `power_origin="derived"` for such calculated meter values. Failed reads
must set `control_power_available=false`; sensor identity alone cannot
confirm a command.

### Effective EV pilot evidence

`pilot_a` is the current physically signalled to the vehicle, including zero;
`dynamic_limit_a` is the separately read back dynamic setting. Keep `max_a`
for older hosts. `pilot_observed_at` is the vendor RFC 3339 value-change time;
`pilot_confirmed_at_ms` is the Unix-millisecond time a successful REST read or
stream update confirmed that specific value (zero when unavailable). Do not
refresh that confirmation merely because another observation or poll arrived.
An unchanged old source value may still be confirmed by a new read. A missing,
invalid, future-dated or unconfirmed pilot must not explain a control failure.
25 changes: 21 additions & 4 deletions drivers/lua/easee_cloud.lua
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ DRIVER = {
id = "easee_cloud",
name = "Easee Cloud",
manufacturer = "Easee",
version = "1.4.1",
version = "1.5.0",
protocols = { "http" },
capabilities = { "ev" },
description = "Easee Home/Charge via Cloud REST API. No local protocol needed.",
Expand Down Expand Up @@ -360,6 +360,8 @@ end
-- cannot hold a value. The same instant keeps its first spelling, so a
-- source time never looks new because the other source wrote it
-- differently. Returns true when REST brought a newer, different value.
local observation_confirmed_ms, observation_confirmed_wall_ms = {}, {}

local function note_observation(id, value, at, pushed)
if not OBS_WANTED[id] then return false end
local new_key, old_key = source_time_key(at), source_time_key(latest_at[id])
Expand All @@ -371,6 +373,8 @@ local function note_observation(id, value, at, pushed)
end
local changed = value ~= latest[id]
latest[id], latest_at[id], latest_pushed[id] = value, at, pushed
observation_confirmed_ms[id] = host.millis()
observation_confirmed_wall_ms[id] = host.unix_ms and host.unix_ms() or nil
return not pushed and newer and changed
end

Expand Down Expand Up @@ -1205,10 +1209,18 @@ function driver_poll()
if dyn_current ~= order_seen_a then order_seen_a, order_seen_ms = dyn_current, now end
local order_at = source_time_key(timestamps[OBS_DYN_CURRENT])
local order_settled = now - order_seen_ms >= ORDER_SETTLE_MS
local function answering(id)
local function confirmed_current(id)
local value, value_at = obs[id], source_time_key(timestamps[id])
if type(value) ~= "number" then return nil end
if order_settled or (value_at and order_at and value_at >= order_at) then return value end
if type(value) ~= "number" or value ~= value or value < 0 or value > 80 or not value_at then return nil end
local confirmed = observation_confirmed_ms[id]
if not confirmed or now - confirmed > REST_FRESH_MS then return nil end
local wall = host.unix_ms and host.unix_ms() or nil
if wall and tonumber(value_at) * 1000 > wall then return nil end
return value, value_at
end
local function answering(id)
local value, value_at = confirmed_current(id)
if value ~= nil and (order_settled or (value_at and order_at and value_at >= order_at)) then return value end
return nil
end
-- The charger must hold FTW's last order: while it reads back an older
Expand Down Expand Up @@ -1245,6 +1257,7 @@ function driver_poll()
current_limit_a, current_limited_by = last_limit_a, last_limited_by
end

local raw_pilot = confirmed_current(OBS_OUTPUT_CURRENT)
local limit_age_s = device_limit_read_ms and (host.millis() - device_limit_read_ms) / 1000
host.emit("ev", {
w = power_w,
Expand Down Expand Up @@ -1277,6 +1290,10 @@ function driver_poll()
device_limit_age_s = limit_age_s,
current_limit_a = current_limit_a, -- A signalled to the car, below the order
current_limited_by = current_limited_by, -- "load_balancer" or nil
pilot_a = raw_pilot,
pilot_observed_at = raw_pilot ~= nil and timestamps[OBS_OUTPUT_CURRENT] or nil,
pilot_confirmed_at_ms = raw_pilot ~= nil and observation_confirmed_wall_ms[OBS_OUTPUT_CURRENT] or 0,
dynamic_limit_a = dyn_current,
max_a = dyn_current, -- last-set dynamic limit (echoes our write, may lag)
actual_amps_per_phase = actual_amps_per_phase, -- live per-phase A derived from totalPower
phases = phases, -- our committed phase count (1 or 3)
Expand Down
99 changes: 99 additions & 0 deletions drivers/tests/lua_harness/test_easee_cloud_pilot_freshness.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
dofile("drivers/tests/lua_harness/host_mock.lua")
-- Easee's own load balancing can hold the car below FTW's order. Field
-- trace (srcfl/ftw#1763, 23 Oct 2026, UTC): FTW ordered 15 A and the
-- charger read back 15 A (observation 48), while the circuit's allocated
-- current (70-72) and the current signalled to the car (114) were 12 A at
-- the start, then 14 A and 13 A; the car took 8.2-8.9 kW, not 10.4 kW.
local driver = "drivers/lua/easee_cloud.lua"
local RS = "\30"
local SERIAL = "EH123456"

local function later(ms) host._millis_counter = host._millis_counter + ms end

local function boot(extra, ws, config_body)
host.reset()
host._millis_step = 0
host._ws_granted = ws or false
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 cfg = {email="test@example.invalid", password="test", serial=SERIAL}
for k, v in pairs(extra or {}) do cfg[k] = v end
driver_init(cfg)
end

-- order sends FTW's order, as Core does every tick; amps per phase.
local function order(a, phase_mode)
local p = phase_mode == "1p" and 1 or 3
assert(driver_command("ev_set_current", a * 230 * p,
{phase_mode=phase_mode or "3p", voltage=230, max_amps_per_phase=25, cap_amps_per_phase=25}))
end

-- The trace crosses midnight UTC: 23:xx is 22 October, the rest 23 October.
local function at(clock)
return (clock:sub(1, 2) == "23" and "2026-10-22T" or "2026-10-23T") .. clock .. "Z"
end

-- obs builds a REST reply. o.alloc is {L1, L2, L3}; false leaves a value out.
local function obs(o)
local list = {
{id=109, value=o.mode or 3, timestamp=at("23:53:00")},
{id=120, value=o.kw or 8.3, timestamp=at(o.power_at or "23:54:30")},
{id=121, value=1.5, timestamp=at(o.power_at or "23:54:30")},
}
if o.order ~= false then
table.insert(list, {id=48, value=o.order or 15, timestamp=at(o.order_at or "23:50:00")})
end
if o.pilot ~= false then
table.insert(list, {id=114, value=o.pilot or 12, timestamp=at(o.pilot_at or "23:54:22")})
end
local alloc = o.alloc == nil and {12, 12, 12} or o.alloc
for i = 1, 3 do
if alloc and alloc[i] ~= false and alloc[i] ~= nil then
table.insert(list, {id=69 + i, value=alloc[i], timestamp=at(o.alloc_at or o.pilot_at or "23:54:22")})
end
end
if o.reason then table.insert(list, {id=96, value=o.reason, timestamp=at(o.reason_at)}) end
return list
end

local function poll(o)
host._http_responses["/observations?ids="] = host.json_encode(obs(o or {}))
driver_poll()
local rows = host._emitted.ev or {}
return rows[#rows], #rows
end

local function expect(ev, limit, by, what)
assert(ev, what .. ": no EV sample")
assert(ev.current_limit_a == limit and ev.current_limited_by == by,
string.format("%s: got %s/%s, want %s/%s", what, tostring(ev.current_limit_a),
tostring(ev.current_limited_by), tostring(limit), tostring(by)))
end


boot()
host.unix_ms = function() return 1792800000000 + host._millis_counter end
order(15)
local e = poll()
assert(e.pilot_a == 12 and e.dynamic_limit_a == 15, "pilot and dynamic setting must stay distinct")
assert(e.pilot_confirmed_at_ms == host.unix_ms(), "specific pilot confirmation missing")
local original = e.pilot_observed_at
later(61000)
e = poll()
assert(e.pilot_observed_at == original and e.pilot_confirmed_at_ms == host.unix_ms(), "unchanged pilot must be reconfirmed by REST")
later(91000)
e = poll({pilot=false, alloc=false})
assert(e.pilot_a == nil and e.pilot_confirmed_at_ms == 0, "other fresh readings refreshed a missing pilot")
e = poll({pilot=0, pilot_at="23:55:00", kw=0, power_at="23:55:10"})
assert(e.pilot_a == 0 and e.current_limit_a == nil, "zero pilot must not invent an explained limitation")
local list = obs({power_at="23:56:10"})
for _, o in ipairs(list) do if o.id == 114 then o.timestamp = "2099-01-01T00:00:00Z" end end
host._http_responses["/observations?ids="] = host.json_encode(list)
driver_poll()
e = (host._emitted.ev or {})[#host._emitted.ev]
assert(e.pilot_a == nil, "future pilot emitted as usable evidence")
print("pilot freshness and separate setting: passed")
7 changes: 7 additions & 0 deletions drivers/tests/test_easee_cloud_pilot_freshness.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
from pathlib import Path
import subprocess

def test_easee_cloud_pilot_freshness():
root = Path(__file__).resolve().parents[2]
result = subprocess.run([str(root / "lua55"), "drivers/tests/lua_harness/test_easee_cloud_pilot_freshness.lua"], cwd=root, capture_output=True, text=True)
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.4.1"
version: "1.5.0"
tier: core
protocol: http
connectivity: cloud
setup: [vendor_portal]
ders: [ev]
control: true
size_bytes: 72644
sha256: "e4b82cf453816e251dbd28b02d4944965e30df3f2913bf4d945b4d11850db682"
size_bytes: 73683
sha256: "7c976ddc2829431455ec940079a69c22cb9d9fc59625001aaf93b52c5129656a"
- name: "esphome_dsmr"
version: "1.0.7"
tier: core
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.4.1"
version: "1.5.0"
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: 72644
size_bytes: 73683
dkb_id: "easee_cloud"
sha256: "e4b82cf453816e251dbd28b02d4944965e30df3f2913bf4d945b4d11850db682"
sha256: "7c976ddc2829431455ec940079a69c22cb9d9fc59625001aaf93b52c5129656a"
signature: ""

bytecode_sha256: ""
Expand Down
6 changes: 5 additions & 1 deletion spec/host-api-profile.json
Original file line number Diff line number Diff line change
Expand Up @@ -216,7 +216,11 @@
"device_limit_a",
"device_limit_age_s",
"current_limit_a",
"current_limited_by"
"current_limited_by",
"pilot_a",
"pilot_observed_at",
"pilot_confirmed_at_ms",
"dynamic_limit_a"
]
},
"repeating_structures": "A repeating structure is a plural-named array, not numbered keys. pv.mppts is a list of {V, A, W} whose length is what the device physically has. The catalog's mppt1_v/mppt2_v cannot represent a four-MPPT inverter at all.",
Expand Down
2 changes: 1 addition & 1 deletion support-status.json
Original file line number Diff line number Diff line change
Expand Up @@ -443,7 +443,7 @@
},
{
"catalog_source": true,
"catalog_version": "1.4.1",
"catalog_version": "1.5.0",
"driver_id": "easee_cloud",
"targets": {
"blixt-l1": {
Expand Down
Loading