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
5 changes: 5 additions & 0 deletions .changeset/bundled-drivers-follow-device-drivers-main.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"ftw": patch
---

Move the bundled-driver pin to device-drivers main. A gateway booting offline no longer runs Pixii, SolarEdge legacy or Sungrow with the poll-counter flap on an absent optional register.
2 changes: 1 addition & 1 deletion drivers/BUNDLED_SOURCE.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
"for coverage. Run scripts/sync-bundled-drivers.sh to update."
],
"repository": "srcfl/device-drivers",
"commit": "1f67078a54c44573b54483bb3f2b709cdf6ff7df",
"commit": "edb32ffed2f1225da160cc51c0cce8fe8e0adfd9",
"source_dir": "drivers/lua",
"drivers": [
"ambibox_v2x", "ctek", "ctek_hybrid", "ctek_v2", "deye", "easee_cloud",
Expand Down
48 changes: 41 additions & 7 deletions drivers/ctek.lua
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ DRIVER = {
id = "ctek-chargestorm",
name = "CTEK Chargestorm (API v1)",
manufacturer = "CTEK",
version = "0.2.0",
version = "0.3.1",
protocols = { "modbus" },
capabilities = { "ev" },
description = "CTEK Chargestorm Connected 2/3 via Modbus/TCP Automation API v1 (CSOS ≥ 4.9.3). Full telemetry + current-limit control.",
Expand Down Expand Up @@ -120,6 +120,36 @@ local sn_read = false
-- Helpers
----------------------------------------------------------------------------

-- Reading a register the charger does not have costs a failed read on every
-- poll forever, and the host counts those against the poll whether or not Lua
-- caught the error. Enough of them and the site is marked offline and reports
-- nothing at all, which is worse than reporting one field less. So stop asking
-- once a register has proved it is not there. Three tries, because one failure
-- proves nothing -- the link may just have been slow.
--
-- Only driver_poll routes through this. The two reads in driver_init run once
-- each and cost nothing per poll, and the control path (driver_command) only
-- writes, so nothing here can veto a later command.
local GIVE_UP_AFTER = 3
local read_failures = {}

local function probe_read(addr, count, kind)
if (read_failures[addr] or 0) >= GIVE_UP_AFTER then return nil end
local ok, regs = pcall(host.modbus_read, addr, count, kind)
if ok and regs and regs[1] ~= nil then
read_failures[addr] = nil
return regs
end
local failures = (read_failures[addr] or 0) + 1
read_failures[addr] = failures
if failures == GIVE_UP_AFTER then
host.log("info", string.format(
"CTEK: register %d did not answer %d times; leaving it alone " ..
"until restart", addr, GIVE_UP_AFTER))
end
return nil
end

local function clamp_amps(a)
if a == nil then return 0 end
a = math.floor(a + 0.5)
Expand Down Expand Up @@ -161,6 +191,9 @@ local function write_setpoint(amps)
return true
end

-- Deliberately not bounded: this runs once, from driver_init, so it can never
-- cost more than a single failed read. Sharing a failure budget with the
-- poll-time read of the same register would only spend that budget early.
local function read_setpoint()
local ok, regs = pcall(host.modbus_read, REG_CHARGE_LIMIT, 1, "holding")
if not ok or not regs or not regs[1] then return nil end
Expand Down Expand Up @@ -190,6 +223,7 @@ function driver_init(config)
-- Sanity-check the CCU is serving API v1 on this unit. A mismatch
-- here means the operator enabled v2 instead — suggest the v2
-- driver rather than silently reading back garbage.
-- Deliberately not bounded: one read at startup, never repeated.
local ok, api_regs = pcall(host.modbus_read, REG_API_VERSION, 2, "holding")
if ok and api_regs and api_regs[1] then
local api_ver = api_regs[1]
Expand All @@ -215,8 +249,8 @@ function driver_poll()
-- battery/other models keyed on device_id stay stable across driver
-- renames.
if not sn_read then
local ok_sn, sn_regs = pcall(host.modbus_read, REG_SERIAL_BASE, 6, "holding")
if ok_sn and sn_regs then
local sn_regs = probe_read(REG_SERIAL_BASE, 6, "holding")
if sn_regs then
local sn = decode_ascii(sn_regs, 6)
if #sn > 0 then
host.set_sn(sn)
Expand All @@ -228,12 +262,12 @@ function driver_poll()
-- Telemetry block: 9 registers in one transaction so the energy
-- counter, per-phase current + voltage, and total power all come
-- from a consistent snapshot.
local ok_tel, tel = pcall(host.modbus_read, REG_TELEMETRY, 9, "holding")
local tel = probe_read(REG_TELEMETRY, 9, "holding")
local ev_w = 0
local i_l1, i_l2, i_l3 = 0, 0, 0
local v_l1, v_l2, v_l3 = 0, 0, 0
local lifetime_wh = 0
if ok_tel and tel then
if tel then
lifetime_wh = host.decode_u32_be(tel[1], tel[2])
i_l1 = (tel[3] or 0) / 1000
i_l2 = (tel[4] or 0) / 1000
Expand All @@ -253,8 +287,8 @@ function driver_poll()
-- charging / connected conservatively from the current + power
-- readings so the dispatch clamp has something to key off.
local limit, max_assign = last_set_a, max_a
local ok_ctl, ctl = pcall(host.modbus_read, REG_CHARGE_LIMIT, 2, "holding")
if ok_ctl and ctl then
local ctl = probe_read(REG_CHARGE_LIMIT, 2, "holding")
if ctl then
limit = ctl[1] or last_set_a
max_assign = ctl[2] or max_a
last_set_a = limit
Expand Down
50 changes: 43 additions & 7 deletions drivers/ctek_hybrid.lua
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ DRIVER = {
id = "ctek-chargestorm-hybrid",
name = "CTEK Chargestorm (Modbus + MQTT)",
manufacturer = "CTEK",
version = "0.2.0",
version = "0.3.1",
protocols = { "modbus", "mqtt" },
capabilities = { "ev" },
description = "CTEK Chargestorm Connected 2/3 — MQTT for state + live telemetry (preferred), Modbus/TCP for control + telemetry fallback.",
Expand Down Expand Up @@ -172,6 +172,37 @@ local function decode_ascii(regs, n)
return s
end

-- A register the CCU never answers costs a failed read on every poll for as
-- long as the driver keeps asking. The host counts those failures against the
-- poll whether or not the pcall caught them, so a driver that keeps reporting
-- telemetry while permanently failing one read gets the whole site marked
-- offline. Give a register three chances, then leave it alone until restart.
-- Three rather than one: a single failure is not proof, the link may just have
-- been slow.
--
-- Poll-path reads only. driver_init's API-version check and read_setpoint run
-- once at startup and driver_command only writes, so nothing bounded here can
-- veto a charging command.
local GIVE_UP_AFTER = 3
local read_failures = {}

local function probe_read(addr, count, kind)
if (read_failures[addr] or 0) >= GIVE_UP_AFTER then return nil end
local ok, regs = pcall(host.modbus_read, addr, count, kind)
if ok and regs and regs[1] ~= nil then
read_failures[addr] = nil
return regs
end
local failures = (read_failures[addr] or 0) + 1
read_failures[addr] = failures
if failures == GIVE_UP_AFTER then
host.log("info", string.format(
"CTEK-hybrid: register %d did not answer %d times; leaving it alone " ..
"until restart", addr, GIVE_UP_AFTER))
end
return nil
end

local function write_setpoint(amps)
local err = host.modbus_write(REG_CHARGE_LIMIT, amps)
if err ~= nil and err ~= "" then
Expand All @@ -182,6 +213,8 @@ local function write_setpoint(amps)
return true
end

-- Startup readback only, called once from driver_init. Left unbounded on
-- purpose: it never runs from driver_poll, so it costs no failed poll.
local function read_setpoint()
local ok, regs = pcall(host.modbus_read, REG_CHARGE_LIMIT, 1, "holding")
if not ok or not regs or not regs[1] then return nil end
Expand Down Expand Up @@ -429,8 +462,8 @@ end
function driver_poll()
-- One-shot serial read, anchors device identity to the EVSE serial.
if not sn_read then
local ok_sn, sn_regs = pcall(host.modbus_read, REG_SERIAL_BASE, 6, "holding")
if ok_sn and sn_regs then
local sn_regs = probe_read(REG_SERIAL_BASE, 6, "holding")
if sn_regs then
local sn = decode_ascii(sn_regs, 6)
if #sn > 0 then
serial = sn
Expand Down Expand Up @@ -483,9 +516,12 @@ function driver_poll()
-- Control-block readback is cheap (2 regs) and required regardless of
-- telemetry source: we need the live charging-limit + max-assignment
-- for both the EV emit and the loadpoint clamp.
-- If the CCU stops answering this block the driver keeps its own
-- last_set_a — every setpoint the driver writes updates it — so giving up
-- costs the readback, not control.
local limit, max_assign = last_set_a, max_a
local ok_ctl, ctl = pcall(host.modbus_read, REG_CHARGE_LIMIT, 2, "holding")
if ok_ctl and ctl then
local ctl = probe_read(REG_CHARGE_LIMIT, 2, "holding")
if ctl then
limit = ctl[1] or last_set_a
max_assign = ctl[2] or max_a
last_set_a = limit
Expand All @@ -511,8 +547,8 @@ function driver_poll()
hz = mqtt_em_hz
else
-- Modbus fallback: 9-reg telemetry block in a single transaction.
local ok_tel, tel = pcall(host.modbus_read, REG_TELEMETRY, 9, "holding")
if ok_tel and tel then
local tel = probe_read(REG_TELEMETRY, 9, "holding")
if tel then
lifetime_wh = host.decode_u32_be(tel[1], tel[2])
i_l1 = (tel[3] or 0) / 1000
i_l2 = (tel[4] or 0) / 1000
Expand Down
43 changes: 36 additions & 7 deletions drivers/ctek_v2.lua
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ DRIVER = {
id = "ctek-chargestorm-v2",
name = "CTEK Chargestorm (API v2)",
manufacturer = "CTEK",
version = "0.2.0",
version = "0.3.1",
protocols = { "modbus" },
capabilities = { "ev" },
description = "CTEK Chargestorm Connected 2/3 via Modbus/TCP Automation API v2 (CSOS ≥ 4.9.3). Full telemetry + current-limit control.",
Expand Down Expand Up @@ -116,6 +116,32 @@ local sn_read = false
-- Helpers
----------------------------------------------------------------------------

-- The host counts every failed modbus_read against the poll whether or not
-- Lua caught it, so a register this CCU does not answer -- an older CSOS
-- build without the serial block, say -- costs a failed read on every poll
-- forever and the stale-telemetry watchdog takes the site offline while the
-- driver goes on emitting. Ask three times, since one miss can just be a slow
-- link, then leave the register alone. A restart re-probes.
local GIVE_UP_AFTER = 3
local read_failures = {}

local function probe_read(addr, count, kind)
if (read_failures[addr] or 0) >= GIVE_UP_AFTER then return nil end
local ok, regs = pcall(host.modbus_read, addr, count, kind)
if ok and regs and regs[1] ~= nil then
read_failures[addr] = nil
return regs
end
local failures = (read_failures[addr] or 0) + 1
read_failures[addr] = failures
if failures == GIVE_UP_AFTER then
host.log("info", string.format(
"CTEK: register %d did not answer %d times; leaving it alone " ..
"until restart", addr, GIVE_UP_AFTER))
end
return nil
end

local function clamp_amps(a)
if a == nil then return 0 end
a = math.floor(a + 0.5)
Expand Down Expand Up @@ -154,6 +180,9 @@ local function write_setpoint(amps)
return true
end

-- Deliberately not bounded: this runs once from driver_init, so it costs no
-- repeated failed read, and a counter shared with the poll would let one slow
-- start eat into the poll's three attempts.
local function read_setpoint()
local ok, regs = pcall(host.modbus_read, REG_CHARGE_LIMIT, 1, "holding")
if not ok or not regs or not regs[1] then return nil end
Expand Down Expand Up @@ -202,8 +231,8 @@ end

function driver_poll()
if not sn_read then
local ok_sn, sn_regs = pcall(host.modbus_read, REG_SERIAL_BASE, 6, "holding")
if ok_sn and sn_regs then
local sn_regs = probe_read(REG_SERIAL_BASE, 6, "holding")
if sn_regs then
local sn = decode_ascii(sn_regs, 6)
if #sn > 0 then
host.set_sn(sn)
Expand All @@ -212,12 +241,12 @@ function driver_poll()
end
end

local ok_tel, tel = pcall(host.modbus_read, REG_TELEMETRY, 9, "holding")
local tel = probe_read(REG_TELEMETRY, 9, "holding")
local ev_w = 0
local i_l1, i_l2, i_l3 = 0, 0, 0
local v_l1, v_l2, v_l3 = 0, 0, 0
local lifetime_wh = 0
if ok_tel and tel then
if tel then
lifetime_wh = host.decode_u32_be(tel[1], tel[2])
i_l1 = (tel[3] or 0) / 1000
i_l2 = (tel[4] or 0) / 1000
Expand All @@ -231,8 +260,8 @@ function driver_poll()
end

local limit, max_assign = last_set_a, max_a
local ok_ctl, ctl = pcall(host.modbus_read, REG_CHARGE_LIMIT, 2, "holding")
if ok_ctl and ctl then
local ctl = probe_read(REG_CHARGE_LIMIT, 2, "holding")
if ctl then
limit = ctl[1] or last_set_a
max_assign = ctl[2] or max_a
last_set_a = limit
Expand Down
Loading