diff --git a/CHANGELOG.md b/CHANGELOG.md index bac5784..8e36f50 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/SUPPORT_STATUS.md b/SUPPORT_STATUS.md index a88e8a2..b5f5751 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 | | 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 | diff --git a/devices.yaml b/devices.yaml index 445a41b..4cb0c31 100644 --- a/devices.yaml +++ b/devices.yaml @@ -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: "" diff --git a/docs/WRITING-A-DRIVER.md b/docs/WRITING-A-DRIVER.md index 0efa6ce..90d76be 100644 --- a/docs/WRITING-A-DRIVER.md +++ b/docs/WRITING-A-DRIVER.md @@ -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. diff --git a/drivers/lua/easee_cloud.lua b/drivers/lua/easee_cloud.lua index 1f5ced9..86abaef 100644 --- a/drivers/lua/easee_cloud.lua +++ b/drivers/lua/easee_cloud.lua @@ -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.", @@ -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]) @@ -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 @@ -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 @@ -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, @@ -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) diff --git a/drivers/tests/lua_harness/test_easee_cloud_pilot_freshness.lua b/drivers/tests/lua_harness/test_easee_cloud_pilot_freshness.lua new file mode 100644 index 0000000..d59f080 --- /dev/null +++ b/drivers/tests/lua_harness/test_easee_cloud_pilot_freshness.lua @@ -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") diff --git a/drivers/tests/test_easee_cloud_pilot_freshness.py b/drivers/tests/test_easee_cloud_pilot_freshness.py new file mode 100644 index 0000000..fb035b2 --- /dev/null +++ b/drivers/tests/test_easee_cloud_pilot_freshness.py @@ -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 diff --git a/index.yaml b/index.yaml index 88407a6..4ce50b5 100644 --- a/index.yaml +++ b/index.yaml @@ -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 diff --git a/manifests/easee_cloud.yaml b/manifests/easee_cloud.yaml index 5a0f11c..497d32c 100644 --- a/manifests/easee_cloud.yaml +++ b/manifests/easee_cloud.yaml @@ -1,5 +1,5 @@ name: "easee_cloud" -version: "1.4.1" +version: "1.5.0" 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: 72644 +size_bytes: 73683 dkb_id: "easee_cloud" -sha256: "e4b82cf453816e251dbd28b02d4944965e30df3f2913bf4d945b4d11850db682" +sha256: "7c976ddc2829431455ec940079a69c22cb9d9fc59625001aaf93b52c5129656a" signature: "" bytecode_sha256: "" diff --git a/spec/host-api-profile.json b/spec/host-api-profile.json index 7550b2b..59af5bc 100644 --- a/spec/host-api-profile.json +++ b/spec/host-api-profile.json @@ -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.", diff --git a/support-status.json b/support-status.json index 2c8268f..aed2da4 100644 --- a/support-status.json +++ b/support-status.json @@ -443,7 +443,7 @@ }, { "catalog_source": true, - "catalog_version": "1.4.1", + "catalog_version": "1.5.0", "driver_id": "easee_cloud", "targets": { "blixt-l1": {