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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
# Changelog

## easee_cloud 1.3.7

- Keep a load-balancing ReasonForNoCurrent while the car charges, when the per-phase current is at least 2 A below the offer. Easee records the reason when it changes, so a limit that began before the last power change was dropped and FTW said the charger had given no reason. The offer is observation 48, or FTW's last write when that is missing. Other old reasons are still dropped while the car charges.
- Report `current_limited_by = "load_balancer"` for codes 1–5, 10 and 25–30 while a car is connected. Core reads this name, not Easee's codes. The charger's own limits (77, 78) and the car are not the load balancer.
- Match the labels for codes 6, 8–11, 25–30 and 77 to Easee's published table. Code 28 read "fuse limit reached"; Easee calls it "Current limited by Equalizer". The enumeration page joins `upstream_docs`.
- Not yet seen on hardware. Easee's table defines the "Current limited by …" codes, but no raw observation from a load-balanced charger has been captured.

## Tests: no invented numbers

- `drivers/tests/lua_harness/no_invented_numbers.lua` runs a driver twice with different registers, config and clock, then makes every read fail and every Modbus reply short. A number the driver still emits that is equal in both runs and was not read is invented: 0 W, 0 A, 0 % SoC, 50 Hz. A failed read must leave the value nil, so the host sends null. Sourceful's driver registry runs the same file on every publish.
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.3.6 | ftw-core | not_assessed | — | not_recorded | not_assessed |
| easee_cloud | 1.3.6 | blixt-l1 | not_assessed | — | not_recorded | not_assessed |
| easee_cloud | 1.3.7 | ftw-core | not_assessed | — | not_recorded | not_assessed |
| easee_cloud | 1.3.7 | 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.3.6"
version: "1.3.7"
ders: [ev]
control: true
firmware_versions: ""
Expand Down
5 changes: 5 additions & 0 deletions docs/WRITING-A-DRIVER.md
Original file line number Diff line number Diff line change
Expand Up @@ -399,6 +399,11 @@ all modes. They never grant control or replace a safety limit:
from the dynamic offer in `max_a`. `device_limit_age_s` is time since the
successful settings read. Failed reads must not reset its age. Core stops
treating it as a current limit after two minutes.
- `current_limited_by`: `"load_balancer"` when the charger says its own
load balancing, a separate load-balancing unit or a partner's circuit
limit holds the car below the offer or at zero. Leave it nil for the
charger's own limits and for the car. Core reports the car as limited
only when the measured power also falls short of its command.

