diff --git a/CHANGELOG.md b/CHANGELOG.md index acfbfbd..9205608 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,18 @@ Driver versions follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html ### Changed +- **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 + authorization, schedules, fuse limits) stays true. Lets the FTW host tell + "the car declined" from "we paused it": the session-completion latch stops + the planner allocating energy to a full car, a manual Start hold + auto-releases instead of offering power all night, and the + charging-interrupted notification stops firing on the car's own + renegotiation bursts. Field-observed 2026-08-29: a car at its own charge + limit held reason 50 overnight while the box kept offering 11 kW and paged + the operator twice. + - **zap** 3.1.0 — P1/HAN remains the default. `read_pv` and `read_battery` are opt-in, read-only ingest of devices Zap already talks to (closed inverter Modbus, or an RS-485 bus Zap owns). Off by default so a native FTW driver is not doubled. The driver still never writes. Chargers stay out: add those in FTW. - **`nibe_local` 1.1.3** — heat-pump diagnostic metrics convert vendor kW/kWh to W/Wh at emit (case and surrounding spaces folded, so `kW ` still converts). Headline names `hp_energy_consumed_kwh` and `hp_energy_produced_kwh` stay so existing series keys do not move; the unit field is Wh. `DRIVER.read_only = true` so the signed artifact matches the observe-only command path. HTTP GET and JSON decode wrap in `pcall`. - **`myuplink` 1.2.1** — bulk kW/kWh points and the `hp_power_w` headline convert to W/Wh at emit. There are no `hp_energy_*_kwh` headlines; energy, if the pump reports it, is a sanitized bulk name with unit Wh. diff --git a/SUPPORT_STATUS.md b/SUPPORT_STATUS.md index 6d8c714..531ced2 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.1.1 | ftw-core | not_assessed | — | — | not_recorded | — | not_assessed | no | -| easee_cloud | 1.1.1 | 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 | | 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 fddf0d1..6780733 100644 --- a/devices.yaml +++ b/devices.yaml @@ -349,7 +349,7 @@ manufacturers: protocols: - protocol: http driver: "easee_cloud" - version: "1.1.1" + version: "1.2.0" ders: [ev] control: true firmware_versions: "" diff --git a/drivers/lua/easee_cloud.lua b/drivers/lua/easee_cloud.lua index 912513d..75a7fe4 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.0.1", + version = "1.2.0", protocols = { "http" }, capabilities = { "ev" }, description = "Easee Home/Charge via Cloud REST API. No local protocol needed.", @@ -181,6 +181,22 @@ local function redact_http_err(err) return tostring(err):match("^(HTTP %d+)") or "request failed" end +-- Wrapped transport. A host whose http_get raises, or a vendor API +-- that answers with garbage, must degrade to (nil, err) — never kill +-- the driver mid-poll. +local function safe_http_get(url, headers) + local ok, resp, err = pcall(host.http_get, url, headers) + if not ok then return nil, tostring(resp) end + return resp, err +end + +local function safe_json_decode(s) + if s == nil then return nil end + local ok, data = pcall(host.json_decode, s) + if not ok then return nil end + return data +end + -- ---- Auth helpers ---- local function login(email, password) @@ -190,7 +206,7 @@ local function login(email, password) host.log("error", "Easee login failed: " .. redact_http_err(err)) return false end - local data = host.json_decode(resp) + local data = safe_json_decode(resp) if not data or not data.accessToken then host.log("error", "Easee login: no accessToken in response") return false @@ -215,7 +231,7 @@ local function do_refresh() host.log("warn", "Easee token refresh failed: " .. redact_http_err(err)) return false end - local data = host.json_decode(resp) + local data = safe_json_decode(resp) if not data or not data.accessToken then host.log("warn", "Easee refresh: no accessToken, will re-login") return false @@ -246,9 +262,9 @@ end -- ---- API helpers ---- local function get_chargers() - local resp, err = host.http_get(BASE_URL .. "/chargers", auth_headers()) + local resp, err = safe_http_get(BASE_URL .. "/chargers", auth_headers()) if err then return nil, err end - return host.json_decode(resp), nil + return safe_json_decode(resp), nil end -- Observation IDs (from developer.easee.com/docs/charger-observation-ids) @@ -266,9 +282,9 @@ local OBS_IDS = "48,96,103,109,120,121,124,183,194" local function get_observations(serial) local url = "https://api.easee.com/state/" .. serial .. "/observations?ids=" .. OBS_IDS - local resp, err = host.http_get(url, auth_headers()) + local resp, err = safe_http_get(url, auth_headers()) if err then return nil, err end - local decoded = host.json_decode(resp) + local decoded = safe_json_decode(resp) if not decoded then return nil, "decode failed" end local list = decoded.observations or decoded local obs = {} @@ -355,9 +371,9 @@ local email, password, configured_max_a -- (compare requested phaseMode after a write) is a follow-up; this -- driver's contribution is the diagnostic logging at init. local function read_settings(serial) - local resp, err = host.http_get(BASE_URL .. "/chargers/" .. serial .. "/config", auth_headers()) + local resp, err = safe_http_get(BASE_URL .. "/chargers/" .. serial .. "/config", auth_headers()) if err then return nil, redact_http_err(err) end - local decoded = host.json_decode(resp) + local decoded = safe_json_decode(resp) if decoded == nil then return nil, "decode failed (non-JSON response)" end @@ -531,10 +547,28 @@ function driver_poll() command_stalled_since_ms = 0 end + -- request_active: false ONLY when the vehicle side has explicitly + -- stopped requesting current — Easee reason 50 ("secondary unit + -- not requesting current"; the vehicle is the secondary unit) or + -- op_mode 4 ("completed"). Every other reason (52/53/100 = the box + -- throttled it, pending authorization, schedules, fuse limits) + -- keeps the default true, so a pause the box itself ordered is + -- never mistaken for the car declining. The host debounces this + -- for 90 s before acting on it (session-completion latch, + -- manual-hold auto-release), so a brief 50 during session + -- handshake is harmless. Field observation 2026-08-29: a car at + -- its own charge limit held reason 50 all night while the box kept + -- offering 11 kW and paged the operator twice about it. + local request_active = true + if connected and not charging and (reason_code == 50 or op_mode == 4) then + request_active = false + end + host.emit("ev", { w = power_w, connected = connected, charging = charging, + request_active = request_active, session_wh = session_wh, 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/index.yaml b/index.yaml index 2692363..4b56577 100644 --- a/index.yaml +++ b/index.yaml @@ -193,15 +193,15 @@ drivers: size_bytes: 4054 sha256: "4a2cd1efb4a5583468ce897ebd88a000e348917295c40210e86ecf834c0ba543" - name: "easee_cloud" - version: "1.1.1" + version: "1.2.0" tier: core protocol: http connectivity: cloud setup: [vendor_portal] ders: [ev] control: true - size_bytes: 31312 - sha256: "dfe3107d6d99e03204184602fd2be973efb53b7f122d4ad1dd3cbe1d0be5ebd4" + size_bytes: 32848 + sha256: "ea3c8b158ebef1ad2f8a3f94a82e2826438231e8fe04c5957546ee836b370ec8" - name: "esphome-dsmr" version: "1.0.3" tier: community diff --git a/manifests/easee_cloud.yaml b/manifests/easee_cloud.yaml index b09ff86..df34bf3 100644 --- a/manifests/easee_cloud.yaml +++ b/manifests/easee_cloud.yaml @@ -1,5 +1,5 @@ name: "easee_cloud" -version: "1.1.1" +version: "1.2.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: 31312 +size_bytes: 32848 dkb_id: "easee_cloud" -sha256: "dfe3107d6d99e03204184602fd2be973efb53b7f122d4ad1dd3cbe1d0be5ebd4" +sha256: "ea3c8b158ebef1ad2f8a3f94a82e2826438231e8fe04c5957546ee836b370ec8" signature: "" bytecode_sha256: "" diff --git a/support-status.json b/support-status.json index b2bfabc..948a218 100644 --- a/support-status.json +++ b/support-status.json @@ -590,7 +590,7 @@ }, { "catalog_source": true, - "catalog_version": "1.1.1", + "catalog_version": "1.2.0", "driver_id": "easee_cloud", "package_id": null, "targets": {