A device reading makes a response measured, nothing more. Core calls it
confirmed only when an identified, separate site meter shows a matching
Expand Down
70 changes: 49 additions & 21 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.6",
version = "1.3.7",
protocols = { "http" },
capabilities = { "ev" },
description = "Easee Home/Charge via Cloud REST API. No local protocol needed.",
Expand Down Expand Up @@ -421,18 +421,18 @@ local REASON_LABELS = {
[3] = "offline fallback circuit current too low",
[4] = "circuit fuse too low",
[5] = "waiting in queue",
[6] = "waiting (other cars fully charged)",
[6] = "waiting in fully charged queue",
[7] = "illegal grid type",
[8] = "no current request from primary",
[9] = "max dynamic charger current too low",
[10] = "phase imbalance",
[11] = "equalizer communication lost",
[25] = "equalizer dynamic limit too low",
[26] = "equalizer static limit too low",
[27] = "offline fallback equalizer too low",
[28] = "fuse limit reached",
[29] = "current limited by equalizer",
[30] = "current limited by offline equalizer",
[8] = "no current request from car",
[9] = "master communication lost",
[10] = "equalizer current too low",
[11] = "phase not connected",
[25] = "current limited by circuit fuse",
[26] = "current limited by circuit max current",
[27] = "current limited by dynamic circuit current",
[28] = "current limited by equalizer",
[29] = "current limited by circuit load balancing",
[30] = "current limited by offline settings",
[50] = "secondary unit not requesting current",
[51] = "max charger current too low",
[52] = "max dynamic charger current too low",
Expand All @@ -443,7 +443,7 @@ local REASON_LABELS = {
[57] = "erratic EV",
[75] = "limited by cable rating",
[76] = "limited by schedule",
[77] = "limited by charger current",
[77] = "limited by charger max current",
[78] = "limited by dynamic charger current",
[79] = "car not drawing current",
[80] = "current ramping",
Expand All @@ -457,6 +457,20 @@ local REASON_LABELS = {
[100] = "EV not accepting current",
}

-- Reasons in which Easee's circuit load balancing, an Equalizer or a
-- partner's dynamic circuit current holds the car back. Easee's table
-- words 25-30 "Current limited by ...", so the car may still charge below
-- the offer. 77 and 78 are the charger's own limits, which Core already
-- reads as device_limit_a and max_a.
local LOAD_BALANCER_REASONS = {
[1] = true, [2] = true, [3] = true, [4] = true, [5] = true, [10] = true,
[25] = true, [26] = true, [27] = true, [28] = true, [29] = true, [30] = true,
}

-- A car may draw a little less than the offer, and the per-phase current
-- below comes from total power and one voltage. A smaller gap shows nothing.
local LIMIT_GAP_A = 2

local email, password, configured_max_a
local device_limit_a, device_limit_read_ms
local settings_poll_ms = 0
Expand Down Expand Up @@ -621,14 +635,6 @@ function driver_poll()
last_power_observed_at = power_observed_at
end

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 All @@ -652,6 +658,27 @@ function driver_poll()
actual_amps_per_phase = power_w / vv / phases
end

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,
-- unless it is a load-balancing limit and the car still draws well
-- below the offer. Easee records the reason only when it changes, so a
-- limit set before the last power change can still hold.
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
local offer = tonumber(dyn_current) or last_amps_set
local held_back = LOAD_BALANCER_REASONS[reason_code] and offer and actual_amps_per_phase and
actual_amps_per_phase < offer - LIMIT_GAP_A
if not held_back then reason_code = nil end
end
-- A vendor-neutral name for Core. Core still compares the measured power
-- with its own command before it reports the car as limited.
local current_limited_by = nil
if connected and LOAD_BALANCER_REASONS[reason_code] then
current_limited_by = "load_balancer"
end

-- command_stalled: true when we've been offering >0 A for >30 s but
-- the charger isn't drawing AND Easee is reporting an EV-side or
-- "max dynamic too low" reason. Lets the controller / UI tell the
Expand Down Expand Up @@ -714,6 +741,7 @@ function driver_poll()
state_label = OP_MODE_LABELS[op_mode] or "unknown",
reason_no_current = reason_code, -- int: 0=ok; why NOT drawing current
reason_no_current_label = reason_code and REASON_LABELS[reason_code], -- nil if 0/ok, string otherwise
current_limited_by = current_limited_by, -- "load_balancer" or nil
is_online = is_online,
cable_locked = cable_locked,
device_limit_a = device_limit_a,
Expand Down
94 changes: 94 additions & 0 deletions drivers/tests/lua_harness/test_easee_cloud_load_balancer.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
dofile("drivers/tests/lua_harness/host_mock.lua")
-- Easee's load balancer can cut a charging car below the offer. The reason
-- stays recorded from when the limit began, which may be before the last
-- power change. A limit that still explains the shortfall must reach Core.
local driver = "drivers/lua/easee_cloud.lua"

local function boot()
host.reset()
host._millis_step = 0
host._http_responses["/accounts/login"] = '{"accessToken":"test","expiresIn":3600}'
host._http_responses["/config"] = '{"maxChargerCurrent":16}'
host._http_responses["/sessions/ongoing"] = '{}'
host._http_responses["/settings"] = '{}'
host._http_responses["/commands/"] = 'null'
dofile(driver)
driver_init({email="test@example.invalid", password="test", serial="TEST123"})
end

-- reason_time before power_time: the reason predates the last power change.
local function poll(mode, power_kw, reason, reason_time, offer_a)
local obs = {
{id=109, value=mode, timestamp="2026-10-08T05:00:00Z"},
{id=120, value=power_kw, timestamp="2026-10-08T05:10:00Z"},
{id=121, value=12.5, timestamp="2026-10-08T05:10:00Z"},
{id=194, value=230, timestamp="2026-10-08T05:00:00Z"},
{id=96, value=reason, timestamp=reason_time},
}
if offer_a ~= nil then table.insert(obs, {id=48, value=offer_a, timestamp="2026-10-08T05:00:00Z"}) 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 OLD, NEW = "2026-10-08T05:05:00Z", "2026-10-08T05:15:00Z"

-- The reported case: 16 A offered, 8.3 kW on three phases is 12 A per phase.
boot()
local cut = poll(3, 8.3, 29, OLD, 16)
assert(cut.reason_no_current == 29, "an active load-balancing limit was dropped while charging")
assert(cut.reason_no_current_label == "current limited by circuit load balancing",
"label does not match Easee's table: " .. tostring(cut.reason_no_current_label))
assert(cut.current_limited_by == "load_balancer", "Core was not told the load balancer limits the car")

-- Easee's Equalizer and a partner's dynamic circuit current are load balancing too.
local eq = poll(3, 8.3, 28, OLD, 16)
assert(eq.current_limited_by == "load_balancer" and eq.reason_no_current_label == "current limited by equalizer",
"an Equalizer limit was not reported")
assert(poll(3, 8.3, 27, OLD, 16).current_limited_by == "load_balancer", "a dynamic circuit limit was not reported")

-- Drawing the whole offer: the old reason no longer explains anything.
local full = poll(3, 11.0, 29, OLD, 16)
assert(full.reason_no_current == nil and full.current_limited_by == nil,
"an old limit was reported while the car drew the whole offer")

-- A gap under 2 A is within what a car and the estimate may differ by.
assert(poll(3, 9.8, 29, OLD, 16).current_limited_by == nil, "a small gap was read as a limit")

-- An old reason that is not load balancing keeps the existing rule.
local other = poll(3, 8.3, 52, OLD, 16)
assert(other.reason_no_current == nil and other.current_limited_by == nil,
"an old non-balancing reason was reported while charging")

-- Without a known offer there is nothing to measure the shortfall against.
boot()
local unknown = poll(3, 8.3, 29, OLD, nil)
assert(unknown.reason_no_current == nil and unknown.current_limited_by == nil,
"an old limit was kept without a known offer")

-- FTW's own last write stands in for a missing offer readback.
assert(driver_command("ev_set_current", 11000, {phase_mode="3p", voltage=230, max_amps_per_phase=16}),
"ev_set_current failed")
local written = poll(3, 8.3, 29, OLD, nil)
assert(written.current_limited_by == "load_balancer", "FTW's last offer was not used as the offer")

-- A reason newer than the last power change holds as before.
boot()
assert(poll(3, 8.3, 29, NEW, 16).current_limited_by == "load_balancer", "a new limit was dropped")

-- No current at all: the load balancer holds a connected car at zero.
local held = poll(2, 0, 2, OLD, 16)
assert(held.reason_no_current == 2 and held.current_limited_by == "load_balancer",
"a car held at zero by the load balancer was not reported")
assert(poll(2, 0, 5, OLD, 16).current_limited_by == "load_balancer", "a load-balancing queue was not reported")

-- Charger and car limits are not the load balancer.
assert(poll(2, 0, 52, OLD, 16).current_limited_by == nil, "a charger limit was named the load balancer")
assert(poll(2, 0, 100, OLD, 16).current_limited_by == nil, "a car refusal was named the load balancer")

-- No car, no limit.
assert(poll(1, 0, 29, NEW, 16).current_limited_by == nil, "an unplugged charger reported a limit")

print("Easee load balancer: passed")
13 changes: 13 additions & 0 deletions drivers/tests/test_easee_cloud_load_balancer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
"""A charging Easee car cut by the load balancer must say so."""
from pathlib import Path
import subprocess


def test_easee_cloud_reports_load_balancer_limit_while_charging():
root = Path(__file__).resolve().parents[2]
result = subprocess.run(
[str(root / "lua55"), "drivers/tests/lua_harness/test_easee_cloud_load_balancer.lua"],
cwd=root, text=True, capture_output=True, check=False,
)
assert result.returncode == 0, result.stdout + result.stderr
assert "Easee load balancer: passed" in result.stdout
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.6"
version: "1.3.7"
tier: core
protocol: http
connectivity: cloud
setup: [vendor_portal]
ders: [ev]
control: true
size_bytes: 41070
sha256: "4f33149c0640c2e9d2c043e36fdf967d8b297f07da56aec934c21e81a397d22b"
size_bytes: 42628
sha256: "8b1b9f980178af96fb897f275fbcd3b6a7f9a8df340f4907a9216f1596c849e4"
- name: "esphome_dsmr"
version: "1.0.7"
tier: core
Expand Down
10 changes: 7 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.6"
version: "1.3.7"
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: 41070
size_bytes: 42628
dkb_id: "easee_cloud"
sha256: "4f33149c0640c2e9d2c043e36fdf967d8b297f07da56aec934c21e81a397d22b"
sha256: "8b1b9f980178af96fb897f275fbcd3b6a7f9a8df340f4907a9216f1596c849e4"
signature: ""

bytecode_sha256: ""
Expand All @@ -28,6 +28,10 @@ upstream_docs:
title: "Easee charger observations"
kind: api_docs
url_stability: stable
- url: "https://developer.easee.com/docs/enumerations"
title: "Easee enumerations (ReasonForNoCurrent)"
kind: api_docs
url_stability: stable
- url: "https://developer.easee.com/reference/chargers_getongoingsessiondetails"
title: "Easee ongoing session API"
kind: api_docs
Expand Down
3 changes: 2 additions & 1 deletion spec/host-api-profile.json
Original file line number Diff line number Diff line change
Expand Up @@ -213,7 +213,8 @@
"control_power_observed_at",
"control_power_confirmed",
"device_limit_a",
"device_limit_age_s"
"device_limit_age_s",
"current_limited_by"
]
},
"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.3.6",
"catalog_version": "1.3.7",
"driver_id": "easee_cloud",
"targets": {
"blixt-l1": {
Expand Down
Loading