From d951951093d7a7e6ead0e1c572fe36513f42d749 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Wed, 29 Jul 2026 07:39:27 +0200 Subject: [PATCH] chore(drivers): move the bundled pin to device-drivers main Brings the whole absent-register campaign into the recovery snapshot: every bundled driver now gives up on a register the device does not answer instead of paying a failed read for it on every poll. 497 addresses across 49 drivers, plus the control-path fixes that landed alongside. Pin moves 1f67078 -> edb32ff. Generated by scripts/sync-bundled-drivers.sh; nothing edited by hand. The SemVer gate added in #709 verified the move across all 37 bundled drivers. go test ./... passes, including the Sungrow zero-power case that held this back, and the full-stack e2e is green. Co-Authored-By: Claude Opus 5 Signed-off-by: Fredrik Ahlgren --- ...dled-drivers-follow-device-drivers-main.md | 5 + drivers/BUNDLED_SOURCE.json | 2 +- drivers/ctek.lua | 48 +++++- drivers/ctek_hybrid.lua | 50 +++++- drivers/ctek_v2.lua | 43 +++++- drivers/deye.lua | 124 +++++++++------ drivers/ferroamp_modbus.lua | 80 +++++++--- drivers/fronius.lua | 140 ++++++++++------- drivers/fronius_smart_meter.lua | 39 ++++- drivers/goodwe.lua | 96 ++++++++---- drivers/growatt.lua | 112 +++++++++----- drivers/huawei.lua | 106 ++++++++----- drivers/kostal.lua | 64 ++++++-- drivers/pixii.lua | 145 +++++++++++------- drivers/sma.lua | 124 +++++++++------ drivers/sma_pv.lua | 116 +++++++++----- drivers/sofar.lua | 89 +++++++---- drivers/solaredge.lua | 94 ++++++++---- drivers/solaredge_legacy.lua | 100 ++++++++++-- drivers/solaredge_pv.lua | 56 +++++-- drivers/solis.lua | 112 +++++++++----- drivers/solis_string.lua | 32 +++- drivers/sungrow.lua | 106 ++++++++++--- drivers/victron.lua | 51 ++++-- 24 files changed, 1370 insertions(+), 564 deletions(-) create mode 100644 .changeset/bundled-drivers-follow-device-drivers-main.md diff --git a/.changeset/bundled-drivers-follow-device-drivers-main.md b/.changeset/bundled-drivers-follow-device-drivers-main.md new file mode 100644 index 00000000..91772862 --- /dev/null +++ b/.changeset/bundled-drivers-follow-device-drivers-main.md @@ -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. diff --git a/drivers/BUNDLED_SOURCE.json b/drivers/BUNDLED_SOURCE.json index 20eaac7b..7112ae58 100644 --- a/drivers/BUNDLED_SOURCE.json +++ b/drivers/BUNDLED_SOURCE.json @@ -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", diff --git a/drivers/ctek.lua b/drivers/ctek.lua index 571e6f01..a0ef0b16 100644 --- a/drivers/ctek.lua +++ b/drivers/ctek.lua @@ -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.", @@ -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) @@ -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 @@ -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] @@ -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) @@ -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 @@ -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 diff --git a/drivers/ctek_hybrid.lua b/drivers/ctek_hybrid.lua index 01f1ae60..f4a6d1ed 100644 --- a/drivers/ctek_hybrid.lua +++ b/drivers/ctek_hybrid.lua @@ -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.", @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 diff --git a/drivers/ctek_v2.lua b/drivers/ctek_v2.lua index b3b96a4b..96f485b2 100644 --- a/drivers/ctek_v2.lua +++ b/drivers/ctek_v2.lua @@ -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.", @@ -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) @@ -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 @@ -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) @@ -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 @@ -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 diff --git a/drivers/deye.lua b/drivers/deye.lua index 01608525..27ce5357 100644 --- a/drivers/deye.lua +++ b/drivers/deye.lua @@ -10,7 +10,7 @@ DRIVER = { id = "deye", name = "Deye hybrid inverter", manufacturer = "Deye", - version = "1.0.1", + version = "2.1.1", protocols = { "modbus" }, capabilities = { "meter", "pv", "battery" }, description = "Deye SUN-SG series hybrid inverters via Modbus. Auto-detects LV vs HV battery at init.", @@ -65,6 +65,36 @@ local grid_charge_current_a = 31 local soc_max = 100 local soc_min = 20 +-- Registers this device has stopped answering. +-- +-- The host counts every failed host.modbus_read against the poll whether or +-- not this driver caught the error — "driver_poll: N of M modbus reads +-- failed". So a register retried on every poll costs a failed poll on every +-- poll, and the stale-telemetry watchdog takes the driver offline. The site +-- then reports nothing at all, which is worse than reporting one field less. +-- +-- Three attempts absorb a transient blip; after that we stop asking. A +-- restart re-probes, so firmware that gains the register is picked up. +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( + "Deye: register %d did not answer %d times; leaving it alone " .. + "until restart", addr, GIVE_UP_AFTER)) + end + return nil +end + ---------------------------------------------------------------------------- -- Initialization ---------------------------------------------------------------------------- @@ -76,8 +106,8 @@ function driver_init(config) -- protocol docs). Earlier ports also accepted the value in the high -- byte, but the Zap reference on real hardware uses strict equality -- and we match that to avoid false HV positives on LV units. - local ok, mode_regs = pcall(host.modbus_read, 0, 1, "holding") - if ok and mode_regs then + local mode_regs = probe_read(0, 1, "holding") + if mode_regs then local val = mode_regs[1] is_hv = (val == 6) host.log("info", string.format("Deye: device type 0x%04x (%s battery)", @@ -92,8 +122,8 @@ function driver_init(config) if config and type(config) == "table" and tonumber(config.rated_w) then rated_power_w = math.floor(tonumber(config.rated_w)) else - local ok_r, rated_regs = pcall(host.modbus_read, 20, 2, "holding") - if ok_r and rated_regs then + local rated_regs = probe_read(20, 2, "holding") + if rated_regs then rated_power_w = math.floor( host.decode_u32_le(rated_regs[1], rated_regs[2]) * 0.1 * 1000) end @@ -136,8 +166,8 @@ function driver_poll() -- Read serial number once. Registers 3..7 hold 10 ASCII bytes -- (2 chars per register, big-endian within each word). if not sn_read then - local ok, sn_regs = pcall(host.modbus_read, 3, 5, "holding") - if ok and sn_regs then + local sn_regs = probe_read(3, 5, "holding") + if sn_regs then local sn = "" for i = 1, 5 do local hi = math.floor(sn_regs[i] / 256) @@ -155,9 +185,9 @@ function driver_poll() -- ---- PV ---- -- PV1-PV4 power: 672-675, U16 each (×1 LV, ×10 HV) - local ok_pvw, pvw_regs = pcall(host.modbus_read, 672, 4, "holding") + local pvw_regs = probe_read(672, 4, "holding") local pv_total_w = 0 - if ok_pvw and pvw_regs then + if pvw_regs then local pv_scale = is_hv and 10 or 1 for i = 1, 4 do pv_total_w = pv_total_w + pvw_regs[i] * pv_scale @@ -165,39 +195,39 @@ function driver_poll() end -- MPPT1 V/A: 676-677, U16 × 0.1 each - local ok_m1, m1_regs = pcall(host.modbus_read, 676, 2, "holding") + local m1_regs = probe_read(676, 2, "holding") local mppt1_v, mppt1_a = 0, 0 - if ok_m1 and m1_regs then + if m1_regs then mppt1_v = m1_regs[1] * 0.1 mppt1_a = m1_regs[2] * 0.1 end -- MPPT2 V/A: 678-679, U16 × 0.1 each - local ok_m2, m2_regs = pcall(host.modbus_read, 678, 2, "holding") + local m2_regs = probe_read(678, 2, "holding") local mppt2_v, mppt2_a = 0, 0 - if ok_m2 and m2_regs then + if m2_regs then mppt2_v = m2_regs[1] * 0.1 mppt2_a = m2_regs[2] * 0.1 end -- Total generation: 534-535, U32 LE × 0.1 kWh - local ok_gen, gen_regs = pcall(host.modbus_read, 534, 2, "holding") + local gen_regs = probe_read(534, 2, "holding") local pv_gen_wh = 0 - if ok_gen and gen_regs then + if gen_regs then pv_gen_wh = host.decode_u32_le(gen_regs[1], gen_regs[2]) * 0.1 * 1000 end -- Rated power: 20-21, U32 LE × 0.1 kW - local ok_rated, rated_regs = pcall(host.modbus_read, 20, 2, "holding") + local rated_regs = probe_read(20, 2, "holding") local rated_w = 0 - if ok_rated and rated_regs then + if rated_regs then rated_w = host.decode_u32_le(rated_regs[1], rated_regs[2]) * 0.1 * 1000 end -- Heatsink temperature: 541, U16 × 0.1 C - local ok_temp, temp_regs = pcall(host.modbus_read, 541, 1, "holding") + local temp_regs = probe_read(541, 1, "holding") local heatsink_c = 0 - if ok_temp and temp_regs then + if temp_regs then heatsink_c = temp_regs[1] * 0.1 end @@ -220,52 +250,52 @@ function driver_poll() -- ---- Battery ---- -- Battery voltage: 587, U16 (×0.01 LV, ×0.1 HV) - local ok_bv, bv_regs = pcall(host.modbus_read, 587, 1, "holding") + local bv_regs = probe_read(587, 1, "holding") local bat_v = 0 - if ok_bv and bv_regs then + if bv_regs then bat_v = bv_regs[1] * (is_hv and 0.1 or 0.01) end -- Battery SoC: 588, U16 percent → 0..1 fraction - local ok_bsoc, bsoc_regs = pcall(host.modbus_read, 588, 1, "holding") + local bsoc_regs = probe_read(588, 1, "holding") local bat_soc = 0 - if ok_bsoc and bsoc_regs then + if bsoc_regs then bat_soc = bsoc_regs[1] / 100 end -- Battery power: 590, I16 (×1 LV, ×10 HV). Deye native: positive = charging. - local ok_bw, bw_regs = pcall(host.modbus_read, 590, 1, "holding") + local bw_regs = probe_read(590, 1, "holding") local bat_w = 0 - if ok_bw and bw_regs then + if bw_regs then local bat_scale = is_hv and 10 or 1 bat_w = host.decode_i16(bw_regs[1]) * bat_scale end -- Battery current: 591, I16 × 0.01 A - local ok_ba, ba_regs = pcall(host.modbus_read, 591, 1, "holding") + local ba_regs = probe_read(591, 1, "holding") local bat_a = 0 - if ok_ba and ba_regs then + if ba_regs then bat_a = host.decode_i16(ba_regs[1]) * 0.01 end -- Battery temperature: 217, U16 offset-encoded (actual = (val-1000)/10) - local ok_btemp, btemp_regs = pcall(host.modbus_read, 217, 1, "holding") + local btemp_regs = probe_read(217, 1, "holding") local bat_temp = 0 - if ok_btemp and btemp_regs then + if btemp_regs then bat_temp = (btemp_regs[1] - 1000) / 10 end -- Battery charge energy: 516-517, U32 LE × 0.1 kWh - local ok_bchg, bchg_regs = pcall(host.modbus_read, 516, 2, "holding") + local bchg_regs = probe_read(516, 2, "holding") local bat_charge_wh = 0 - if ok_bchg and bchg_regs then + if bchg_regs then bat_charge_wh = host.decode_u32_le(bchg_regs[1], bchg_regs[2]) * 0.1 * 1000 end -- Battery discharge energy: 518-519, U32 LE × 0.1 kWh - local ok_bdis, bdis_regs = pcall(host.modbus_read, 518, 2, "holding") + local bdis_regs = probe_read(518, 2, "holding") local bat_discharge_wh = 0 - if ok_bdis and bdis_regs then + if bdis_regs then bat_discharge_wh = host.decode_u32_le(bdis_regs[1], bdis_regs[2]) * 0.1 * 1000 end @@ -286,57 +316,57 @@ function driver_poll() -- ---- Meter ---- -- Per-phase voltage: 598-600, U16 × 0.1 V - local ok_lv, lv_regs = pcall(host.modbus_read, 598, 3, "holding") + local lv_regs = probe_read(598, 3, "holding") local l1_v, l2_v, l3_v = 0, 0, 0 - if ok_lv and lv_regs then + if lv_regs then l1_v = lv_regs[1] * 0.1 l2_v = lv_regs[2] * 0.1 l3_v = lv_regs[3] * 0.1 end -- Grid frequency: 609, U16 × 0.01 Hz - local ok_hz, hz_regs = pcall(host.modbus_read, 609, 1, "holding") + local hz_regs = probe_read(609, 1, "holding") local hz = 0 - if ok_hz and hz_regs then + if hz_regs then hz = hz_regs[1] * 0.01 end -- Per-phase current: 610-612, I16 × 0.01 A - local ok_la, la_regs = pcall(host.modbus_read, 610, 3, "holding") + local la_regs = probe_read(610, 3, "holding") local l1_a, l2_a, l3_a = 0, 0, 0 - if ok_la and la_regs then + if la_regs then l1_a = host.decode_i16(la_regs[1]) * 0.01 l2_a = host.decode_i16(la_regs[2]) * 0.01 l3_a = host.decode_i16(la_regs[3]) * 0.01 end -- Total meter power: 619, I16 W (positive = import, negative = export) - local ok_tw, tw_regs = pcall(host.modbus_read, 619, 1, "holding") + local tw_regs = probe_read(619, 1, "holding") local meter_w = 0 - if ok_tw and tw_regs then + if tw_regs then meter_w = host.decode_i16(tw_regs[1]) end -- Per-phase power: 622-624, I16 W - local ok_lw, lw_regs = pcall(host.modbus_read, 622, 3, "holding") + local lw_regs = probe_read(622, 3, "holding") local l1_w, l2_w, l3_w = 0, 0, 0 - if ok_lw and lw_regs then + if lw_regs then l1_w = host.decode_i16(lw_regs[1]) l2_w = host.decode_i16(lw_regs[2]) l3_w = host.decode_i16(lw_regs[3]) end -- Import energy: 522-523, U32 LE × 0.1 kWh - local ok_imp, imp_regs = pcall(host.modbus_read, 522, 2, "holding") + local imp_regs = probe_read(522, 2, "holding") local import_wh = 0 - if ok_imp and imp_regs then + if imp_regs then import_wh = host.decode_u32_le(imp_regs[1], imp_regs[2]) * 0.1 * 1000 end -- Export energy: 524-525, U32 LE × 0.1 kWh - local ok_exp, exp_regs = pcall(host.modbus_read, 524, 2, "holding") + local exp_regs = probe_read(524, 2, "holding") local export_wh = 0 - if ok_exp and exp_regs then + if exp_regs then export_wh = host.decode_u32_le(exp_regs[1], exp_regs[2]) * 0.1 * 1000 end diff --git a/drivers/ferroamp_modbus.lua b/drivers/ferroamp_modbus.lua index 0fc1b0b6..3d3de9ab 100644 --- a/drivers/ferroamp_modbus.lua +++ b/drivers/ferroamp_modbus.lua @@ -24,7 +24,7 @@ DRIVER = { id = "ferroamp-modbus", name = "Ferroamp EnergyHub (Modbus)", manufacturer = "Ferroamp", - version = "1.0.0", + version = "2.1.1", protocols = { "modbus" }, capabilities = { "meter", "pv", "battery" }, description = "Ferroamp EnergyHub XL via Modbus TCP (alternative transport to drivers/ferroamp.lua).", @@ -115,6 +115,40 @@ local function encode_f32_ws(value) return { lo, hi } -- word-swapped: lo first, hi second end +---------------------------------------------------------------------------- +-- Bounded reads +---------------------------------------------------------------------------- + +-- A register the EnergyHub 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. +-- +-- Only the poll path goes through here. driver_command writes, it never reads, +-- so a poll-time give-up can never veto a battery or curtail setpoint. +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( + "Ferroamp Modbus: register %d did not answer %d times; leaving it alone " .. + "until restart", addr, GIVE_UP_AFTER)) + end + return nil +end + ---------------------------------------------------------------------------- -- Driver interface ---------------------------------------------------------------------------- @@ -133,29 +167,29 @@ function driver_poll() -------------------------------------------------------------------------- -- Grid frequency: input 2016, float32, Hz (2 regs, word-swapped) - local ok_hz, hz_regs = pcall(host.modbus_read, 2016, 2, "input") + local hz_regs = probe_read(2016, 2, "input") local hz = 0 - if ok_hz and hz_regs then hz = decode_f32_ws_at(hz_regs, 1) end + if hz_regs then hz = decode_f32_ws_at(hz_regs, 1) end -- Grid voltage L1/L2/L3: input 2032/2036/2040, float32, Vrms. -- Each value occupies 4 regs (2 f32 + 2 unused), so read 10 regs for 3 values. - local ok_v, v_regs = pcall(host.modbus_read, 2032, 10, "input") + local v_regs = probe_read(2032, 10, "input") local l1_v, l2_v, l3_v = 0, 0, 0 - if ok_v and v_regs then + if v_regs then l1_v = decode_f32_ws_at(v_regs, 1) -- 2032-2033 l2_v = decode_f32_ws_at(v_regs, 5) -- 2036-2037 l3_v = decode_f32_ws_at(v_regs, 9) -- 2040-2041 end -- Grid active power (total): input 3100, float32, kW - local ok_gw, gw_regs = pcall(host.modbus_read, 3100, 2, "input") + local gw_regs = probe_read(3100, 2, "input") local grid_w = 0 - if ok_gw and gw_regs then grid_w = decode_f32_ws_at(gw_regs, 1) * 1000 end + if gw_regs then grid_w = decode_f32_ws_at(gw_regs, 1) * 1000 end -- Grid active current L1/L2/L3: input 3112/3116/3120, float32, Arms - local ok_ga, ga_regs = pcall(host.modbus_read, 3112, 10, "input") + local ga_regs = probe_read(3112, 10, "input") local l1_a, l2_a, l3_a = 0, 0, 0 - if ok_ga and ga_regs then + if ga_regs then l1_a = decode_f32_ws_at(ga_regs, 1) -- 3112-3113 l2_a = decode_f32_ws_at(ga_regs, 5) -- 3116-3117 l3_a = decode_f32_ws_at(ga_regs, 9) -- 3120-3121 @@ -167,9 +201,9 @@ function driver_poll() local l3_w = l3_v * l3_a -- Grid energy: export at 3064, import at 3068, float32, kWh (8 regs, two values) - local ok_ge, ge_regs = pcall(host.modbus_read, 3064, 8, "input") + local ge_regs = probe_read(3064, 8, "input") local export_wh, import_wh = 0, 0 - if ok_ge and ge_regs then + if ge_regs then export_wh = decode_f32_ws_at(ge_regs, 1) * 1000 -- 3064-3065 import_wh = decode_f32_ws_at(ge_regs, 5) * 1000 -- 3068-3069 end @@ -206,14 +240,14 @@ function driver_poll() -------------------------------------------------------------------------- -- Solar power: input 5100, float32, kW (always positive from Ferroamp) - local ok_pv, pv_regs = pcall(host.modbus_read, 5100, 2, "input") + local pv_regs = probe_read(5100, 2, "input") local pv_w = 0 - if ok_pv and pv_regs then pv_w = decode_f32_ws_at(pv_regs, 1) * 1000 end + if pv_regs then pv_w = decode_f32_ws_at(pv_regs, 1) * 1000 end -- Solar energy produced: input 5064, float32, kWh - local ok_pe, pe_regs = pcall(host.modbus_read, 5064, 2, "input") + local pe_regs = probe_read(5064, 2, "input") local pv_lifetime_wh = 0 - if ok_pe and pe_regs then pv_lifetime_wh = decode_f32_ws_at(pe_regs, 1) * 1000 end + if pe_regs then pv_lifetime_wh = decode_f32_ws_at(pe_regs, 1) * 1000 end host.emit("pv", { w = -pv_w, -- negative = generation (site convention) @@ -227,19 +261,21 @@ function driver_poll() -- Battery power: input 6100, float32, kW. -- Ferroamp: positive = discharging. Site convention: positive = charging. -- Negate and convert kW → W (matches drivers/ferroamp.lua's sign handling). - local ok_bw, bw_regs = pcall(host.modbus_read, 6100, 2, "input") + local bw_regs = probe_read(6100, 2, "input") local bat_w = 0 - if ok_bw and bw_regs then bat_w = -decode_f32_ws_at(bw_regs, 1) * 1000 end + if bw_regs then bat_w = -decode_f32_ws_at(bw_regs, 1) * 1000 end -- Battery SoC: input 6016, float32, percent → 0-1 fraction - local ok_soc, soc_regs = pcall(host.modbus_read, 6016, 2, "input") + local soc_regs = probe_read(6016, 2, "input") local bat_soc = nil - if ok_soc and soc_regs then bat_soc = decode_f32_ws_at(soc_regs, 1) / 100 end + if soc_regs then bat_soc = decode_f32_ws_at(soc_regs, 1) / 100 end - -- Battery energy: discharge at 6064, charge at 6068, float32, kWh (8 regs) - local ok_be, be_regs = pcall(host.modbus_read, 6064, 8, "input") + -- Battery energy: discharge at 6064, charge at 6068, float32, kWh (8 regs). + -- Read as input registers; driver_command writes holding 6064 for the power + -- setpoint, which is a different function code and a separate path. + local be_regs = probe_read(6064, 8, "input") local bat_discharge_wh, bat_charge_wh = 0, 0 - if ok_be and be_regs then + if be_regs then bat_discharge_wh = decode_f32_ws_at(be_regs, 1) * 1000 -- 6064-6065 bat_charge_wh = decode_f32_ws_at(be_regs, 5) * 1000 -- 6068-6069 end diff --git a/drivers/fronius.lua b/drivers/fronius.lua index f10e1f47..ca497ef1 100644 --- a/drivers/fronius.lua +++ b/drivers/fronius.lua @@ -15,7 +15,7 @@ DRIVER = { id = "fronius", name = "Fronius GEN24", manufacturer = "Fronius", - version = "1.0.0", + version = "2.1.1", protocols = { "modbus" }, capabilities = { "pv", "battery" }, description = "Fronius Symo / Primo GEN24 hybrid inverters via Modbus TCP (SunSpec).", @@ -94,6 +94,36 @@ end -- Initialization ---------------------------------------------------------------------------- +-- Registers this device has stopped answering. +-- +-- The host counts every failed host.modbus_read against the poll whether or +-- not this driver caught the error — "driver_poll: N of M modbus reads +-- failed". So a register retried on every poll costs a failed poll on every +-- poll, and the stale-telemetry watchdog takes the driver offline. The site +-- then reports nothing at all, which is worse than reporting one field less. +-- +-- Three attempts absorb a transient blip; after that we stop asking. A +-- restart re-probes, so firmware that gains the register is picked up. +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( + "Fronius: register %d did not answer %d times; leaving it alone " .. + "until restart", addr, GIVE_UP_AFTER)) + end + return nil +end + function driver_init(config) host.set_make("Fronius") end @@ -106,8 +136,8 @@ function driver_poll() -- Read SunSpec Common block serial number once (40052, 16 regs = 32 chars). -- Some gateways don't expose the common block; fall back silently. if not sn_read then - local ok_sn, sn_regs = pcall(host.modbus_read, 40052, 16, "holding") - if ok_sn and sn_regs then + local sn_regs = probe_read(40052, 16, "holding") + if sn_regs then local sn = regs_to_string(sn_regs, 16) if #sn > 0 then host.set_sn(sn) @@ -120,94 +150,94 @@ function driver_poll() -- Scale factors live in separate i16 registers. local rated_w_sf = 0 - local ok_rwsf, rwsf_regs = pcall(host.modbus_read, 40135, 1, "holding") - if ok_rwsf and rwsf_regs then rated_w_sf = host.decode_i16(rwsf_regs[1]) end + local rwsf_regs = probe_read(40135, 1, "holding") + if rwsf_regs then rated_w_sf = host.decode_i16(rwsf_regs[1]) end local mppt_a_sf = 0 - local ok_masf, masf_regs = pcall(host.modbus_read, 40265, 1, "holding") - if ok_masf and masf_regs then mppt_a_sf = host.decode_i16(masf_regs[1]) end + local masf_regs = probe_read(40265, 1, "holding") + if masf_regs then mppt_a_sf = host.decode_i16(masf_regs[1]) end local mppt_v_sf = 0 - local ok_mvsf, mvsf_regs = pcall(host.modbus_read, 40266, 1, "holding") - if ok_mvsf and mvsf_regs then mppt_v_sf = host.decode_i16(mvsf_regs[1]) end + local mvsf_regs = probe_read(40266, 1, "holding") + if mvsf_regs then mppt_v_sf = host.decode_i16(mvsf_regs[1]) end local max_charge_sf = 0 - local ok_mcsf, mcsf_regs = pcall(host.modbus_read, 40331, 1, "holding") - if ok_mcsf and mcsf_regs then max_charge_sf = host.decode_i16(mcsf_regs[1]) end + local mcsf_regs = probe_read(40331, 1, "holding") + if mcsf_regs then max_charge_sf = host.decode_i16(mcsf_regs[1]) end local soc_sf = 0 - local ok_socsf, socsf_regs = pcall(host.modbus_read, 40335, 1, "holding") - if ok_socsf and socsf_regs then soc_sf = host.decode_i16(socsf_regs[1]) end + local socsf_regs = probe_read(40335, 1, "holding") + if socsf_regs then soc_sf = host.decode_i16(socsf_regs[1]) end local bat_v_sf = 0 - local ok_bvsf, bvsf_regs = pcall(host.modbus_read, 40337, 1, "holding") - if ok_bvsf and bvsf_regs then bat_v_sf = host.decode_i16(bvsf_regs[1]) end + local bvsf_regs = probe_read(40337, 1, "holding") + if bvsf_regs then bat_v_sf = host.decode_i16(bvsf_regs[1]) end local charge_rate_sf = 0 - local ok_crsf, crsf_regs = pcall(host.modbus_read, 40338, 1, "holding") - if ok_crsf and crsf_regs then charge_rate_sf = host.decode_i16(crsf_regs[1]) end + local crsf_regs = probe_read(40338, 1, "holding") + if crsf_regs then charge_rate_sf = host.decode_i16(crsf_regs[1]) end -- ---------------------------------------------------------------- PV -- Inverter/AC values are SunSpec F32 BE pairs. local ac_w = 0 - local ok_acw, acw_regs = pcall(host.modbus_read, 40091, 2, "holding") - if ok_acw and acw_regs then ac_w = decode_f32_be(acw_regs[1], acw_regs[2]) end + local acw_regs = probe_read(40091, 2, "holding") + if acw_regs then ac_w = decode_f32_be(acw_regs[1], acw_regs[2]) end local hz = 0 - local ok_hz, hz_regs = pcall(host.modbus_read, 40093, 2, "holding") - if ok_hz and hz_regs then hz = decode_f32_be(hz_regs[1], hz_regs[2]) end + local hz_regs = probe_read(40093, 2, "holding") + if hz_regs then hz = decode_f32_be(hz_regs[1], hz_regs[2]) end local lifetime_wh = 0 - local ok_le, le_regs = pcall(host.modbus_read, 40101, 2, "holding") - if ok_le and le_regs then lifetime_wh = decode_f32_be(le_regs[1], le_regs[2]) end + local le_regs = probe_read(40101, 2, "holding") + if le_regs then lifetime_wh = decode_f32_be(le_regs[1], le_regs[2]) end local dc_w = 0 - local ok_dcw, dcw_regs = pcall(host.modbus_read, 40107, 2, "holding") - if ok_dcw and dcw_regs then dc_w = decode_f32_be(dcw_regs[1], dcw_regs[2]) end + local dcw_regs = probe_read(40107, 2, "holding") + if dcw_regs then dc_w = decode_f32_be(dcw_regs[1], dcw_regs[2]) end local heatsink_c = 0 - local ok_temp, temp_regs = pcall(host.modbus_read, 40111, 2, "holding") - if ok_temp and temp_regs then heatsink_c = decode_f32_be(temp_regs[1], temp_regs[2]) end + local temp_regs = probe_read(40111, 2, "holding") + if temp_regs then heatsink_c = decode_f32_be(temp_regs[1], temp_regs[2]) end -- Rated W: 40134, U16 raw × 10^rated_w_sf local rated_w = 0 - local ok_rw, rw_regs = pcall(host.modbus_read, 40134, 1, "holding") - if ok_rw and rw_regs then rated_w = apply_sf(rw_regs[1], rated_w_sf) end + local rw_regs = probe_read(40134, 1, "holding") + if rw_regs then rated_w = apply_sf(rw_regs[1], rated_w_sf) end -- MPPT1 A/V: 40282-40283, U16 each local mppt1_a, mppt1_v = 0, 0 - local ok_m1, m1_regs = pcall(host.modbus_read, 40282, 2, "holding") - if ok_m1 and m1_regs then + local m1_regs = probe_read(40282, 2, "holding") + if m1_regs then mppt1_a = apply_sf(m1_regs[1], mppt_a_sf) mppt1_v = apply_sf(m1_regs[2], mppt_v_sf) end -- MPPT2 A/V: 40302-40303, U16 each local mppt2_a, mppt2_v = 0, 0 - local ok_m2, m2_regs = pcall(host.modbus_read, 40302, 2, "holding") - if ok_m2 and m2_regs then + local m2_regs = probe_read(40302, 2, "holding") + if m2_regs then mppt2_a = apply_sf(m2_regs[1], mppt_a_sf) mppt2_v = apply_sf(m2_regs[2], mppt_v_sf) end -- Per-phase AC current: 40073, 40075, 40077 (F32 BE pairs) local l1_a, l2_a, l3_a = 0, 0, 0 - local ok_l1a, l1a_regs = pcall(host.modbus_read, 40073, 2, "holding") - if ok_l1a and l1a_regs then l1_a = decode_f32_be(l1a_regs[1], l1a_regs[2]) end - local ok_l2a, l2a_regs = pcall(host.modbus_read, 40075, 2, "holding") - if ok_l2a and l2a_regs then l2_a = decode_f32_be(l2a_regs[1], l2a_regs[2]) end - local ok_l3a, l3a_regs = pcall(host.modbus_read, 40077, 2, "holding") - if ok_l3a and l3a_regs then l3_a = decode_f32_be(l3a_regs[1], l3a_regs[2]) end + local l1a_regs = probe_read(40073, 2, "holding") + if l1a_regs then l1_a = decode_f32_be(l1a_regs[1], l1a_regs[2]) end + local l2a_regs = probe_read(40075, 2, "holding") + if l2a_regs then l2_a = decode_f32_be(l2a_regs[1], l2a_regs[2]) end + local l3a_regs = probe_read(40077, 2, "holding") + if l3a_regs then l3_a = decode_f32_be(l3a_regs[1], l3a_regs[2]) end -- Per-phase AC voltage: 40085, 40087, 40089 (F32 BE pairs) local l1_v, l2_v, l3_v = 0, 0, 0 - local ok_l1v, l1v_regs = pcall(host.modbus_read, 40085, 2, "holding") - if ok_l1v and l1v_regs then l1_v = decode_f32_be(l1v_regs[1], l1v_regs[2]) end - local ok_l2v, l2v_regs = pcall(host.modbus_read, 40087, 2, "holding") - if ok_l2v and l2v_regs then l2_v = decode_f32_be(l2v_regs[1], l2v_regs[2]) end - local ok_l3v, l3v_regs = pcall(host.modbus_read, 40089, 2, "holding") - if ok_l3v and l3v_regs then l3_v = decode_f32_be(l3v_regs[1], l3v_regs[2]) end + local l1v_regs = probe_read(40085, 2, "holding") + if l1v_regs then l1_v = decode_f32_be(l1v_regs[1], l1v_regs[2]) end + local l2v_regs = probe_read(40087, 2, "holding") + if l2v_regs then l2_v = decode_f32_be(l2v_regs[1], l2v_regs[2]) end + local l3v_regs = probe_read(40089, 2, "holding") + if l3v_regs then l3_v = decode_f32_be(l3v_regs[1], l3v_regs[2]) end -- Emit PV (dc_w magnitude → negative for generation, per site convention) host.emit("pv", { @@ -232,36 +262,36 @@ function driver_poll() -- Max charge power: 40315, U16 raw × 10^max_charge_sf local max_charge_w = 0 - local ok_maxchg, maxchg_regs = pcall(host.modbus_read, 40315, 1, "holding") - if ok_maxchg and maxchg_regs then + local maxchg_regs = probe_read(40315, 1, "holding") + if maxchg_regs then max_charge_w = apply_sf(maxchg_regs[1], max_charge_sf) end -- Battery SoC: 40321, U16 raw (percent), convert to 0..1 fraction local bat_soc = 0 - local ok_bsoc, bsoc_regs = pcall(host.modbus_read, 40321, 1, "holding") - if ok_bsoc and bsoc_regs then + local bsoc_regs = probe_read(40321, 1, "holding") + if bsoc_regs then bat_soc = apply_sf(bsoc_regs[1], soc_sf) / 100 end -- Battery voltage: 40323, U16 raw × 10^bat_v_sf local bat_v = 0 - local ok_batv, batv_regs = pcall(host.modbus_read, 40323, 1, "holding") - if ok_batv and batv_regs then + local batv_regs = probe_read(40323, 1, "holding") + if batv_regs then bat_v = apply_sf(batv_regs[1], bat_v_sf) end -- Discharge rate % (I16): 40325 local discharge_rate = 0 - local ok_dis, dis_regs = pcall(host.modbus_read, 40325, 1, "holding") - if ok_dis and dis_regs then + local dis_regs = probe_read(40325, 1, "holding") + if dis_regs then discharge_rate = apply_sf(host.decode_i16(dis_regs[1]), charge_rate_sf) end -- Charge rate % (I16): 40326 local charge_rate = 0 - local ok_chg, chg_regs = pcall(host.modbus_read, 40326, 1, "holding") - if ok_chg and chg_regs then + local chg_regs = probe_read(40326, 1, "holding") + if chg_regs then charge_rate = apply_sf(host.decode_i16(chg_regs[1]), charge_rate_sf) end diff --git a/drivers/fronius_smart_meter.lua b/drivers/fronius_smart_meter.lua index 8f098c0d..14d6e80e 100644 --- a/drivers/fronius_smart_meter.lua +++ b/drivers/fronius_smart_meter.lua @@ -14,7 +14,7 @@ DRIVER = { id = "fronius-smart-meter", name = "Fronius Smart Meter", manufacturer = "Fronius", - version = "1.0.0", + version = "2.1.1", protocols = { "modbus" }, capabilities = { "meter" }, description = "Fronius Smart Meter three-phase energy meter via Modbus TCP (SunSpec).", @@ -79,12 +79,43 @@ local function decode_f32_be(hi, lo) return sign * value end +---------------------------------------------------------------------------- +-- Bounded register probe +---------------------------------------------------------------------------- + +-- The host counts every failed host.modbus_read against the poll, even one +-- pcall caught here. A driver that keeps emitting while a register keeps +-- failing is marked offline by the stale-telemetry watchdog, and the site +-- then reports nothing at all. So: three tries, then leave the register +-- alone. Three rather than one because a single failure is not proof the +-- register is missing -- the link may just have been slow. +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( + "Fronius Smart Meter: register %d did not answer %d times; " .. + "leaving it alone until restart", addr, GIVE_UP_AFTER)) + end + return nil +end + -- Helper: read a contiguous F32 BE pair at `addr` and return the decoded -- float. On Modbus error, returns 0 so the poll cycle still emits a --- well-shaped table. +-- well-shaped table. Every read in this driver goes through here, so +-- bounding it bounds the whole driver. local function read_f32(addr) - local ok, regs = pcall(host.modbus_read, addr, 2, "holding") - if ok and regs then + local regs = probe_read(addr, 2, "holding") + if regs then return decode_f32_be(regs[1], regs[2]) end return 0 diff --git a/drivers/goodwe.lua b/drivers/goodwe.lua index 53986e98..b9f106aa 100644 --- a/drivers/goodwe.lua +++ b/drivers/goodwe.lua @@ -13,7 +13,7 @@ DRIVER = { id = "goodwe", name = "GoodWe hybrid inverter", manufacturer = "GoodWe", - version = "1.0.0", + version = "2.1.1", protocols = { "modbus" }, capabilities = { "meter", "pv", "battery" }, description = "GoodWe ET-Plus / EH series hybrid inverters via Modbus TCP.", @@ -30,6 +30,40 @@ DRIVER = { PROTOCOL = "modbus" +---------------------------------------------------------------------------- +-- Register probing +---------------------------------------------------------------------------- + +-- Registers this device has stopped answering. +-- +-- The host counts every failed host.modbus_read against the poll whether or +-- not this driver caught the error — "driver_poll: N of M modbus reads +-- failed". So a register retried on every poll costs a failed poll on every +-- poll, and the stale-telemetry watchdog takes the driver offline. The site +-- then reports nothing at all, which is worse than reporting one field less. +-- +-- Three attempts absorb a transient blip; after that we stop asking. A +-- restart re-probes, so firmware that gains the register is picked up. +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( + "GoodWe: register %d did not answer %d times; leaving it alone " .. + "until restart", addr, GIVE_UP_AFTER)) + end + return nil +end + ---------------------------------------------------------------------------- -- Initialization ---------------------------------------------------------------------------- @@ -48,39 +82,39 @@ function driver_poll() ------------------------------------------------------------------------ -- PV total power: 35105-35106, U32 BE × 0.1 W - local ok_pvw, pvw_regs = pcall(host.modbus_read, 35105, 2, "holding") + local pvw_regs = probe_read(35105, 2, "holding") local pv_w = 0 - if ok_pvw and pvw_regs then + if pvw_regs then pv_w = host.decode_u32_be(pvw_regs[1], pvw_regs[2]) * 0.1 end -- PV1: voltage @ 35103 (U16 × 0.1 V), current @ 35104 (U16 × 0.1 A) - local ok_m1, m1_regs = pcall(host.modbus_read, 35103, 2, "holding") + local m1_regs = probe_read(35103, 2, "holding") local mppt1_v, mppt1_a = 0, 0 - if ok_m1 and m1_regs then + if m1_regs then mppt1_v = m1_regs[1] * 0.1 mppt1_a = m1_regs[2] * 0.1 end -- PV2: voltage @ 35109 (U16 × 0.1 V), current @ 35110 (U16 × 0.1 A) - local ok_m2, m2_regs = pcall(host.modbus_read, 35109, 2, "holding") + local m2_regs = probe_read(35109, 2, "holding") local mppt2_v, mppt2_a = 0, 0 - if ok_m2 and m2_regs then + if m2_regs then mppt2_v = m2_regs[1] * 0.1 mppt2_a = m2_regs[2] * 0.1 end -- Grid frequency: 35113, U16 × 0.01 Hz - local ok_hz, hz_regs = pcall(host.modbus_read, 35113, 1, "holding") + local hz_regs = probe_read(35113, 1, "holding") local hz = 0 - if ok_hz and hz_regs then + if hz_regs then hz = hz_regs[1] * 0.01 end -- PV lifetime generation: 35191-35192, U32 BE × 0.1 kWh - local ok_pvgen, pvgen_regs = pcall(host.modbus_read, 35191, 2, "holding") + local pvgen_regs = probe_read(35191, 2, "holding") local pv_gen_wh = 0 - if ok_pvgen and pvgen_regs then + if pvgen_regs then pv_gen_wh = host.decode_u32_be(pvgen_regs[1], pvgen_regs[2]) * 0.1 * 1000 end @@ -104,37 +138,37 @@ function driver_poll() ------------------------------------------------------------------------ -- Battery voltage: 35178, U16 × 0.1 V - local ok_bv, bv_regs = pcall(host.modbus_read, 35178, 1, "holding") + local bv_regs = probe_read(35178, 1, "holding") local bat_v = 0 - if ok_bv and bv_regs then + if bv_regs then bat_v = bv_regs[1] * 0.1 end -- Battery current: 35179, I16 × 0.1 A - local ok_ba, ba_regs = pcall(host.modbus_read, 35179, 1, "holding") + local ba_regs = probe_read(35179, 1, "holding") local bat_a = 0 - if ok_ba and ba_regs then + if ba_regs then bat_a = host.decode_i16(ba_regs[1]) * 0.1 end -- Battery power: 35180, I16, W (positive=charge, negative=discharge — matches site convention) - local ok_bw, bw_regs = pcall(host.modbus_read, 35180, 1, "holding") + local bw_regs = probe_read(35180, 1, "holding") local bat_w = 0 - if ok_bw and bw_regs then + if bw_regs then bat_w = host.decode_i16(bw_regs[1]) end -- Battery SoC: 35182, U16, percent - local ok_bsoc, bsoc_regs = pcall(host.modbus_read, 35182, 1, "holding") + local bsoc_regs = probe_read(35182, 1, "holding") local bat_soc = 0 - if ok_bsoc and bsoc_regs then + if bsoc_regs then bat_soc = bsoc_regs[1] / 100 -- percent → 0-1 fraction end -- Battery temperature: 35183, I16 × 0.1 C - local ok_btemp, btemp_regs = pcall(host.modbus_read, 35183, 1, "holding") + local btemp_regs = probe_read(35183, 1, "holding") local bat_temp = 0 - if ok_btemp and btemp_regs then + if btemp_regs then bat_temp = host.decode_i16(btemp_regs[1]) * 0.1 end @@ -158,17 +192,17 @@ function driver_poll() -- above the driver layer sees positive = import. -- Total: 35140-35141, I32 BE, W - local ok_mw, mw_regs = pcall(host.modbus_read, 35140, 2, "holding") + local mw_regs = probe_read(35140, 2, "holding") local meter_w = 0 - if ok_mw and mw_regs then + if mw_regs then meter_w = -host.decode_i32_be(mw_regs[1], mw_regs[2]) end -- Per-phase V/A interleaved at 35121-35126: L1_V, L1_A, L2_V, L2_A, L3_V, L3_A -- (U16, voltage × 0.1 V, current × 0.1 A). One read, six registers. - local ok_va, va_regs = pcall(host.modbus_read, 35121, 6, "holding") + local va_regs = probe_read(35121, 6, "holding") local l1_v, l1_a, l2_v, l2_a, l3_v, l3_a = 0, 0, 0, 0, 0, 0 - if ok_va and va_regs then + if va_regs then l1_v = va_regs[1] * 0.1 l1_a = va_regs[2] * 0.1 l2_v = va_regs[3] * 0.1 @@ -178,25 +212,25 @@ function driver_poll() end -- Per-phase power at 35132-35137: L1, L2, L3 (I32 BE each pair). One read. - local ok_lw, lw_regs = pcall(host.modbus_read, 35132, 6, "holding") + local lw_regs = probe_read(35132, 6, "holding") local l1_w, l2_w, l3_w = 0, 0, 0 - if ok_lw and lw_regs then + if lw_regs then l1_w = -host.decode_i32_be(lw_regs[1], lw_regs[2]) l2_w = -host.decode_i32_be(lw_regs[3], lw_regs[4]) l3_w = -host.decode_i32_be(lw_regs[5], lw_regs[6]) end -- Total import energy: 35195-35196, U32 BE × 0.1 kWh - local ok_imp, imp_regs = pcall(host.modbus_read, 35195, 2, "holding") + local imp_regs = probe_read(35195, 2, "holding") local import_wh = 0 - if ok_imp and imp_regs then + if imp_regs then import_wh = host.decode_u32_be(imp_regs[1], imp_regs[2]) * 0.1 * 1000 end -- Total export energy: 35199-35200, U32 BE × 0.1 kWh - local ok_exp, exp_regs = pcall(host.modbus_read, 35199, 2, "holding") + local exp_regs = probe_read(35199, 2, "holding") local export_wh = 0 - if ok_exp and exp_regs then + if exp_regs then export_wh = host.decode_u32_be(exp_regs[1], exp_regs[2]) * 0.1 * 1000 end diff --git a/drivers/growatt.lua b/drivers/growatt.lua index 9badda65..3c96cd9b 100644 --- a/drivers/growatt.lua +++ b/drivers/growatt.lua @@ -21,7 +21,7 @@ DRIVER = { id = "growatt", name = "Growatt hybrid inverter", manufacturer = "Growatt", - version = "1.0.0", + version = "2.1.1", protocols = { "modbus" }, capabilities = { "meter", "pv", "battery" }, description = "Growatt SPH / MOD hybrid inverters via Modbus TCP.", @@ -38,6 +38,40 @@ DRIVER = { PROTOCOL = "modbus" +---------------------------------------------------------------------------- +-- Absent registers +---------------------------------------------------------------------------- + +-- Registers this device has stopped answering. +-- +-- The host counts every failed host.modbus_read against the poll whether or +-- not this driver caught the error — "driver_poll: N of M modbus reads +-- failed". So a register retried on every poll costs a failed poll on every +-- poll, and the stale-telemetry watchdog takes the driver offline. The site +-- then reports nothing at all, which is worse than reporting one field less. +-- +-- Three attempts absorb a transient blip; after that we stop asking. A +-- restart re-probes, so firmware that gains the register is picked up. +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( + "Growatt: register %d did not answer %d times; leaving it alone " .. + "until restart", addr, GIVE_UP_AFTER)) + end + return nil +end + ---------------------------------------------------------------------------- -- Initialization ---------------------------------------------------------------------------- @@ -55,39 +89,39 @@ function driver_poll() -- ---- PV ---- -- PV total power: 1-2, U32 BE × 0.1 W - local ok_pvw, pvw_regs = pcall(host.modbus_read, 1, 2, "input") + local pvw_regs = probe_read(1, 2, "input") local pv_w = 0 - if ok_pvw and pvw_regs then + if pvw_regs then pv_w = host.decode_u32_be(pvw_regs[1], pvw_regs[2]) * 0.1 end -- PV1 V/A: 3-4, U16 × 0.1 V, U16 × 0.1 A - local ok_m1, m1_regs = pcall(host.modbus_read, 3, 2, "input") + local m1_regs = probe_read(3, 2, "input") local mppt1_v, mppt1_a = 0, 0 - if ok_m1 and m1_regs then + if m1_regs then mppt1_v = m1_regs[1] * 0.1 mppt1_a = m1_regs[2] * 0.1 end -- PV2 V/A: 7-8, U16 × 0.1 V, U16 × 0.1 A - local ok_m2, m2_regs = pcall(host.modbus_read, 7, 2, "input") + local m2_regs = probe_read(7, 2, "input") local mppt2_v, mppt2_a = 0, 0 - if ok_m2 and m2_regs then + if m2_regs then mppt2_v = m2_regs[1] * 0.1 mppt2_a = m2_regs[2] * 0.1 end -- Grid frequency: 37, U16 × 0.01 Hz - local ok_hz, hz_regs = pcall(host.modbus_read, 37, 1, "input") + local hz_regs = probe_read(37, 1, "input") local hz = 0 - if ok_hz and hz_regs then + if hz_regs then hz = hz_regs[1] * 0.01 end -- Total PV lifetime energy: 91-92, U32 BE × 0.1 kWh → × 1000 for Wh - local ok_pvgen, pvgen_regs = pcall(host.modbus_read, 91, 2, "input") + local pvgen_regs = probe_read(91, 2, "input") local pv_gen_wh = 0 - if ok_pvgen and pvgen_regs then + if pvgen_regs then pv_gen_wh = host.decode_u32_be(pvgen_regs[1], pvgen_regs[2]) * 0.1 * 1000 end @@ -109,16 +143,16 @@ function driver_poll() -- ---- Battery ---- -- Battery charge power: 1009-1010, U32 BE × 0.1 W - local ok_bchgw, bchgw_regs = pcall(host.modbus_read, 1009, 2, "input") + local bchgw_regs = probe_read(1009, 2, "input") local bat_charge_w = 0 - if ok_bchgw and bchgw_regs then + if bchgw_regs then bat_charge_w = host.decode_u32_be(bchgw_regs[1], bchgw_regs[2]) * 0.1 end -- Battery discharge power: 1011-1012, U32 BE × 0.1 W - local ok_bdisw, bdisw_regs = pcall(host.modbus_read, 1011, 2, "input") + local bdisw_regs = probe_read(1011, 2, "input") local bat_discharge_w = 0 - if ok_bdisw and bdisw_regs then + if bdisw_regs then bat_discharge_w = host.decode_u32_be(bdisw_regs[1], bdisw_regs[2]) * 0.1 end @@ -126,23 +160,23 @@ function driver_poll() local bat_w = bat_charge_w - bat_discharge_w -- Battery voltage: 1013, U16 × 0.1 V - local ok_bv, bv_regs = pcall(host.modbus_read, 1013, 1, "input") + local bv_regs = probe_read(1013, 1, "input") local bat_v = 0 - if ok_bv and bv_regs then + if bv_regs then bat_v = bv_regs[1] * 0.1 end -- Battery SoC: 1014, U16, percent → 0-1 fraction - local ok_bsoc, bsoc_regs = pcall(host.modbus_read, 1014, 1, "input") + local bsoc_regs = probe_read(1014, 1, "input") local bat_soc = 0 - if ok_bsoc and bsoc_regs then + if bsoc_regs then bat_soc = bsoc_regs[1] / 100 end -- Battery temperature: 1040, U16 × 0.1 °C - local ok_btemp, btemp_regs = pcall(host.modbus_read, 1040, 1, "input") + local btemp_regs = probe_read(1040, 1, "input") local bat_temp = 0 - if ok_btemp and btemp_regs then + if btemp_regs then bat_temp = btemp_regs[1] * 0.1 end @@ -159,61 +193,61 @@ function driver_poll() -- Meter total power: 1015-1016, I32 BE × 0.1 W -- Growatt sign: positive = import (matches site convention). - local ok_mw, mw_regs = pcall(host.modbus_read, 1015, 2, "input") + local mw_regs = probe_read(1015, 2, "input") local meter_w = 0 - if ok_mw and mw_regs then + if mw_regs then meter_w = host.decode_i32_be(mw_regs[1], mw_regs[2]) * 0.1 end -- Per-phase voltages: L1=38, L2=42, L3=46, U16 × 0.1 V - local ok_lv1, lv1_regs = pcall(host.modbus_read, 38, 1, "input") + local lv1_regs = probe_read(38, 1, "input") local l1_v = 0 - if ok_lv1 and lv1_regs then + if lv1_regs then l1_v = lv1_regs[1] * 0.1 end - local ok_lv2, lv2_regs = pcall(host.modbus_read, 42, 1, "input") + local lv2_regs = probe_read(42, 1, "input") local l2_v = 0 - if ok_lv2 and lv2_regs then + if lv2_regs then l2_v = lv2_regs[1] * 0.1 end - local ok_lv3, lv3_regs = pcall(host.modbus_read, 46, 1, "input") + local lv3_regs = probe_read(46, 1, "input") local l3_v = 0 - if ok_lv3 and lv3_regs then + if lv3_regs then l3_v = lv3_regs[1] * 0.1 end -- Per-phase currents: L1=39, L2=43, L3=47, U16 × 0.1 A - local ok_la1, la1_regs = pcall(host.modbus_read, 39, 1, "input") + local la1_regs = probe_read(39, 1, "input") local l1_a = 0 - if ok_la1 and la1_regs then + if la1_regs then l1_a = la1_regs[1] * 0.1 end - local ok_la2, la2_regs = pcall(host.modbus_read, 43, 1, "input") + local la2_regs = probe_read(43, 1, "input") local l2_a = 0 - if ok_la2 and la2_regs then + if la2_regs then l2_a = la2_regs[1] * 0.1 end - local ok_la3, la3_regs = pcall(host.modbus_read, 47, 1, "input") + local la3_regs = probe_read(47, 1, "input") local l3_a = 0 - if ok_la3 and la3_regs then + if la3_regs then l3_a = la3_regs[1] * 0.1 end -- Total import energy: 1021-1022, U32 BE × 0.1 kWh → × 1000 for Wh - local ok_imp, imp_regs = pcall(host.modbus_read, 1021, 2, "input") + local imp_regs = probe_read(1021, 2, "input") local import_wh = 0 - if ok_imp and imp_regs then + if imp_regs then import_wh = host.decode_u32_be(imp_regs[1], imp_regs[2]) * 0.1 * 1000 end -- Total export energy: 1029-1030, U32 BE × 0.1 kWh → × 1000 for Wh - local ok_exp, exp_regs = pcall(host.modbus_read, 1029, 2, "input") + local exp_regs = probe_read(1029, 2, "input") local export_wh = 0 - if ok_exp and exp_regs then + if exp_regs then export_wh = host.decode_u32_be(exp_regs[1], exp_regs[2]) * 0.1 * 1000 end diff --git a/drivers/huawei.lua b/drivers/huawei.lua index 2334fa43..18b23eb5 100644 --- a/drivers/huawei.lua +++ b/drivers/huawei.lua @@ -23,7 +23,7 @@ DRIVER = { id = "huawei-sun2000", name = "Huawei SUN2000 Hybrid Inverter", manufacturer = "Huawei", - version = "1.0.0", + version = "2.1.1", protocols = { "modbus" }, capabilities = { "meter", "pv", "battery" }, description = "Huawei SUN2000 hybrid inverters with LUNA2000 battery via Modbus TCP.", @@ -68,6 +68,42 @@ local function decode_ascii(regs, n) return s end +-- Registers this device has stopped answering. +-- +-- The host counts every failed host.modbus_read against the poll whether or +-- not this driver caught the error — "driver_poll: N of M modbus reads +-- failed". So a register retried on every poll costs a failed poll on every +-- poll, and the stale-telemetry watchdog takes the driver offline. The site +-- then reports nothing at all, which is worse than reporting one field less. +-- +-- Three attempts absorb a transient blip; after that we stop asking. A +-- restart re-probes, so firmware that gains the register is picked up. +-- +-- The meter power register (37113) deliberately does NOT go through here: +-- when it fails the driver emits nothing at all, so it never claims the +-- device is fine while paying for a failed read. Giving up on it would +-- silence the driver for good after three transient blips instead of +-- letting it recover the moment the meter answers again. +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( + "Huawei: register %d did not answer %d times; leaving it alone " .. + "until restart", addr, GIVE_UP_AFTER)) + end + return nil +end + ---------------------------------------------------------------------------- -- Initialization ---------------------------------------------------------------------------- @@ -84,8 +120,8 @@ end function driver_poll() -- Read serial number once (register 30015, 10 registers, ASCII). if not sn_read then - local ok, sn_regs = pcall(host.modbus_read, 30015, 10, "holding") - if ok and sn_regs then + local sn_regs = probe_read(30015, 10, "holding") + if sn_regs then local sn = decode_ascii(sn_regs, 10) if string.len(sn) > 0 then host.set_sn(sn) @@ -98,39 +134,39 @@ function driver_poll() -- ---- PV ---- -- PV1 V/A: 32016-32017, I16 × 0.1 V, I16 × 0.01 A - local ok_pv1, pv1_regs = pcall(host.modbus_read, 32016, 2, "holding") + local pv1_regs = probe_read(32016, 2, "holding") local pv1_v, pv1_a = 0, 0 - if ok_pv1 and pv1_regs then + if pv1_regs then pv1_v = host.decode_i16(pv1_regs[1]) * 0.1 pv1_a = host.decode_i16(pv1_regs[2]) * 0.01 end -- PV2 V/A: 32018-32019, I16 × 0.1 V, I16 × 0.01 A - local ok_pv2, pv2_regs = pcall(host.modbus_read, 32018, 2, "holding") + local pv2_regs = probe_read(32018, 2, "holding") local pv2_v, pv2_a = 0, 0 - if ok_pv2 and pv2_regs then + if pv2_regs then pv2_v = host.decode_i16(pv2_regs[1]) * 0.1 pv2_a = host.decode_i16(pv2_regs[2]) * 0.01 end -- Input power (PV total): 32064-32065, I32 BE × 0.001 kW → × 1000 for W - local ok_pvw, pvw_regs = pcall(host.modbus_read, 32064, 2, "holding") + local pvw_regs = probe_read(32064, 2, "holding") local pv_w = 0 - if ok_pvw and pvw_regs then + if pvw_regs then pv_w = host.decode_i32_be(pvw_regs[1], pvw_regs[2]) * 0.001 * 1000 end -- Inverter temperature: 32087, I16 × 0.1 °C - local ok_itemp, itemp_regs = pcall(host.modbus_read, 32087, 1, "holding") + local itemp_regs = probe_read(32087, 1, "holding") local inv_temp = 0 - if ok_itemp and itemp_regs then + if itemp_regs then inv_temp = host.decode_i16(itemp_regs[1]) * 0.1 end -- PV lifetime yield: 32106-32107, U32 BE × 0.01 kWh → × 1000 for Wh - local ok_yield, yield_regs = pcall(host.modbus_read, 32106, 2, "holding") + local yield_regs = probe_read(32106, 2, "holding") local pv_gen_wh = 0 - if ok_yield and yield_regs then + if yield_regs then pv_gen_wh = host.decode_u32_be(yield_regs[1], yield_regs[2]) * 0.01 * 1000 end @@ -138,44 +174,44 @@ function driver_poll() -- Battery power: 37001-37002, I32 BE, watts -- Huawei sign: positive = charging, negative = discharging (matches site convention). - local ok_bw, bw_regs = pcall(host.modbus_read, 37001, 2, "holding") + local bw_regs = probe_read(37001, 2, "holding") local bat_w = 0 - if ok_bw and bw_regs then + if bw_regs then bat_w = host.decode_i32_be(bw_regs[1], bw_regs[2]) end -- Battery bus voltage: 37003, U16 × 0.1 V - local ok_bv, bv_regs = pcall(host.modbus_read, 37003, 1, "holding") + local bv_regs = probe_read(37003, 1, "holding") local bat_v = 0 - if ok_bv and bv_regs then + if bv_regs then bat_v = bv_regs[1] * 0.1 end -- Battery SoC: 37004, U16 × 0.1 percent (convert to 0-1 fraction). - local ok_bsoc, bsoc_regs = pcall(host.modbus_read, 37004, 1, "holding") + local bsoc_regs = probe_read(37004, 1, "holding") local bat_soc = 0 - if ok_bsoc and bsoc_regs then + if bsoc_regs then bat_soc = bsoc_regs[1] * 0.1 / 100 end -- Battery current: 37021, I16 × 0.1 A - local ok_ba, ba_regs = pcall(host.modbus_read, 37021, 1, "holding") + local ba_regs = probe_read(37021, 1, "holding") local bat_a = 0 - if ok_ba and ba_regs then + if ba_regs then bat_a = host.decode_i16(ba_regs[1]) * 0.1 end -- Battery temperature: 37022, I16 × 0.1 °C - local ok_btemp, btemp_regs = pcall(host.modbus_read, 37022, 1, "holding") + local btemp_regs = probe_read(37022, 1, "holding") local bat_temp = 0 - if ok_btemp and btemp_regs then + if btemp_regs then bat_temp = host.decode_i16(btemp_regs[1]) * 0.1 end -- Battery charge/discharge lifetime energy: 37066-37069, U32 BE × 0.01 kWh pairs. - local ok_benergy, benergy_regs = pcall(host.modbus_read, 37066, 4, "holding") + local benergy_regs = probe_read(37066, 4, "holding") local bat_charge_wh, bat_discharge_wh = 0, 0 - if ok_benergy and benergy_regs then + if benergy_regs then bat_charge_wh = host.decode_u32_be(benergy_regs[1], benergy_regs[2]) * 0.01 * 1000 bat_discharge_wh = host.decode_u32_be(benergy_regs[3], benergy_regs[4]) * 0.01 * 1000 end @@ -183,18 +219,18 @@ function driver_poll() -- ---- Meter ---- -- Per-phase voltage: 37101-37106, I32 BE × 0.1 pairs (L1 V, L2 V, L3 V) - local ok_lv, lv_regs = pcall(host.modbus_read, 37101, 6, "holding") + local lv_regs = probe_read(37101, 6, "holding") local l1_v, l2_v, l3_v = 0, 0, 0 - if ok_lv and lv_regs then + if lv_regs then l1_v = host.decode_i32_be(lv_regs[1], lv_regs[2]) * 0.1 l2_v = host.decode_i32_be(lv_regs[3], lv_regs[4]) * 0.1 l3_v = host.decode_i32_be(lv_regs[5], lv_regs[6]) * 0.1 end -- Per-phase current: 37107-37112, I32 BE × 0.01 pairs (L1 A, L2 A, L3 A) - local ok_la, la_regs = pcall(host.modbus_read, 37107, 6, "holding") + local la_regs = probe_read(37107, 6, "holding") local l1_a, l2_a, l3_a = 0, 0, 0 - if ok_la and la_regs then + if la_regs then l1_a = host.decode_i32_be(la_regs[1], la_regs[2]) * 0.01 l2_a = host.decode_i32_be(la_regs[3], la_regs[4]) * 0.01 l3_a = host.decode_i32_be(la_regs[5], la_regs[6]) * 0.01 @@ -213,24 +249,24 @@ function driver_poll() local meter_w = host.decode_i32_be(mw_regs[1], mw_regs[2]) -- Grid frequency: 37118, I16 × 0.01 Hz - local ok_hz, hz_regs = pcall(host.modbus_read, 37118, 1, "holding") + local hz_regs = probe_read(37118, 1, "holding") local hz = 0 - if ok_hz and hz_regs then + if hz_regs then hz = host.decode_i16(hz_regs[1]) * 0.01 end -- Export/Import lifetime energy: 37119-37122, I32 BE × 0.01 kWh pairs - local ok_energy, energy_regs = pcall(host.modbus_read, 37119, 4, "holding") + local energy_regs = probe_read(37119, 4, "holding") local export_wh, import_wh = 0, 0 - if ok_energy and energy_regs then + if energy_regs then export_wh = host.decode_i32_be(energy_regs[1], energy_regs[2]) * 0.01 * 1000 import_wh = host.decode_i32_be(energy_regs[3], energy_regs[4]) * 0.01 * 1000 end -- Per-phase power: 37132-37137, I32 BE pairs, watts (Huawei sign). - local ok_lpw, lpw_regs = pcall(host.modbus_read, 37132, 6, "holding") + local lpw_regs = probe_read(37132, 6, "holding") local l1_w, l2_w, l3_w = 0, 0, 0 - if ok_lpw and lpw_regs then + if lpw_regs then l1_w = host.decode_i32_be(lpw_regs[1], lpw_regs[2]) l2_w = host.decode_i32_be(lpw_regs[3], lpw_regs[4]) l3_w = host.decode_i32_be(lpw_regs[5], lpw_regs[6]) diff --git a/drivers/kostal.lua b/drivers/kostal.lua index 6b5d6d33..1c861841 100644 --- a/drivers/kostal.lua +++ b/drivers/kostal.lua @@ -10,7 +10,7 @@ DRIVER = { id = "kostal", name = "Kostal Plenticore", manufacturer = "Kostal", - version = "1.0.0", + version = "2.1.1", protocols = { "modbus" }, capabilities = { "meter", "pv", "battery" }, description = "Kostal Plenticore Plus and Piko IQ via Modbus TCP (SunSpec plus Kostal custom map).", @@ -38,11 +38,41 @@ local function apply_sf(v, sf) return v / (10 ^ -sf) end +-- Registers this device has stopped answering. +-- +-- The host counts every failed host.modbus_read against the poll whether or +-- not this driver caught the error — "driver_poll: N of M modbus reads +-- failed". So a register retried on every poll costs a failed poll on every +-- poll, and the stale-telemetry watchdog takes the driver offline. The site +-- then reports nothing at all, which is worse than reporting one field less. +-- +-- Three attempts absorb a transient blip; after that we stop asking. A +-- restart re-probes, so firmware that gains the register is picked up. +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( + "Kostal: register %d did not answer %d times; leaving it alone " .. + "until restart", addr, GIVE_UP_AFTER)) + end + return nil +end + -- Read a single holding register as signed i16 (e.g. scale factors). -- Returns the decoded value, or the default if the read fails. local function read_i16(addr, default) - local ok, regs = pcall(host.modbus_read, addr, 1, "holding") - if ok and regs and regs[1] then + local regs = probe_read(addr, 1, "holding") + if regs then return host.decode_i16(regs[1]) end return default @@ -50,8 +80,8 @@ end -- Read a single holding register as unsigned u16. local function read_u16(addr, default) - local ok, regs = pcall(host.modbus_read, addr, 1, "holding") - if ok and regs and regs[1] then + local regs = probe_read(addr, 1, "holding") + if regs then return regs[1] end return default @@ -59,8 +89,8 @@ end -- Read a u32 big-endian pair (SunSpec standard word order). local function read_u32_be(addr, default) - local ok, regs = pcall(host.modbus_read, addr, 2, "holding") - if ok and regs and regs[1] and regs[2] then + local regs = probe_read(addr, 2, "holding") + if regs and regs[2] then return host.decode_u32_be(regs[1], regs[2]) end return default @@ -84,8 +114,8 @@ function driver_poll() -- 40020-40035 is Opt (model string) — NOT serial number.) -- ----------------------------------------------------------------- if not sn_read then - local ok_sn, sn_regs = pcall(host.modbus_read, 40052, 16, "holding") - if ok_sn and sn_regs then + local sn_regs = probe_read(40052, 16, "holding") + if sn_regs then local sn = "" for i = 1, 16 do local hi = math.floor(sn_regs[i] / 256) @@ -160,8 +190,8 @@ function driver_poll() local bat_soc = bat_soc_raw / 100.0 -- percent → 0-1 fraction local bat_w = 0 - local ok_bw, bw_regs = pcall(host.modbus_read, 40138, 1, "holding") - if ok_bw and bw_regs and bw_regs[1] then + local bw_regs = probe_read(40138, 1, "holding") + if bw_regs then -- Kostal reports battery power with EMS-compatible sign already: -- positive = charging, negative = discharging. bat_w = host.decode_i16(bw_regs[1]) @@ -188,8 +218,8 @@ function driver_poll() -- Per-phase current: 40191-40193, I16 local l1_a, l2_a, l3_a = 0, 0, 0 - local ok_la, la_regs = pcall(host.modbus_read, 40191, 3, "holding") - if ok_la and la_regs then + local la_regs = probe_read(40191, 3, "holding") + if la_regs then l1_a = -apply_sf(host.decode_i16(la_regs[1]), meter_a_sf) l2_a = -apply_sf(host.decode_i16(la_regs[2]), meter_a_sf) l3_a = -apply_sf(host.decode_i16(la_regs[3]), meter_a_sf) @@ -197,8 +227,8 @@ function driver_poll() -- Per-phase voltage: 40196-40198, I16 local l1_v, l2_v, l3_v = 0, 0, 0 - local ok_lv, lv_regs = pcall(host.modbus_read, 40196, 3, "holding") - if ok_lv and lv_regs then + local lv_regs = probe_read(40196, 3, "holding") + if lv_regs then l1_v = apply_sf(host.decode_i16(lv_regs[1]), meter_v_sf) l2_v = apply_sf(host.decode_i16(lv_regs[2]), meter_v_sf) l3_v = apply_sf(host.decode_i16(lv_regs[3]), meter_v_sf) @@ -206,8 +236,8 @@ function driver_poll() -- Per-phase power: 40207-40209, I16 local l1_w, l2_w, l3_w = 0, 0, 0 - local ok_lw, lw_regs = pcall(host.modbus_read, 40207, 3, "holding") - if ok_lw and lw_regs then + local lw_regs = probe_read(40207, 3, "holding") + if lw_regs then l1_w = -apply_sf(host.decode_i16(lw_regs[1]), meter_w_sf) l2_w = -apply_sf(host.decode_i16(lw_regs[2]), meter_w_sf) l3_w = -apply_sf(host.decode_i16(lw_regs[3]), meter_w_sf) diff --git a/drivers/pixii.lua b/drivers/pixii.lua index c153e032..041f7a9b 100644 --- a/drivers/pixii.lua +++ b/drivers/pixii.lua @@ -29,7 +29,7 @@ DRIVER = { id = "pixii", name = "Pixii PowerShaper", manufacturer = "Pixii", - version = "2.0.0", + version = "2.1.3", protocols = { "modbus" }, capabilities = { "battery", "meter" }, description = "Pixii PowerShaper commercial battery storage via Modbus TCP.", @@ -89,10 +89,49 @@ local function scale(v, sf) return v * (10 ^ sf) end --- Read a single i16-typed scale factor register, returning 0 on error. +-- Registers this device has stopped answering. +-- +-- The host counts every failed host.modbus_read against the poll whether or +-- not this driver caught the error — "driver_poll: N of M modbus reads +-- failed". So a register retried on every poll costs a failed poll on every +-- poll, and the stale-telemetry watchdog takes the driver offline. The site +-- then reports nothing at all, which is worse than reporting one field less. +-- PowerShaper firmware below CPU 2.0.23 answers Modbus exception 2 on 40288 +-- (meter_energy_sf), which is how this was found in the field. +-- +-- Three attempts absorb a transient blip; after that we stop asking. A +-- restart re-probes, so firmware that gains the register is picked up. +-- +-- This replaces a scale-factor-only cache that gave up after one failure. +-- Every read goes through here now: the scale factors were the fourteen +-- registers that fix covered, and the value reads below are the twenty-three +-- it left behind. +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( + "Pixii: register %d did not answer %d times; leaving it alone " .. + "until restart", addr, GIVE_UP_AFTER)) + end + return nil +end + +-- Read a single i16-typed scale factor register. SF=0 (x1 scaling) is the +-- right fallback when the register is absent, and matches units where it +-- exists but reads zero. local function read_sf(addr) - local ok, regs = pcall(host.modbus_read, addr, 1, "holding") - if ok and regs then return host.decode_i16(regs[1]) end + local regs = probe_read(addr, 1, "holding") + if regs then return host.decode_i16(regs[1]) end return 0 end @@ -117,22 +156,22 @@ local function config_bool(config, key) end local function read_u16(addr) - local ok, regs = pcall(host.modbus_read, addr, 1, "holding") - if ok and regs and regs[1] ~= nil then return regs[1] end + local regs = probe_read(addr, 1, "holding") + if regs then return regs[1] end return nil end local function read_u32_be(addr) - local ok, regs = pcall(host.modbus_read, addr, 2, "holding") - if ok and regs and regs[1] ~= nil and regs[2] ~= nil then + local regs = probe_read(addr, 2, "holding") + if regs and regs[2] ~= nil then return host.decode_u32_be(regs[1], regs[2]) end return nil end local function read_i32_be(addr) - local ok, regs = pcall(host.modbus_read, addr, 2, "holding") - if ok and regs and regs[1] ~= nil and regs[2] ~= nil then + local regs = probe_read(addr, 2, "holding") + if regs and regs[2] ~= nil then return host.decode_i32_be(regs[1], regs[2]) end return nil @@ -247,16 +286,16 @@ end -- false → answered Modbus but it's a non-Pixii device -- nil → couldn't read (wrong unit id, not Modbus, timeout) function driver_fingerprint() - local ok, sig = pcall(host.modbus_read, 40000, 2, "holding") - if not ok or sig == nil or sig[1] == nil or sig[2] == nil then + local sig = probe_read(40000, 2, "holding") + if sig == nil or sig[2] == nil then return nil end -- SunSpec identifier "SunS" = 0x5375, 0x6E53. if sig[1] ~= 0x5375 or sig[2] ~= 0x6E53 then return false end - local mok, mfg_regs = pcall(host.modbus_read, 40004, 16, "holding") - if not mok or mfg_regs == nil then + local mfg_regs = probe_read(40004, 16, "holding") + if mfg_regs == nil then return nil end local mfg = decode_ascii(mfg_regs, 16) @@ -264,8 +303,8 @@ function driver_fingerprint() return false -- SunSpec, but a different vendor end local serial = "" - local sok, sn_regs = pcall(host.modbus_read, 40052, 16, "holding") - if sok and sn_regs ~= nil then + local sn_regs = probe_read(40052, 16, "holding") + if sn_regs ~= nil then serial = decode_ascii(sn_regs, 16) end return true, { make = "Pixii", serial = serial, confidence = 0.95 } @@ -286,8 +325,8 @@ function driver_init(config) end -- Verify SunSpec signature at 40000 ("SunS") as a sanity check. - local ok, sig = pcall(host.modbus_read, 40000, 2, "holding") - if ok and sig then + local sig = probe_read(40000, 2, "holding") + if sig then local want = "SunS" local got = decode_ascii(sig, 2) if got ~= want then @@ -312,7 +351,7 @@ function driver_poll() -- Read serial number once from SunSpec Common Model (offset 52 from -- the common block → absolute 40052, 16 regs ASCII). if not sn_read then - local ok, sn_regs = pcall(host.modbus_read, 40052, 16, "holding") + local sn_regs = probe_read(40052, 16, "holding") if ok and sn_regs then local sn = decode_ascii(sn_regs, 16) if string.len(sn) > 0 then @@ -341,33 +380,33 @@ function driver_poll() -- ---- Battery Values ---- -- AC power (inverter): 40083, I16 (diagnostic only; bat_w below is DC) - local ok_acw, acw_regs = pcall(host.modbus_read, 40083, 1, "holding") + local acw_regs = probe_read(40083, 1, "holding") local ac_w = 0 - if ok_acw and acw_regs then + if acw_regs then ac_w = scale(host.decode_i16(acw_regs[1]), ac_w_sf) end -- Inverter frequency: 40085, U16 - local ok_hz, hz_regs = pcall(host.modbus_read, 40085, 1, "holding") + local hz_regs = probe_read(40085, 1, "holding") local inv_hz = 0 - if ok_hz and hz_regs then + if hz_regs then inv_hz = scale(hz_regs[1], hz_sf) end -- Inverter temperature: 40102, I16 (°C) - local ok_temp, temp_regs = pcall(host.modbus_read, 40102, 1, "holding") + local temp_regs = probe_read(40102, 1, "holding") local temp_c = 0 - if ok_temp and temp_regs then + if temp_regs then temp_c = scale(host.decode_i16(temp_regs[1]), temp_sf) end -- Battery SoC: 40132, U16 (percent → fraction 0..1). -- If Pixii returns a sentinel or otherwise impossible value, omit SoC -- from the emit rather than invalidating the whole battery reading. - local ok_soc, soc_regs = pcall(host.modbus_read, REG_BATTERY_SOC, 1, "holding") + local soc_regs = probe_read(REG_BATTERY_SOC, 1, "holding") local bat_soc = nil local bat_soc_pct = nil - if ok_soc and soc_regs and soc_regs[1] ~= nil then + if soc_regs and soc_regs[1] ~= nil then bat_soc_pct = scale(soc_regs[1], soc_sf) local candidate = bat_soc_pct / 100 if candidate >= 0 and candidate <= 1 then @@ -385,30 +424,30 @@ function driver_poll() end -- Battery voltage: 40155, I16 - local ok_bv, bv_regs = pcall(host.modbus_read, 40155, 1, "holding") + local bv_regs = probe_read(40155, 1, "holding") local bat_v = 0 - if ok_bv and bv_regs then + if bv_regs then bat_v = scale(host.decode_i16(bv_regs[1]), bat_v_sf) end -- Battery current: 40165, I16 - local ok_ba, ba_regs = pcall(host.modbus_read, 40165, 1, "holding") + local ba_regs = probe_read(40165, 1, "holding") local bat_a = 0 - if ok_ba and ba_regs then + if ba_regs then bat_a = scale(host.decode_i16(ba_regs[1]), bat_a_sf) end -- Battery DC power: 40168, I16 (SunSpec: positive = charge, so site-conv) - local ok_bw, bw_regs = pcall(host.modbus_read, 40168, 1, "holding") + local bw_regs = probe_read(40168, 1, "holding") local bat_w = 0 - if ok_bw and bw_regs then + if bw_regs then bat_w = scale(host.decode_i16(bw_regs[1]), bat_w_sf) end -- Cabinet charge/discharge energy: 39958-39961, two I32 BE pairs, kWh - local ok_cab, cab_regs = pcall(host.modbus_read, 39958, 4, "holding") + local cab_regs = probe_read(39958, 4, "holding") local bat_charge_wh, bat_discharge_wh = 0, 0 - if ok_cab and cab_regs then + if cab_regs then bat_charge_wh = host.decode_i32_be(cab_regs[1], cab_regs[2]) * 1000 bat_discharge_wh = host.decode_i32_be(cab_regs[3], cab_regs[4]) * 1000 end @@ -455,41 +494,41 @@ function driver_poll() -- so signed amps here do NOT weaken the per-phase fuse guard — -- the guard fires on magnitude regardless of direction, exactly -- as before. - local ok_la, la_regs = pcall(host.modbus_read, 40237, 3, "holding") + local la_regs = probe_read(40237, 3, "holding") local l1_a_mag, l2_a_mag, l3_a_mag = 0, 0, 0 - if ok_la and la_regs then + if la_regs then l1_a_mag = math.abs(scale(host.decode_i16(la_regs[1]), meter_a_sf)) l2_a_mag = math.abs(scale(host.decode_i16(la_regs[2]), meter_a_sf)) l3_a_mag = math.abs(scale(host.decode_i16(la_regs[3]), meter_a_sf)) end -- Per-phase voltage: 40242-40244, I16 each - local ok_lv, lv_regs = pcall(host.modbus_read, 40242, 3, "holding") + local lv_regs = probe_read(40242, 3, "holding") local l1_v, l2_v, l3_v = 0, 0, 0 - if ok_lv and lv_regs then + if lv_regs then l1_v = scale(host.decode_i16(lv_regs[1]), meter_v_sf) l2_v = scale(host.decode_i16(lv_regs[2]), meter_v_sf) l3_v = scale(host.decode_i16(lv_regs[3]), meter_v_sf) end -- Meter frequency: 40250, U16 - local ok_mhz, mhz_regs = pcall(host.modbus_read, 40250, 1, "holding") + local mhz_regs = probe_read(40250, 1, "holding") local meter_hz = 0 - if ok_mhz and mhz_regs then + if mhz_regs then meter_hz = scale(mhz_regs[1], meter_hz_sf) end -- Total meter power: 40252, I16 - local ok_mw, mw_regs = pcall(host.modbus_read, 40252, 1, "holding") + local mw_regs = probe_read(40252, 1, "holding") local meter_w = 0 - if ok_mw and mw_regs then + if mw_regs then meter_w = scale(host.decode_i16(mw_regs[1]), meter_w_sf) end -- Per-phase meter power: 40253-40255, I16 each - local ok_lpw, lpw_regs = pcall(host.modbus_read, 40253, 3, "holding") + local lpw_regs = probe_read(40253, 3, "holding") local l1_w, l2_w, l3_w = 0, 0, 0 - if ok_lpw and lpw_regs then + if lpw_regs then l1_w = scale(host.decode_i16(lpw_regs[1]), meter_w_sf) l2_w = scale(host.decode_i16(lpw_regs[2]), meter_w_sf) l3_w = scale(host.decode_i16(lpw_regs[3]), meter_w_sf) @@ -508,15 +547,15 @@ function driver_poll() local function i16_present(reg) return reg ~= 0x8000 end - local ok_va, va_regs = pcall(host.modbus_read, 40257, 1, "holding") + local va_regs = probe_read(40257, 1, "holding") local meter_va, meter_va_ok = 0, false - if ok_va and va_regs and i16_present(va_regs[1]) then + if va_regs and i16_present(va_regs[1]) then meter_va = scale(host.decode_i16(va_regs[1]), meter_va_sf) meter_va_ok = true end - local ok_var, var_regs = pcall(host.modbus_read, 40262, 1, "holding") + local var_regs = probe_read(40262, 1, "holding") local meter_var, meter_var_ok = 0, false - if ok_var and var_regs and i16_present(var_regs[1]) then + if var_regs and i16_present(var_regs[1]) then meter_var = scale(host.decode_i16(var_regs[1]), meter_var_sf) meter_var_ok = true end @@ -537,16 +576,16 @@ function driver_poll() local l3_a = signed_a(l3_a_mag, l3_w) -- Export energy: 40272-40275, U32 BE (two regs consumed for the value) - local ok_exp, exp_regs = pcall(host.modbus_read, 40272, 4, "holding") + local exp_regs = probe_read(40272, 4, "holding") local export_wh = 0 - if ok_exp and exp_regs then + if exp_regs then export_wh = scale(host.decode_u32_be(exp_regs[1], exp_regs[2]), meter_energy_sf) end -- Import energy: 40280-40283, U32 BE - local ok_imp, imp_regs = pcall(host.modbus_read, 40280, 4, "holding") + local imp_regs = probe_read(40280, 4, "holding") local import_wh = 0 - if ok_imp and imp_regs then + if imp_regs then import_wh = scale(host.decode_u32_be(imp_regs[1], imp_regs[2]), meter_energy_sf) end diff --git a/drivers/sma.lua b/drivers/sma.lua index 9a485f0b..c6a4c65f 100644 --- a/drivers/sma.lua +++ b/drivers/sma.lua @@ -13,7 +13,7 @@ DRIVER = { id = "sma", name = "SMA hybrid inverter", manufacturer = "SMA", - version = "1.0.0", + version = "2.1.1", protocols = { "modbus" }, capabilities = { "meter", "pv", "battery" }, description = "SMA Sunny Tripower / Sunny Boy Storage via Modbus TCP (SunSpec).", @@ -61,6 +61,36 @@ local function u64_valid(h1, h2, h3, h4) return not (h1 == 65535 and h2 == 65535 and h3 == 65535 and h4 == 65535) end +-- Registers this device has stopped answering. +-- +-- The host counts every failed host.modbus_read against the poll whether or +-- not this driver caught the error — "driver_poll: N of M modbus reads +-- failed". So a register retried on every poll costs a failed poll on every +-- poll, and the stale-telemetry watchdog takes the driver offline. The site +-- then reports nothing at all, which is worse than reporting one field less. +-- +-- Three attempts absorb a transient blip; after that we stop asking. A +-- restart re-probes, so firmware that gains the register is picked up. +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( + "SMA: register %d did not answer %d times; leaving it alone " .. + "until restart", addr, GIVE_UP_AFTER)) + end + return nil +end + local sn_read = false ---------------------------------------------------------------------------- @@ -73,8 +103,8 @@ function driver_init(config) -- SunSpec common model serial number: 30057-30058 (U32 BE). -- Best-effort; older SMA firmware can report 0 here — we fall back -- to make:endpoint identity in that case. - local ok, sn_regs = pcall(host.modbus_read, 30057, 2, "input") - if ok and sn_regs then + local sn_regs = probe_read(30057, 2, "input") + if sn_regs then local sn = host.decode_u32_be(sn_regs[1], sn_regs[2]) if u32_valid(sn) and sn > 0 then host.set_sn(tostring(sn)) @@ -87,8 +117,8 @@ function driver_poll() -- Opportunistically retry SN if init couldn't read it (e.g. the -- inverter was still booting). if not sn_read then - local ok, sn_regs = pcall(host.modbus_read, 30057, 2, "input") - if ok and sn_regs then + local sn_regs = probe_read(30057, 2, "input") + if sn_regs then local sn = host.decode_u32_be(sn_regs[1], sn_regs[2]) if u32_valid(sn) and sn > 0 then host.set_sn(tostring(sn)) @@ -100,64 +130,64 @@ function driver_poll() ------------------------------------------------------------------ PV -- -- PV power: 30775-30776, I32 BE, watts - local ok_pvw, pvw_regs = pcall(host.modbus_read, 30775, 2, "input") + local pvw_regs = probe_read(30775, 2, "input") local pv_w = 0 - if ok_pvw and pvw_regs then + if pvw_regs then local v = host.decode_i32_be(pvw_regs[1], pvw_regs[2]) if i32_valid(v) then pv_w = v end end -- MPPT1 current: 30769-30770, I32 BE × 0.001 A - local ok_m1a, m1a_regs = pcall(host.modbus_read, 30769, 2, "input") + local m1a_regs = probe_read(30769, 2, "input") local mppt1_a = 0 - if ok_m1a and m1a_regs then + if m1a_regs then local v = host.decode_i32_be(m1a_regs[1], m1a_regs[2]) if i32_valid(v) then mppt1_a = v * 0.001 end end -- MPPT1 voltage: 30771-30772, I32 BE × 0.01 V - local ok_m1v, m1v_regs = pcall(host.modbus_read, 30771, 2, "input") + local m1v_regs = probe_read(30771, 2, "input") local mppt1_v = 0 - if ok_m1v and m1v_regs then + if m1v_regs then local v = host.decode_i32_be(m1v_regs[1], m1v_regs[2]) if i32_valid(v) then mppt1_v = v * 0.01 end end -- MPPT2 current: 30957-30958, I32 BE × 0.001 A - local ok_m2a, m2a_regs = pcall(host.modbus_read, 30957, 2, "input") + local m2a_regs = probe_read(30957, 2, "input") local mppt2_a = 0 - if ok_m2a and m2a_regs then + if m2a_regs then local v = host.decode_i32_be(m2a_regs[1], m2a_regs[2]) if i32_valid(v) then mppt2_a = v * 0.001 end end -- MPPT2 voltage: 30959-30960, I32 BE × 0.01 V - local ok_m2v, m2v_regs = pcall(host.modbus_read, 30959, 2, "input") + local m2v_regs = probe_read(30959, 2, "input") local mppt2_v = 0 - if ok_m2v and m2v_regs then + if m2v_regs then local v = host.decode_i32_be(m2v_regs[1], m2v_regs[2]) if i32_valid(v) then mppt2_v = v * 0.01 end end -- PV lifetime generation: 30513-30516, U64 BE, Wh - local ok_pvgen, pvgen_regs = pcall(host.modbus_read, 30513, 4, "input") + local pvgen_regs = probe_read(30513, 4, "input") local pv_gen_wh = 0 - if ok_pvgen and pvgen_regs and u64_valid(pvgen_regs[1], pvgen_regs[2], pvgen_regs[3], pvgen_regs[4]) then + if pvgen_regs and u64_valid(pvgen_regs[1], pvgen_regs[2], pvgen_regs[3], pvgen_regs[4]) then pv_gen_wh = decode_u64_be(pvgen_regs[1], pvgen_regs[2], pvgen_regs[3], pvgen_regs[4]) end -- Inverter heatsink temp: 30953-30954, I32 BE × 0.1 C - local ok_itemp, itemp_regs = pcall(host.modbus_read, 30953, 2, "input") + local itemp_regs = probe_read(30953, 2, "input") local inv_temp = 0 - if ok_itemp and itemp_regs then + if itemp_regs then local v = host.decode_i32_be(itemp_regs[1], itemp_regs[2]) if i32_valid(v) then inv_temp = v * 0.1 end end -- Rated power: 31085-31086, U32 BE, watts - local ok_rated, rated_regs = pcall(host.modbus_read, 31085, 2, "input") + local rated_regs = probe_read(31085, 2, "input") local rated_w = 0 - if ok_rated and rated_regs then + if rated_regs then local v = host.decode_u32_be(rated_regs[1], rated_regs[2]) if u32_valid(v) then rated_w = v end end @@ -181,33 +211,33 @@ function driver_poll() ------------------------------------------------------------- Battery -- -- Battery current: 30843-30844, I32 BE × 0.001 A (+ = charging) - local ok_ba, ba_regs = pcall(host.modbus_read, 30843, 2, "input") + local ba_regs = probe_read(30843, 2, "input") local bat_a = 0 - if ok_ba and ba_regs then + if ba_regs then local v = host.decode_i32_be(ba_regs[1], ba_regs[2]) if i32_valid(v) then bat_a = v * 0.001 end end -- Battery SoC: 30845-30846, U32 BE, percent → fraction - local ok_bsoc, bsoc_regs = pcall(host.modbus_read, 30845, 2, "input") + local bsoc_regs = probe_read(30845, 2, "input") local bat_soc = 0 - if ok_bsoc and bsoc_regs then + if bsoc_regs then local v = host.decode_u32_be(bsoc_regs[1], bsoc_regs[2]) if u32_valid(v) then bat_soc = v / 100 end end -- Battery temperature: 30849-30850, I32 BE × 0.1 C - local ok_btemp, btemp_regs = pcall(host.modbus_read, 30849, 2, "input") + local btemp_regs = probe_read(30849, 2, "input") local bat_temp = 0 - if ok_btemp and btemp_regs then + if btemp_regs then local v = host.decode_i32_be(btemp_regs[1], btemp_regs[2]) if i32_valid(v) then bat_temp = v * 0.1 end end -- Battery voltage: 30851-30852, U32 BE × 0.01 V - local ok_bv, bv_regs = pcall(host.modbus_read, 30851, 2, "input") + local bv_regs = probe_read(30851, 2, "input") local bat_v = 0 - if ok_bv and bv_regs then + if bv_regs then local v = host.decode_u32_be(bv_regs[1], bv_regs[2]) if u32_valid(v) then bat_v = v * 0.01 end end @@ -217,16 +247,16 @@ function driver_poll() local bat_w = bat_v * bat_a -- Battery charge energy: 31397-31400, U64 BE, Wh - local ok_bchg, bchg_regs = pcall(host.modbus_read, 31397, 4, "input") + local bchg_regs = probe_read(31397, 4, "input") local bat_charge_wh = 0 - if ok_bchg and bchg_regs and u64_valid(bchg_regs[1], bchg_regs[2], bchg_regs[3], bchg_regs[4]) then + if bchg_regs and u64_valid(bchg_regs[1], bchg_regs[2], bchg_regs[3], bchg_regs[4]) then bat_charge_wh = decode_u64_be(bchg_regs[1], bchg_regs[2], bchg_regs[3], bchg_regs[4]) end -- Battery discharge energy: 31401-31404, U64 BE, Wh - local ok_bdis, bdis_regs = pcall(host.modbus_read, 31401, 4, "input") + local bdis_regs = probe_read(31401, 4, "input") local bat_discharge_wh = 0 - if ok_bdis and bdis_regs and u64_valid(bdis_regs[1], bdis_regs[2], bdis_regs[3], bdis_regs[4]) then + if bdis_regs and u64_valid(bdis_regs[1], bdis_regs[2], bdis_regs[3], bdis_regs[4]) then bat_discharge_wh = decode_u64_be(bdis_regs[1], bdis_regs[2], bdis_regs[3], bdis_regs[4]) end @@ -246,17 +276,17 @@ function driver_poll() --------------------------------------------------------------- Meter -- -- Meter total power: 30885-30886, I32 BE, watts (signed: + = import, - = export) - local ok_mw, mw_regs = pcall(host.modbus_read, 30885, 2, "input") + local mw_regs = probe_read(30885, 2, "input") local meter_w = 0 - if ok_mw and mw_regs then + if mw_regs then local v = host.decode_i32_be(mw_regs[1], mw_regs[2]) if i32_valid(v) then meter_w = v end end -- Per-phase meter power: 30887-30892, I32 BE pairs, watts (signed) - local ok_mpw, mpw_regs = pcall(host.modbus_read, 30887, 6, "input") + local mpw_regs = probe_read(30887, 6, "input") local l1_w, l2_w, l3_w = 0, 0, 0 - if ok_mpw and mpw_regs then + if mpw_regs then local v1 = host.decode_i32_be(mpw_regs[1], mpw_regs[2]) local v2 = host.decode_i32_be(mpw_regs[3], mpw_regs[4]) local v3 = host.decode_i32_be(mpw_regs[5], mpw_regs[6]) @@ -266,17 +296,17 @@ function driver_poll() end -- Grid frequency: 30901-30902, U32 BE × 0.01 Hz - local ok_hz, hz_regs = pcall(host.modbus_read, 30901, 2, "input") + local hz_regs = probe_read(30901, 2, "input") local hz = 0 - if ok_hz and hz_regs then + if hz_regs then local v = host.decode_u32_be(hz_regs[1], hz_regs[2]) if u32_valid(v) then hz = v * 0.01 end end -- Per-phase voltage: 30903-30908, U32 BE × 0.01 V pairs - local ok_lv, lv_regs = pcall(host.modbus_read, 30903, 6, "input") + local lv_regs = probe_read(30903, 6, "input") local l1_v, l2_v, l3_v = 0, 0, 0 - if ok_lv and lv_regs then + if lv_regs then local v1 = host.decode_u32_be(lv_regs[1], lv_regs[2]) local v2 = host.decode_u32_be(lv_regs[3], lv_regs[4]) local v3 = host.decode_u32_be(lv_regs[5], lv_regs[6]) @@ -286,9 +316,9 @@ function driver_poll() end -- Per-phase current: 30909-30914, U32 BE × 0.001 A pairs - local ok_la, la_regs = pcall(host.modbus_read, 30909, 6, "input") + local la_regs = probe_read(30909, 6, "input") local l1_a, l2_a, l3_a = 0, 0, 0 - if ok_la and la_regs then + if la_regs then local v1 = host.decode_u32_be(la_regs[1], la_regs[2]) local v2 = host.decode_u32_be(la_regs[3], la_regs[4]) local v3 = host.decode_u32_be(la_regs[5], la_regs[6]) @@ -298,17 +328,17 @@ function driver_poll() end -- Import energy: 30581-30582, U32 BE, Wh - local ok_imp, imp_regs = pcall(host.modbus_read, 30581, 2, "input") + local imp_regs = probe_read(30581, 2, "input") local import_wh = 0 - if ok_imp and imp_regs then + if imp_regs then local v = host.decode_u32_be(imp_regs[1], imp_regs[2]) if u32_valid(v) then import_wh = v end end -- Export energy: 30583-30584, U32 BE, Wh - local ok_exp, exp_regs = pcall(host.modbus_read, 30583, 2, "input") + local exp_regs = probe_read(30583, 2, "input") local export_wh = 0 - if ok_exp and exp_regs then + if exp_regs then local v = host.decode_u32_be(exp_regs[1], exp_regs[2]) if u32_valid(v) then export_wh = v end end diff --git a/drivers/sma_pv.lua b/drivers/sma_pv.lua index 4004af7d..3e2a78a9 100644 --- a/drivers/sma_pv.lua +++ b/drivers/sma_pv.lua @@ -14,7 +14,7 @@ DRIVER = { id = "sma_pv", name = "SMA PV inverter (non-hybrid)", manufacturer = "SMA", - version = "1.0.0", + version = "1.1.1", protocols = { "modbus" }, capabilities = { "pv", "meter", "pv_curtail" }, description = "SMA Sunny Tripower commercial string inverters (STP 50-40 / 50-41 / X-class) via Modbus TCP, with active-power curtailment over Modbus holding register 40915.", @@ -70,6 +70,34 @@ local function regs_all_ffff(regs, n) return true end +-- A register this inverter does not answer must not be read forever. The host +-- counts every failed modbus_read against the poll whether or not Lua caught +-- it, so one absent optional register -- a second MPPT on a single-string +-- model, say -- holds the driver at "1 of N reads failed" every five seconds +-- and the stale-telemetry watchdog takes the site offline while the driver is +-- still emitting good PV data. Ask three times, since a single miss can just +-- be a slow link, then leave the register alone. A restart re-probes, so a +-- firmware update that adds the register is picked up. +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( + "SMA_PV: register %d did not answer %d times; leaving it alone " .. + "until restart", addr, GIVE_UP_AFTER)) + end + return nil +end + ---------------------------------------------------------------------------- -- Control surface ---------------------------------------------------------------------------- @@ -107,6 +135,12 @@ local curtail_active = false -- Probe the inverter once to figure out which optional sections it populates. -- Called from driver_poll on the first cycle. +-- +-- The three reads below stay on a plain pcall rather than probe_read: each +-- runs once per restart and its answer is cached, so none of them can cost a +-- repeated failed read. Counting them would spend attempts on registers the +-- poll has not asked for yet. The serial read is the other way round -- the +-- poll retries it until it answers -- so that one is bounded. local function probe_capabilities() -- Meter: total power @ 30885 (I32). NaN here means no SMA Energy Meter / -- Sunny Home Manager is paired with the inverter — most commercial string @@ -146,8 +180,8 @@ function driver_init(config) -- SunSpec common model serial number: 30057-30058 (U32 BE). -- Best-effort; older SMA firmware can report 0 here — we fall back -- to make:endpoint identity in that case. - local ok, sn_regs = pcall(host.modbus_read, 30057, 2, "input") - if ok and sn_regs then + local sn_regs = probe_read(30057, 2, "input") + if sn_regs then local sn = host.decode_u32_be(sn_regs[1], sn_regs[2]) if u32_valid(sn) and sn > 0 then host.set_sn(tostring(sn)) @@ -158,10 +192,12 @@ end function driver_poll() -- Opportunistically retry SN if init couldn't read it (e.g. the - -- inverter was still booting). + -- inverter was still booting), but only a bounded number of times -- + -- an inverter that never reports a serial must not cost a failed read + -- on every poll for the rest of its life. if not sn_read then - local ok, sn_regs = pcall(host.modbus_read, 30057, 2, "input") - if ok and sn_regs then + local sn_regs = probe_read(30057, 2, "input") + if sn_regs then local sn = host.decode_u32_be(sn_regs[1], sn_regs[2]) if u32_valid(sn) and sn > 0 then host.set_sn(tostring(sn)) @@ -177,41 +213,41 @@ function driver_poll() ------------------------------------------------------------------ PV -- -- PV power: 30775-30776, I32 BE, watts - local ok_pvw, pvw_regs = pcall(host.modbus_read, 30775, 2, "input") + local pvw_regs = probe_read(30775, 2, "input") local pv_w = 0 - if ok_pvw and pvw_regs then + if pvw_regs then local v = host.decode_i32_be(pvw_regs[1], pvw_regs[2]) if i32_valid(v) then pv_w = v end end -- MPPT1 current: 30769-30770, I32 BE × 0.001 A - local ok_m1a, m1a_regs = pcall(host.modbus_read, 30769, 2, "input") + local m1a_regs = probe_read(30769, 2, "input") local mppt1_a = 0 - if ok_m1a and m1a_regs then + if m1a_regs then local v = host.decode_i32_be(m1a_regs[1], m1a_regs[2]) if i32_valid(v) then mppt1_a = v * 0.001 end end -- MPPT1 voltage: 30771-30772, I32 BE × 0.01 V - local ok_m1v, m1v_regs = pcall(host.modbus_read, 30771, 2, "input") + local m1v_regs = probe_read(30771, 2, "input") local mppt1_v = 0 - if ok_m1v and m1v_regs then + if m1v_regs then local v = host.decode_i32_be(m1v_regs[1], m1v_regs[2]) if i32_valid(v) then mppt1_v = v * 0.01 end end -- MPPT2 current: 30957-30958, I32 BE × 0.001 A - local ok_m2a, m2a_regs = pcall(host.modbus_read, 30957, 2, "input") + local m2a_regs = probe_read(30957, 2, "input") local mppt2_a = 0 - if ok_m2a and m2a_regs then + if m2a_regs then local v = host.decode_i32_be(m2a_regs[1], m2a_regs[2]) if i32_valid(v) then mppt2_a = v * 0.001 end end -- MPPT2 voltage: 30959-30960, I32 BE × 0.01 V - local ok_m2v, m2v_regs = pcall(host.modbus_read, 30959, 2, "input") + local m2v_regs = probe_read(30959, 2, "input") local mppt2_v = 0 - if ok_m2v and m2v_regs then + if m2v_regs then local v = host.decode_i32_be(m2v_regs[1], m2v_regs[2]) if i32_valid(v) then mppt2_v = v * 0.01 end end @@ -220,31 +256,31 @@ function driver_poll() -- probe_capabilities and cached — no per-poll exception handling needed. local pv_gen_wh = 0 if lifetime_reg == 30513 then - local ok_pvgen, pvgen_regs = pcall(host.modbus_read, 30513, 4, "input") - if ok_pvgen and pvgen_regs and u64_valid(pvgen_regs[1], pvgen_regs[2], pvgen_regs[3], pvgen_regs[4]) then + local pvgen_regs = probe_read(30513, 4, "input") + if pvgen_regs and u64_valid(pvgen_regs[1], pvgen_regs[2], pvgen_regs[3], pvgen_regs[4]) then pv_gen_wh = decode_u64_be(pvgen_regs[1], pvgen_regs[2], pvgen_regs[3], pvgen_regs[4]) end else - local ok_pvgen, pvgen_regs = pcall(host.modbus_read, 30529, 2, "input") - if ok_pvgen and pvgen_regs then + local pvgen_regs = probe_read(30529, 2, "input") + if pvgen_regs then local v = host.decode_u32_be(pvgen_regs[1], pvgen_regs[2]) if u32_valid(v) then pv_gen_wh = v end end end -- Inverter heatsink temp: 30953-30954, I32 BE × 0.1 C - local ok_itemp, itemp_regs = pcall(host.modbus_read, 30953, 2, "input") + local itemp_regs = probe_read(30953, 2, "input") local inv_temp = 0 - if ok_itemp and itemp_regs then + if itemp_regs then local v = host.decode_i32_be(itemp_regs[1], itemp_regs[2]) if i32_valid(v) then inv_temp = v * 0.1 end end -- Rated power: 31085-31086, U32 BE, watts. Note: CORE2 firmware can -- report 0 here — release_w from probe_capabilities is more reliable. - local ok_rated, rated_regs = pcall(host.modbus_read, 31085, 2, "input") + local rated_regs = probe_read(31085, 2, "input") local rated_w = 0 - if ok_rated and rated_regs then + if rated_regs then local v = host.decode_u32_be(rated_regs[1], rated_regs[2]) if u32_valid(v) then rated_w = v end end @@ -252,9 +288,9 @@ function driver_poll() -- Current event number (manufacturer). Useful diagnostic — non-zero -- values indicate the inverter has an active event/warning. The actual -- event description isn't exposed over Modbus on STP X / CORE2. - local ok_evt, evt_regs = pcall(host.modbus_read, 30247, 2, "input") + local evt_regs = probe_read(30247, 2, "input") local event_no = 0 - if ok_evt and evt_regs then + if evt_regs then local v = host.decode_u32_be(evt_regs[1], evt_regs[2]) if u32_valid(v) then event_no = v end end @@ -284,17 +320,17 @@ function driver_poll() if has_meter then -- Meter total power: 30885-30886, I32 BE, watts (signed: + = import, - = export) - local ok_mw, mw_regs = pcall(host.modbus_read, 30885, 2, "input") + local mw_regs = probe_read(30885, 2, "input") local meter_w = 0 - if ok_mw and mw_regs then + if mw_regs then local v = host.decode_i32_be(mw_regs[1], mw_regs[2]) if i32_valid(v) then meter_w = v end end -- Per-phase meter power: 30887-30892, I32 BE pairs, watts (signed) - local ok_mpw, mpw_regs = pcall(host.modbus_read, 30887, 6, "input") + local mpw_regs = probe_read(30887, 6, "input") local l1_w, l2_w, l3_w = 0, 0, 0 - if ok_mpw and mpw_regs then + if mpw_regs then local v1 = host.decode_i32_be(mpw_regs[1], mpw_regs[2]) local v2 = host.decode_i32_be(mpw_regs[3], mpw_regs[4]) local v3 = host.decode_i32_be(mpw_regs[5], mpw_regs[6]) @@ -304,17 +340,17 @@ function driver_poll() end -- Grid frequency: 30901-30902, U32 BE × 0.01 Hz - local ok_hz, hz_regs = pcall(host.modbus_read, 30901, 2, "input") + local hz_regs = probe_read(30901, 2, "input") local hz = 0 - if ok_hz and hz_regs then + if hz_regs then local v = host.decode_u32_be(hz_regs[1], hz_regs[2]) if u32_valid(v) then hz = v * 0.01 end end -- Per-phase voltage: 30903-30908, U32 BE × 0.01 V pairs - local ok_lv, lv_regs = pcall(host.modbus_read, 30903, 6, "input") + local lv_regs = probe_read(30903, 6, "input") local l1_v, l2_v, l3_v = 0, 0, 0 - if ok_lv and lv_regs then + if lv_regs then local v1 = host.decode_u32_be(lv_regs[1], lv_regs[2]) local v2 = host.decode_u32_be(lv_regs[3], lv_regs[4]) local v3 = host.decode_u32_be(lv_regs[5], lv_regs[6]) @@ -324,9 +360,9 @@ function driver_poll() end -- Per-phase current: 30909-30914, U32 BE × 0.001 A pairs - local ok_la, la_regs = pcall(host.modbus_read, 30909, 6, "input") + local la_regs = probe_read(30909, 6, "input") local l1_a, l2_a, l3_a = 0, 0, 0 - if ok_la and la_regs then + if la_regs then local v1 = host.decode_u32_be(la_regs[1], la_regs[2]) local v2 = host.decode_u32_be(la_regs[3], la_regs[4]) local v3 = host.decode_u32_be(la_regs[5], la_regs[6]) @@ -336,17 +372,17 @@ function driver_poll() end -- Import energy: 30581-30582, U32 BE, Wh - local ok_imp, imp_regs = pcall(host.modbus_read, 30581, 2, "input") + local imp_regs = probe_read(30581, 2, "input") local import_wh = 0 - if ok_imp and imp_regs then + if imp_regs then local v = host.decode_u32_be(imp_regs[1], imp_regs[2]) if u32_valid(v) then import_wh = v end end -- Export energy: 30583-30584, U32 BE, Wh - local ok_exp, exp_regs = pcall(host.modbus_read, 30583, 2, "input") + local exp_regs = probe_read(30583, 2, "input") local export_wh = 0 - if ok_exp and exp_regs then + if exp_regs then local v = host.decode_u32_be(exp_regs[1], exp_regs[2]) if u32_valid(v) then export_wh = v end end diff --git a/drivers/sofar.lua b/drivers/sofar.lua index f3d79d70..07a77a1e 100644 --- a/drivers/sofar.lua +++ b/drivers/sofar.lua @@ -26,7 +26,7 @@ DRIVER = { id = "sofar", name = "Sofar hybrid inverter", manufacturer = "Sofar Solar", - version = "1.0.0", + version = "2.1.1", protocols = { "modbus" }, capabilities = { "meter", "pv", "battery" }, description = "Sofar Solar HYD-ES / HYD-EP hybrid inverters via Modbus TCP.", @@ -43,6 +43,37 @@ DRIVER = { PROTOCOL = "modbus" +---------------------------------------------------------------------------- +-- Absent-register guard +---------------------------------------------------------------------------- +-- The host counts every failed modbus_read against the poll whether or not +-- Lua caught it. So a register this driver can live without must stop being +-- read once the device has made clear it will not answer — otherwise the +-- poll keeps failing, the stale-telemetry watchdog marks the device offline, +-- and the site reports nothing at all instead of one field less. +-- +-- Three strikes rather than one: a single miss is more often a slow link +-- than a missing register. 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( + "Sofar: register %d did not answer %d times; leaving it alone " .. + "until restart", addr, GIVE_UP_AFTER)) + end + return nil +end + ---------------------------------------------------------------------------- -- Initialization ---------------------------------------------------------------------------- @@ -60,39 +91,39 @@ function driver_poll() -- ---- PV ---- -- PV1 V/A: 1412-1413 (0x0584-0x0585). U16 × 0.1 V, U16 × 0.01 A. - local ok_pv1, pv1_regs = pcall(host.modbus_read, 1412, 2, "holding") + local pv1_regs = probe_read(1412, 2, "holding") local mppt1_v, mppt1_a = 0, 0 - if ok_pv1 and pv1_regs then + if pv1_regs then mppt1_v = pv1_regs[1] * 0.1 mppt1_a = pv1_regs[2] * 0.01 end -- PV total power: 1414 (0x0586). U16, 10 W resolution. - local ok_pvw, pvw_regs = pcall(host.modbus_read, 1414, 1, "holding") + local pvw_regs = probe_read(1414, 1, "holding") local pv_w = 0 - if ok_pvw and pvw_regs then + if pvw_regs then pv_w = pvw_regs[1] * 10 end -- PV2 V/A: 1415-1416 (0x0587-0x0588). U16 × 0.1 V, U16 × 0.01 A. - local ok_pv2, pv2_regs = pcall(host.modbus_read, 1415, 2, "holding") + local pv2_regs = probe_read(1415, 2, "holding") local mppt2_v, mppt2_a = 0, 0 - if ok_pv2 and pv2_regs then + if pv2_regs then mppt2_v = pv2_regs[1] * 0.1 mppt2_a = pv2_regs[2] * 0.01 end -- Grid frequency: 524 (0x020C). U16 × 0.01 Hz. - local ok_hz, hz_regs = pcall(host.modbus_read, 524, 1, "holding") + local hz_regs = probe_read(524, 1, "holding") local hz = 0 - if ok_hz and hz_regs then + if hz_regs then hz = hz_regs[1] * 0.01 end -- PV lifetime yield: 1668-1669 (0x0684-0x0685). U32 BE × 0.1 kWh. - local ok_pvgen, pvgen_regs = pcall(host.modbus_read, 1668, 2, "holding") + local pvgen_regs = probe_read(1668, 2, "holding") local pv_gen_wh = 0 - if ok_pvgen and pvgen_regs then + if pvgen_regs then pv_gen_wh = host.decode_u32_be(pvgen_regs[1], pvgen_regs[2]) * 0.1 * 1000 end @@ -114,38 +145,38 @@ function driver_poll() -- ---- Battery ---- -- Battery voltage: 1540 (0x0604). U16 × 0.1 V. - local ok_bv, bv_regs = pcall(host.modbus_read, 1540, 1, "holding") + local bv_regs = probe_read(1540, 1, "holding") local bat_v = 0 - if ok_bv and bv_regs then + if bv_regs then bat_v = bv_regs[1] * 0.1 end -- Battery current: 1541 (0x0605). I16 × 0.01 A. - local ok_ba, ba_regs = pcall(host.modbus_read, 1541, 1, "holding") + local ba_regs = probe_read(1541, 1, "holding") local bat_a = 0 - if ok_ba and ba_regs then + if ba_regs then bat_a = host.decode_i16(ba_regs[1]) * 0.01 end -- Battery power: 1542 (0x0606). I16, 10 W resolution. -- Sofar sign: positive = charge, negative = discharge (matches site convention). - local ok_bw, bw_regs = pcall(host.modbus_read, 1542, 1, "holding") + local bw_regs = probe_read(1542, 1, "holding") local bat_w = 0 - if ok_bw and bw_regs then + if bw_regs then bat_w = host.decode_i16(bw_regs[1]) * 10 end -- Battery temperature: 1543 (0x0607). I16, °C. - local ok_btemp, btemp_regs = pcall(host.modbus_read, 1543, 1, "holding") + local btemp_regs = probe_read(1543, 1, "holding") local bat_temp = 0 - if ok_btemp and btemp_regs then + if btemp_regs then bat_temp = host.decode_i16(btemp_regs[1]) end -- Battery SoC: 1544 (0x0608). U16 percent → 0..1 fraction. - local ok_bsoc, bsoc_regs = pcall(host.modbus_read, 1544, 1, "holding") + local bsoc_regs = probe_read(1544, 1, "holding") local bat_soc = 0 - if ok_bsoc and bsoc_regs then + if bsoc_regs then bat_soc = bsoc_regs[1] / 100 end @@ -164,18 +195,18 @@ function driver_poll() -- Grid total power: 530 (0x0212). I16, 10 W resolution. -- Sofar sign: positive = import (matches site convention). - local ok_mw, mw_regs = pcall(host.modbus_read, 530, 1, "holding") + local mw_regs = probe_read(530, 1, "holding") local meter_w = 0 - if ok_mw and mw_regs then + if mw_regs then meter_w = host.decode_i16(mw_regs[1]) * 10 end -- Per-phase V/A interleaved: 518-523 (0x0206-0x020B). -- 518 L1_v, 519 L1_a, 520 L2_v, 521 L2_a, 522 L3_v, 523 L3_a -- voltages U16 × 0.1 V, currents U16 × 0.01 A. - local ok_phase, phase_regs = pcall(host.modbus_read, 518, 6, "holding") + local phase_regs = probe_read(518, 6, "holding") local l1_v, l1_a, l2_v, l2_a, l3_v, l3_a = 0, 0, 0, 0, 0, 0 - if ok_phase and phase_regs then + if phase_regs then l1_v = phase_regs[1] * 0.1 l1_a = phase_regs[2] * 0.01 l2_v = phase_regs[3] * 0.1 @@ -185,16 +216,16 @@ function driver_poll() end -- Import energy: 1672-1673 (0x0688-0x0689). U32 BE × 0.1 kWh. - local ok_imp, imp_regs = pcall(host.modbus_read, 1672, 2, "holding") + local imp_regs = probe_read(1672, 2, "holding") local import_wh = 0 - if ok_imp and imp_regs then + if imp_regs then import_wh = host.decode_u32_be(imp_regs[1], imp_regs[2]) * 0.1 * 1000 end -- Export energy: 1674-1675 (0x068A-0x068B). U32 BE × 0.1 kWh. - local ok_exp, exp_regs = pcall(host.modbus_read, 1674, 2, "holding") + local exp_regs = probe_read(1674, 2, "holding") local export_wh = 0 - if ok_exp and exp_regs then + if exp_regs then export_wh = host.decode_u32_be(exp_regs[1], exp_regs[2]) * 0.1 * 1000 end diff --git a/drivers/solaredge.lua b/drivers/solaredge.lua index d0a65e5d..0ef95b7d 100644 --- a/drivers/solaredge.lua +++ b/drivers/solaredge.lua @@ -19,7 +19,7 @@ DRIVER = { id = "solaredge", name = "SolarEdge inverter + meter", manufacturer = "SolarEdge", - version = "1.1.0", + version = "2.1.1", protocols = { "modbus" }, capabilities = { "meter", "pv", "pv-curtail" }, description = "SolarEdge HD-Wave / StorEdge via Modbus TCP (SunSpec) with PV active-power-limit curtail.", @@ -146,12 +146,43 @@ local function scale(value, sf) return value * pow10(sf) end +-- A register the inverter 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_fingerprint has its own unbounded reads on +-- purpose (see the note there), and the curtail path writes rather than reads, +-- so nothing here can veto a 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( + "SolarEdge: register %d did not answer %d times; leaving it alone " .. + "until restart", addr, GIVE_UP_AFTER)) + end + return nil +end + -- Read a single register and return it as an i16 (signed) scale factor. -- Returns 0 on read failure so downstream scaling becomes a no-op (the -- caller will get raw register values until the next retry). local function read_sf(addr) - local ok, regs = pcall(host.modbus_read, addr, 1, "input") - if ok and regs then + local regs = probe_read(addr, 1, "input") + if regs then return host.decode_i16(regs[1]) end return 0 @@ -201,6 +232,11 @@ end -- false → responded, but it's a non-SolarEdge device (no SunSpec marker, -- or a SunSpec marker from another vendor) -- nil → couldn't read (wrong unit id, not Modbus, timeout) → inconclusive +-- +-- These reads deliberately do NOT go through probe_read. Nothing here runs +-- from driver_poll, so a failure costs no failed poll; and this is the probe +-- that decides whether the device is a SolarEdge at all. Bounding it would let +-- three transient timeouts make the device permanently undetectable. function driver_fingerprint() -- SunSpec identifier "SunS" lives at 40000-40001 (0x5375, 0x6E53). local ok, sid = pcall(host.modbus_read, 40000, 2, "input") @@ -248,8 +284,8 @@ end function driver_poll() -- ---- Serial number (SunSpec common block, one-shot) ---- if not sn_read then - local ok_sn, sn_regs = pcall(host.modbus_read, 40052, 16, "input") - if ok_sn and sn_regs then + local sn_regs = probe_read(40052, 16, "input") + if sn_regs then local sn = decode_ascii(sn_regs, 16) if #sn > 0 then host.set_sn(sn) @@ -291,45 +327,45 @@ function driver_poll() -- ---- Inverter AC ---- -- AC power: 40083, I16 - local ok_acw, acw_regs = pcall(host.modbus_read, 40083, 1, "input") + local acw_regs = probe_read(40083, 1, "input") local ac_w = 0 - if ok_acw and acw_regs then + if acw_regs then ac_w = scale(host.decode_i16(acw_regs[1]), sf.ac_power) end -- Frequency: 40085, U16 - local ok_hz, hz_regs = pcall(host.modbus_read, 40085, 1, "input") + local hz_regs = probe_read(40085, 1, "input") local hz = 0 - if ok_hz and hz_regs then + if hz_regs then hz = scale(hz_regs[1], sf.hz) end -- Lifetime energy: 40093-40094, U32 BE (Wh once scaled) - local ok_le, le_regs = pcall(host.modbus_read, 40093, 2, "input") + local le_regs = probe_read(40093, 2, "input") local lifetime_wh = 0 - if ok_le and le_regs then + if le_regs then lifetime_wh = scale(host.decode_u32_be(le_regs[1], le_regs[2]), sf.energy) end -- Heat-sink temperature: 40103, I16 - local ok_temp, temp_regs = pcall(host.modbus_read, 40103, 1, "input") + local temp_regs = probe_read(40103, 1, "input") local temp_c = 0 - if ok_temp and temp_regs then + if temp_regs then temp_c = scale(host.decode_i16(temp_regs[1]), sf.temp) end -- MPPT1 A/V: 40140-40141, U16 each - local ok_m1, m1_regs = pcall(host.modbus_read, 40140, 2, "input") + local m1_regs = probe_read(40140, 2, "input") local mppt1_a, mppt1_v = 0, 0 - if ok_m1 and m1_regs then + if m1_regs then mppt1_a = scale(m1_regs[1], sf.mppt_a) mppt1_v = scale(m1_regs[2], sf.mppt_v) end -- MPPT2 A/V: 40160-40161, U16 each - local ok_m2, m2_regs = pcall(host.modbus_read, 40160, 2, "input") + local m2_regs = probe_read(40160, 2, "input") local mppt2_a, mppt2_v = 0, 0 - if ok_m2 and m2_regs then + if m2_regs then mppt2_a = scale(m2_regs[1], sf.mppt_a) mppt2_v = scale(m2_regs[2], sf.mppt_v) end @@ -354,50 +390,50 @@ function driver_poll() -- ---- Meter ---- -- Total W: 40100, I16 - local ok_mw, mw_regs = pcall(host.modbus_read, 40100, 1, "input") + local mw_regs = probe_read(40100, 1, "input") local meter_w = 0 - if ok_mw and mw_regs then + if mw_regs then meter_w = scale(host.decode_i16(mw_regs[1]), sf.meter_w) end -- Per-phase current: 40191-40193, I16 each - local ok_la, la_regs = pcall(host.modbus_read, 40191, 3, "input") + local la_regs = probe_read(40191, 3, "input") local l1_a, l2_a, l3_a = 0, 0, 0 - if ok_la and la_regs then + if la_regs then l1_a = scale(host.decode_i16(la_regs[1]), sf.meter_a) l2_a = scale(host.decode_i16(la_regs[2]), sf.meter_a) l3_a = scale(host.decode_i16(la_regs[3]), sf.meter_a) end -- Per-phase voltage: 40196-40198, I16 each - local ok_lv, lv_regs = pcall(host.modbus_read, 40196, 3, "input") + local lv_regs = probe_read(40196, 3, "input") local l1_v, l2_v, l3_v = 0, 0, 0 - if ok_lv and lv_regs then + if lv_regs then l1_v = scale(host.decode_i16(lv_regs[1]), sf.meter_v) l2_v = scale(host.decode_i16(lv_regs[2]), sf.meter_v) l3_v = scale(host.decode_i16(lv_regs[3]), sf.meter_v) end -- Per-phase power: 40207-40209, I16 each - local ok_lw, lw_regs = pcall(host.modbus_read, 40207, 3, "input") + local lw_regs = probe_read(40207, 3, "input") local l1_w, l2_w, l3_w = 0, 0, 0 - if ok_lw and lw_regs then + if lw_regs then l1_w = scale(host.decode_i16(lw_regs[1]), sf.phase_w) l2_w = scale(host.decode_i16(lw_regs[2]), sf.phase_w) l3_w = scale(host.decode_i16(lw_regs[3]), sf.phase_w) end -- Export energy: 40226-40227, U32 BE - local ok_exp, exp_regs = pcall(host.modbus_read, 40226, 2, "input") + local exp_regs = probe_read(40226, 2, "input") local export_wh = 0 - if ok_exp and exp_regs then + if exp_regs then export_wh = scale(host.decode_u32_be(exp_regs[1], exp_regs[2]), sf.meter_energy) end -- Import energy: 40234-40235, U32 BE - local ok_imp, imp_regs = pcall(host.modbus_read, 40234, 2, "input") + local imp_regs = probe_read(40234, 2, "input") local import_wh = 0 - if ok_imp and imp_regs then + if imp_regs then import_wh = scale(host.decode_u32_be(imp_regs[1], imp_regs[2]), sf.meter_energy) end diff --git a/drivers/solaredge_legacy.lua b/drivers/solaredge_legacy.lua index 942333c1..6baa297d 100644 --- a/drivers/solaredge_legacy.lua +++ b/drivers/solaredge_legacy.lua @@ -33,7 +33,7 @@ DRIVER = { id = "solaredge-legacy", name = "SolarEdge legacy (K-series with display)", manufacturer = "SolarEdge", - version = "0.2.0", + version = "0.3.2", protocols = { "modbus" }, capabilities = { "pv", "pv-curtail" }, description = "SolarEdge K-series (SE7K / SE10K / SE17K / SE25K) PV inverter via Modbus TCP — reads use FC 0x03 holding; curtail writes use FC 0x10 multi-holding on the same Advanced Power Control registers (0xF000/0xF001) as HD-Wave.", @@ -90,12 +90,60 @@ PROTOCOL = "modbus" local sn_read = false local sunspec_ok = nil -- true / false / nil (= not yet probed) +-- Whether this firmware exposes the proprietary MPPT block at 40123. +-- nil = not probed yet, true = present, false = confirmed absent +-- +-- K-series units (SE7K/10K/17K/25K, the display era) answer Modbus +-- exception 2 there — the registers are simply not populated. The +-- pcall below already treats that as "no MPPT metrics, carry on with +-- Model 103", which is right at the Lua level. But the host counts the +-- failed modbus_read toward the driver-poll error tally regardless, so +-- retrying it every poll holds the driver at "1 of 2 reads failed" +-- until the stale-telemetry watchdog marks it offline and PV data +-- stops reaching the planner. Probe once, then stop issuing a read we +-- know will fail. A restart re-probes. +local mppt_present = nil + -- Curtail state — see header comment for the protocol. local nominal_w = 0 local curtail_active = false local REG_APC_ENABLE = 61440 -- 0xF000 local REG_APC_LIMIT = 61441 -- 0xF001 +---------------------------------------------------------------------------- +-- Absent-register guard +---------------------------------------------------------------------------- +-- Same reasoning as mppt_present above, generalised: the host counts every +-- failed modbus_read against the poll whether or not Lua caught it, so an +-- optional register this driver can live without must stop being read once +-- the inverter has made clear it will not answer. +-- +-- Three strikes rather than one: a single miss is more often a slow link +-- than a missing register. A restart re-probes. +-- +-- This guard is for reads the driver survives. It is deliberately NOT used +-- for the SunSpec probe at 40000 or the Model 103 block at 40069 — see the +-- comments at those call sites. +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( + "SolarEdge legacy: register %d did not answer %d times; leaving " .. + "it alone until restart", addr, GIVE_UP_AFTER)) + end + return nil +end + ---------------------------------------------------------------------------- -- SunSpec helpers ---------------------------------------------------------------------------- @@ -122,6 +170,13 @@ end -- length); 40069..40120 (52 regs) covers the header plus everything -- we care about: AC W/V/A + SFs, energy + SF, heat-sink temp + SF. -- Returns the raw register slice or nil on error. +-- +-- Deliberately NOT routed through probe_read. When this block fails the +-- driver emits nothing at all and returns early, so the failed read is the +-- honest report of an unreadable inverter, not a cost paid for a field we +-- decided to skip. Giving up on it after three misses would silence the +-- driver permanently on three transient blips, with no way back short of a +-- restart. Retrying forever is right here. local function read_inverter_block() local ok, regs = pcall(host.modbus_read, 40069, 52, "holding") if not ok or not regs then return nil end @@ -172,6 +227,12 @@ end -- confirmed SunSpec; subsequent calls short-circuit. Failure is sticky -- *for this poll* but we re-probe on later polls in case the link was -- transiently slow during the first attempt. +-- +-- Deliberately NOT routed through probe_read, for the same reason as +-- read_inverter_block: a failure here gates the whole poll, so the driver +-- emits nothing and the failed read is honest. Capping the retries would +-- turn three slow polls into permanent silence until someone restarts the +-- daemon. Retrying forever is the only way back. local function probe_sunspec() if sunspec_ok == true then return true end local ok, regs = pcall(host.modbus_read, 40000, 2, "holding") @@ -205,9 +266,12 @@ function driver_poll() end -- ---- Serial number (SunSpec common block, one-shot) ---- + -- Bounded: the driver emits PV happily without a serial number, so a + -- firmware that leaves this block empty must not cost a failed read on + -- every poll forever. if not sn_read then - local ok_sn, sn_regs = pcall(host.modbus_read, 40052, 16, "holding") - if ok_sn and sn_regs then + local sn_regs = probe_read(40052, 16, "holding") + if sn_regs then local sn = decode_ascii(sn_regs, 16) if #sn > 0 then host.set_sn(sn) @@ -254,17 +318,25 @@ function driver_poll() -- (40160-40161). reg_off below converts a doc address into the -- 1-based block index. local mppt1_a, mppt1_v, mppt2_a, mppt2_v = 0, 0, 0, 0 - local ok_mppt, mppt_regs = pcall(host.modbus_read, 40123, 39, "holding") - if ok_mppt and mppt_regs and #mppt_regs >= 39 then - local function reg_off(addr) return mppt_regs[addr - 40123 + 1] end - local mppt_a_sf = host.decode_i16(reg_off(40123)) - local mppt_v_sf = host.decode_i16(reg_off(40124)) - mppt1_a = scale(reg_off(40140), mppt_a_sf) - mppt1_v = scale(reg_off(40141), mppt_v_sf) - -- Single-string K-series units return zeros for MPPT2; that's - -- the correct emit, no warning needed. - mppt2_a = scale(reg_off(40160), mppt_a_sf) - mppt2_v = scale(reg_off(40161), mppt_v_sf) + if mppt_present ~= false then + local ok_mppt, mppt_regs = pcall(host.modbus_read, 40123, 39, "holding") + if ok_mppt and mppt_regs and #mppt_regs >= 39 then + mppt_present = true + local function reg_off(addr) return mppt_regs[addr - 40123 + 1] end + local mppt_a_sf = host.decode_i16(reg_off(40123)) + local mppt_v_sf = host.decode_i16(reg_off(40124)) + mppt1_a = scale(reg_off(40140), mppt_a_sf) + mppt1_v = scale(reg_off(40141), mppt_v_sf) + -- Single-string K-series units return zeros for MPPT2; that's + -- the correct emit, no warning needed. + mppt2_a = scale(reg_off(40160), mppt_a_sf) + mppt2_v = scale(reg_off(40161), mppt_v_sf) + elseif mppt_present == nil then + mppt_present = false + host.log("info", + "SolarEdge legacy: the MPPT block at 40123 is not readable on " .. + "this firmware; emitting Model 103 metrics only. Re-probed on restart.") + end end -- Site convention: generation is negative W. diff --git a/drivers/solaredge_pv.lua b/drivers/solaredge_pv.lua index f283bcd9..a9bbd67a 100644 --- a/drivers/solaredge_pv.lua +++ b/drivers/solaredge_pv.lua @@ -13,7 +13,7 @@ DRIVER = { id = "solaredge-pv", name = "SolarEdge inverter (PV only)", manufacturer = "SolarEdge", - version = "1.1.0", + version = "1.2.1", protocols = { "modbus" }, capabilities = { "pv", "pv-curtail" }, description = "SolarEdge HD-Wave / StorEdge PV-only via Modbus TCP (SunSpec) with PV active-power-limit curtail.", @@ -95,9 +95,47 @@ local function scale(value, sf) return value * pow10(sf) end +-- A register the inverter 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. +-- +-- The Model 103 block read below is deliberately left out of this: when it +-- fails the driver emits nothing at all, which is the honest outcome and costs +-- no false "device is fine" claim. Bounding it would silence the driver +-- permanently after three transient blips instead of letting it recover. +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( + "SolarEdge-PV: register %d did not answer %d times; leaving it alone " .. + "until restart", addr, GIVE_UP_AFTER)) + end + return nil +end + -- Read a contiguous Model 103 block starting at 40069 so every value -- and every paired scale factor come from the same Modbus transaction. -- Returns the raw register slice, or nil on error. +-- +-- NOT bounded by probe_read on purpose. This is the driver's whole data +-- block: when it does not answer, driver_poll returns without emitting, so the +-- failing read is already the honest report that the device is unreadable, not +-- a claim that everything is fine. A device that recovers must be able to come +-- back on the next poll. local function read_inverter_block() -- 40069..40120 covers the header (id + len) + everything we care -- about: AC V/A/W + SFs, DC V/A/W + SFs, temperatures + SF, state. @@ -145,8 +183,8 @@ end function driver_poll() -- ---- Serial number (SunSpec common block, one-shot) ---- if not sn_read then - local ok_sn, sn_regs = pcall(host.modbus_read, 40052, 16, "holding") - if ok_sn and sn_regs then + local sn_regs = probe_read(40052, 16, "holding") + if sn_regs then local sn = decode_ascii(sn_regs, 16) if #sn > 0 then host.set_sn(sn) @@ -182,20 +220,20 @@ function driver_poll() -- block past the standard inverter model), so they need their own -- reads. These are occasional / optional, so failures are silent. local mppt_a_sf, mppt_v_sf = 0, 0 - local ok_mppt_sf, mppt_sf_regs = pcall(host.modbus_read, 40123, 2, "holding") - if ok_mppt_sf and mppt_sf_regs then + local mppt_sf_regs = probe_read(40123, 2, "holding") + if mppt_sf_regs then mppt_a_sf = host.decode_i16(mppt_sf_regs[1]) mppt_v_sf = host.decode_i16(mppt_sf_regs[2]) end local mppt1_a, mppt1_v = 0, 0 - local ok_m1, m1_regs = pcall(host.modbus_read, 40140, 2, "holding") - if ok_m1 and m1_regs then + local m1_regs = probe_read(40140, 2, "holding") + if m1_regs then mppt1_a = scale(m1_regs[1], mppt_a_sf) mppt1_v = scale(m1_regs[2], mppt_v_sf) end local mppt2_a, mppt2_v = 0, 0 - local ok_m2, m2_regs = pcall(host.modbus_read, 40160, 2, "holding") - if ok_m2 and m2_regs then + local m2_regs = probe_read(40160, 2, "holding") + if m2_regs then mppt2_a = scale(m2_regs[1], mppt_a_sf) mppt2_v = scale(m2_regs[2], mppt_v_sf) end diff --git a/drivers/solis.lua b/drivers/solis.lua index 8728d5ca..347a622b 100644 --- a/drivers/solis.lua +++ b/drivers/solis.lua @@ -53,7 +53,7 @@ DRIVER = { id = "solis", name = "Solis hybrid inverter", manufacturer = "Ginlong Solis", - version = "1.0.0", + version = "2.1.1", protocols = { "modbus" }, capabilities = { "meter", "pv", "battery" }, description = "Solis S5/S6 hybrid inverters via Modbus TCP.", @@ -75,6 +75,40 @@ local sn_read = false local control_initialized = false local rated_power_w = 5000 -- default; operator can override via config.rated_w +---------------------------------------------------------------------------- +-- Register probing +---------------------------------------------------------------------------- + +-- Registers this device has stopped answering. +-- +-- The host counts every failed host.modbus_read against the poll whether or +-- not this driver caught the error — "driver_poll: N of M modbus reads +-- failed". So a register retried on every poll costs a failed poll on every +-- poll, and the stale-telemetry watchdog takes the driver offline. The site +-- then reports nothing at all, which is worse than reporting one field less. +-- +-- Three attempts absorb a transient blip; after that we stop asking. A +-- restart re-probes, so firmware that gains the register is picked up. +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( + "Solis: register %d did not answer %d times; leaving it alone " .. + "until restart", addr, GIVE_UP_AFTER)) + end + return nil +end + ---------------------------------------------------------------------------- -- Initialization ---------------------------------------------------------------------------- @@ -93,8 +127,8 @@ end local function try_read_sn() if sn_read then return end - local ok, sn_regs = pcall(host.modbus_read, 33004, 16, "input") - if not (ok and sn_regs) then return end + local sn_regs = probe_read(33004, 16, "input") + if not sn_regs then return end local sn = "" for i = 1, 16 do local hi = math.floor(sn_regs[i] / 256) @@ -123,39 +157,39 @@ function driver_poll() ------------------------------------------------------------------------ -- PV DC power: 33057-33058, U32 BE, W - local ok_pvw, pvw_regs = pcall(host.modbus_read, 33057, 2, "input") + local pvw_regs = probe_read(33057, 2, "input") local pv_w = 0 - if ok_pvw and pvw_regs then + if pvw_regs then pv_w = host.decode_u32_be(pvw_regs[1], pvw_regs[2]) end -- MPPT1 V/A: 33049-33050 - local ok_mppt1, mppt1_regs = pcall(host.modbus_read, 33049, 2, "input") + local mppt1_regs = probe_read(33049, 2, "input") local mppt1_v, mppt1_a = 0, 0 - if ok_mppt1 and mppt1_regs then + if mppt1_regs then mppt1_v = mppt1_regs[1] * 0.1 mppt1_a = mppt1_regs[2] * 0.1 end -- MPPT2 V/A: 33051-33052 - local ok_mppt2, mppt2_regs = pcall(host.modbus_read, 33051, 2, "input") + local mppt2_regs = probe_read(33051, 2, "input") local mppt2_v, mppt2_a = 0, 0 - if ok_mppt2 and mppt2_regs then + if mppt2_regs then mppt2_v = mppt2_regs[1] * 0.1 mppt2_a = mppt2_regs[2] * 0.1 end -- PV lifetime energy: 33029-33030, U32 BE × 1 kWh - local ok_pvgen, pvgen_regs = pcall(host.modbus_read, 33029, 2, "input") + local pvgen_regs = probe_read(33029, 2, "input") local pv_gen_wh = 0 - if ok_pvgen and pvgen_regs then + if pvgen_regs then pv_gen_wh = host.decode_u32_be(pvgen_regs[1], pvgen_regs[2]) * 1000 end -- Inverter temperature: 33093, I16 × 0.1 C - local ok_itemp, itemp_regs = pcall(host.modbus_read, 33093, 1, "input") + local itemp_regs = probe_read(33093, 1, "input") local inv_temp = 0 - if ok_itemp and itemp_regs then + if itemp_regs then inv_temp = host.decode_i16(itemp_regs[1]) * 0.1 end @@ -179,37 +213,37 @@ function driver_poll() ------------------------------------------------------------------------ -- Voltage: 33133, U16 × 0.1 V - local ok_bv, bv_regs = pcall(host.modbus_read, 33133, 1, "input") + local bv_regs = probe_read(33133, 1, "input") local bat_v = 0 - if ok_bv and bv_regs then + if bv_regs then bat_v = bv_regs[1] * 0.1 end -- Current: 33134, I16 × 0.1 A - local ok_ba, ba_regs = pcall(host.modbus_read, 33134, 1, "input") + local ba_regs = probe_read(33134, 1, "input") local bat_a = 0 - if ok_ba and ba_regs then + if ba_regs then bat_a = host.decode_i16(ba_regs[1]) * 0.1 end -- Direction: 33135, U16 (0=charge, 1=discharge) - local ok_bdir, bdir_regs = pcall(host.modbus_read, 33135, 1, "input") + local bdir_regs = probe_read(33135, 1, "input") local bat_direction = 0 - if ok_bdir and bdir_regs then + if bdir_regs then bat_direction = bdir_regs[1] end -- SoC: 33139, U16 percent (0-100) → fraction (0-1) - local ok_bsoc, bsoc_regs = pcall(host.modbus_read, 33139, 1, "input") + local bsoc_regs = probe_read(33139, 1, "input") local bat_soc = 0 - if ok_bsoc and bsoc_regs then + if bsoc_regs then bat_soc = bsoc_regs[1] / 100 end -- Power magnitude: 33149-33150, I32 BE, W - local ok_bw, bw_regs = pcall(host.modbus_read, 33149, 2, "input") + local bw_regs = probe_read(33149, 2, "input") local bat_w = 0 - if ok_bw and bw_regs then + if bw_regs then bat_w = host.decode_i32_be(bw_regs[1], bw_regs[2]) end -- Apply direction: 1 = discharge (negative in site convention) @@ -218,23 +252,23 @@ function driver_poll() end -- Charge energy: 33161-33162, U32 BE × 1 kWh - local ok_bchg, bchg_regs = pcall(host.modbus_read, 33161, 2, "input") + local bchg_regs = probe_read(33161, 2, "input") local bat_charge_wh = 0 - if ok_bchg and bchg_regs then + if bchg_regs then bat_charge_wh = host.decode_u32_be(bchg_regs[1], bchg_regs[2]) * 1000 end -- Discharge energy: 33165-33166, U32 BE × 1 kWh - local ok_bdis, bdis_regs = pcall(host.modbus_read, 33165, 2, "input") + local bdis_regs = probe_read(33165, 2, "input") local bat_discharge_wh = 0 - if ok_bdis and bdis_regs then + if bdis_regs then bat_discharge_wh = host.decode_u32_be(bdis_regs[1], bdis_regs[2]) * 1000 end -- Battery temperature: 33096, I16 × 0.1 C - local ok_btemp, btemp_regs = pcall(host.modbus_read, 33096, 1, "input") + local btemp_regs = probe_read(33096, 1, "input") local bat_temp = 0 - if ok_btemp and btemp_regs then + if btemp_regs then bat_temp = host.decode_i16(btemp_regs[1]) * 0.1 end @@ -256,9 +290,9 @@ function driver_poll() ------------------------------------------------------------------------ -- Per-phase V/A: 33251-33256 (V×0.1, A×0.01 interleaved) - local ok_mva, mva_regs = pcall(host.modbus_read, 33251, 6, "input") + local mva_regs = probe_read(33251, 6, "input") local l1_v, l1_a, l2_v, l2_a, l3_v, l3_a = 0, 0, 0, 0, 0, 0 - if ok_mva and mva_regs then + if mva_regs then l1_v = mva_regs[1] * 0.1 l1_a = mva_regs[2] * 0.01 l2_v = mva_regs[3] * 0.1 @@ -270,9 +304,9 @@ function driver_poll() -- Per-phase power: 33257-33262, I32 BE each pair -- Solis vendor sign: positive = export (out of grid meter). -- Site convention: positive = import. Flip sign at the boundary. - local ok_mpw, mpw_regs = pcall(host.modbus_read, 33257, 6, "input") + local mpw_regs = probe_read(33257, 6, "input") local l1_w, l2_w, l3_w = 0, 0, 0 - if ok_mpw and mpw_regs then + if mpw_regs then l1_w = -host.decode_i32_be(mpw_regs[1], mpw_regs[2]) l2_w = -host.decode_i32_be(mpw_regs[3], mpw_regs[4]) l3_w = -host.decode_i32_be(mpw_regs[5], mpw_regs[6]) @@ -280,23 +314,23 @@ function driver_poll() local meter_w = l1_w + l2_w + l3_w -- Frequency: 33282, U16 × 0.01 Hz - local ok_hz, hz_regs = pcall(host.modbus_read, 33282, 1, "input") + local hz_regs = probe_read(33282, 1, "input") local hz = 0 - if ok_hz and hz_regs then + if hz_regs then hz = hz_regs[1] * 0.01 end -- Import: 33283-33284, U32 BE × 0.01 kWh - local ok_imp, imp_regs = pcall(host.modbus_read, 33283, 2, "input") + local imp_regs = probe_read(33283, 2, "input") local import_wh = 0 - if ok_imp and imp_regs then + if imp_regs then import_wh = host.decode_u32_be(imp_regs[1], imp_regs[2]) * 0.01 * 1000 end -- Export: 33285-33286, U32 BE × 0.01 kWh - local ok_exp, exp_regs = pcall(host.modbus_read, 33285, 2, "input") + local exp_regs = probe_read(33285, 2, "input") local export_wh = 0 - if ok_exp and exp_regs then + if exp_regs then export_wh = host.decode_u32_be(exp_regs[1], exp_regs[2]) * 0.01 * 1000 end diff --git a/drivers/solis_string.lua b/drivers/solis_string.lua index ad35d9c6..4a17a386 100644 --- a/drivers/solis_string.lua +++ b/drivers/solis_string.lua @@ -11,7 +11,7 @@ DRIVER = { id = "solis-string", name = "Solis string inverter", manufacturer = "Ginlong Solis", - version = "1.0.0", + version = "1.1.1", protocols = { "modbus" }, capabilities = { "pv" }, description = "Solis non-hybrid PV inverters via Modbus TCP.", @@ -49,12 +49,36 @@ function driver_init(config) host.log("info", "Solis string: driver_init") end -local function read_input(addr, count) - local ok, regs = pcall(host.modbus_read, addr, count, "input") - if ok and regs then return regs end +-- The host counts every failed modbus_read against the poll whether or not +-- Lua caught it, so a block this inverter does not answer -- a single-MPPT +-- model has no 3021 range worth reading -- 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 block 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( + "Solis string: register %d did not answer %d times; leaving it " .. + "alone until restart", addr, GIVE_UP_AFTER)) + end return nil end +local function read_input(addr, count) + return probe_read(addr, count, "input") +end + local function u16(regs, idx) if regs and regs[idx] then return regs[idx] end return 0 diff --git a/drivers/sungrow.lua b/drivers/sungrow.lua index d458775c..c6a33ac0 100644 --- a/drivers/sungrow.lua +++ b/drivers/sungrow.lua @@ -9,7 +9,7 @@ DRIVER = { id = "sungrow-shx", name = "Sungrow SH Hybrid Inverter", manufacturer = "Sungrow", - version = "1.4.0", + version = "1.5.4", protocols = { "modbus" }, capabilities = { "meter", "pv", "battery", "pv-curtail" }, description = "Sungrow SH-series hybrid inverters with LFP battery, via Modbus TCP.", @@ -100,6 +100,14 @@ end local model_family = nil -- "hybrid", "string", "unknown", or nil while asking +-- Set once the battery registers have actually answered. The device type code +-- is the fast answer to "does this have a battery"; this is the slow one, and +-- it is the only one available when register 4999 never answers. The control +-- path needs it: `unknown` is a state the read path may probe its way out of, +-- because a failed read is bounded and self-correcting, while a write to a +-- register the model does not implement is neither. +local battery_confirmed = false + -- Detection has to give up. Retrying a register the inverter never answers -- costs one failed read on every poll for the rest of the session, which is -- the same outage by a slower route. @@ -131,6 +139,15 @@ end -- A register that answers on one model and not another. Absence has to be -- proved: a single timeout or a busy bus must not silence a register for the -- rest of the session, so only a run of failures counts. +-- +-- Every read driver_poll makes goes through here, including the 5xxx block +-- that every family is supposed to answer. "Supposed to" is not a guarantee: +-- a register the inverter never answers costs a failed read on every poll +-- forever, and the stale-telemetry watchdog then takes the whole site offline +-- while this driver is still reporting good numbers for everything else. The +-- control paths below stay on a plain pcall -- they run only when a command +-- arrives, so they cost the poll nothing, and a poll-time miss must not veto +-- a later command without trying. local MISSES_BEFORE_SKIP = 3 local miss_counts = {} @@ -363,18 +380,18 @@ function driver_poll() -- at 0 even while MPPT1/MPPT2 V×I clearly indicate generation. So we -- also read the MPPT voltage+current pairs separately and use their -- product as a fallback when the top-level register doesn't match. - local ok_pv, pv_regs = pcall(host.modbus_read, 5016, 2, "input") + local pv_regs = optional_read(5016, 2, "input") local pv_w_primary = 0 local pv_raw_u32 = 0 - if ok_pv and pv_regs then + if pv_regs then pv_w_primary = host.decode_u32_le(pv_regs[1], pv_regs[2]) pv_raw_u32 = pv_w_primary end -- PV MPPT: 5010-5013 (V×0.1, A×0.1 per string) - local ok_mppt, mppt_regs = pcall(host.modbus_read, 5010, 4, "input") + local mppt_regs = optional_read(5010, 4, "input") local mppt1_v, mppt1_a, mppt2_v, mppt2_a = 0, 0, 0, 0 - if ok_mppt and mppt_regs then + if mppt_regs then mppt1_v = mppt_regs[1] * 0.1 mppt1_a = mppt_regs[2] * 0.1 mppt2_v = mppt_regs[3] * 0.1 @@ -411,24 +428,24 @@ function driver_poll() end -- Rated power: 5000, U16 × 0.1 kW - local ok_rated, rated_regs = pcall(host.modbus_read, 5000, 1, "input") + local rated_regs = optional_read(5000, 1, "input") local rated_w = rated_ac_w - if ok_rated and rated_regs then + if rated_regs then rated_w = rated_regs[1] * 0.1 * 1000 if rated_w > 0 then rated_ac_w = rated_w end end -- Heatsink temp: 5007, I16 × 0.1 C - local ok_temp, temp_regs = pcall(host.modbus_read, 5007, 1, "input") + local temp_regs = optional_read(5007, 1, "input") local heatsink_c = 0 - if ok_temp and temp_regs then + if temp_regs then heatsink_c = host.decode_i16(temp_regs[1]) * 0.1 end -- Grid frequency: 5241, U16 × 0.01 Hz - local ok_hz, hz_regs = pcall(host.modbus_read, 5241, 1, "input") + local hz_regs = optional_read(5241, 1, "input") local hz = 0 - if ok_hz and hz_regs then + if hz_regs then hz = hz_regs[1] * 0.01 end @@ -526,6 +543,7 @@ function driver_poll() bat_regs = optional_read(13019, 4, "input") end if bat_regs then + battery_confirmed = true bat_v = bat_regs[1] * 0.1 bat_a = bat_regs[2] * 0.1 bat_w = bat_regs[3] @@ -581,34 +599,34 @@ function driver_poll() end -- Grid meter power: 5600-5601, I32 LE, watts (positive=import, negative=export) - local ok_mw, mw_regs = pcall(host.modbus_read, 5600, 2, "input") + local mw_regs = optional_read(5600, 2, "input") local meter_w = 0 - if ok_mw and mw_regs then + if mw_regs then meter_w = host.decode_i32_le(mw_regs[1], mw_regs[2]) end -- Per-phase power: 5602-5607, I32 LE pairs - local ok_mp, mp_regs = pcall(host.modbus_read, 5602, 6, "input") + local mp_regs = optional_read(5602, 6, "input") local l1_w, l2_w, l3_w = 0, 0, 0 - if ok_mp and mp_regs then + if mp_regs then l1_w = host.decode_i32_le(mp_regs[1], mp_regs[2]) l2_w = host.decode_i32_le(mp_regs[3], mp_regs[4]) l3_w = host.decode_i32_le(mp_regs[5], mp_regs[6]) end -- Per-phase voltage: 5740-5742, U16 × 0.1 V - local ok_mv, mv_regs = pcall(host.modbus_read, 5740, 3, "input") + local mv_regs = optional_read(5740, 3, "input") local l1_v, l2_v, l3_v = 0, 0, 0 - if ok_mv and mv_regs then + if mv_regs then l1_v = mv_regs[1] * 0.1 l2_v = mv_regs[2] * 0.1 l3_v = mv_regs[3] * 0.1 end -- Per-phase current: 5743-5745, U16 × 0.01 A - local ok_ma, ma_regs = pcall(host.modbus_read, 5743, 3, "input") + local ma_regs = optional_read(5743, 3, "input") local l1_a, l2_a, l3_a = 0, 0, 0 - if ok_ma and ma_regs then + if ma_regs then l1_a = ma_regs[1] * 0.01 l2_a = ma_regs[2] * 0.01 l3_a = ma_regs[3] * 0.01 @@ -675,10 +693,60 @@ end -- Battery control command handler -- EMS convention: positive power_w = charge, negative = discharge -- Verified: charge 200W and discharge 200W both tested and confirmed +-- +-- Do not remove the no-battery guard below. It shipped in 1.2.1 (#17), +-- was lost when #27 replaced this file with FTW's driver, and #29 restored +-- only the read half at 1.4.0. Between those, a battery command on a model +-- this driver had already classified as "string" wrote forced mode, a force +-- command and a setpoint into the 13xxx block the family does not implement, +-- and then reported success: the read-back that would have caught it fails on +-- such a device, and a failed read-back is treated as transient and assumed +-- good. The host recorded an applied setpoint, renewed the lease on it, and +-- the planner went on dispatching a battery that is not there. +-- +-- Refusing follows the shape curtail already uses when rated power is missing: +-- a warn line naming the reason, and a result the host can report. The code is +-- the one packages/v1/sungrow/targets/ftw.lua returns, so the two control +-- paths refuse in the same words rather than drifting apart again. +-- +-- Two ways to know this device takes a battery command: it named itself a +-- hybrid, or its battery registers have answered. Everything else is a guess, +-- and the guess used to be "yes" -- so an SG inverter whose device-type +-- register never answered still got the EMS writes. Note what this is not: +-- refusing everything the device type did not positively confirm. A hybrid +-- whose register 4999 is unreadable reads its battery every poll and reports +-- its SoC; denying control to a battery the driver is actively reading would +-- be an outage of its own, and detection giving up is common enough that +-- DETECT_ATTEMPTS exists for it. function driver_command(action, power_w, cmd) if action == "init" then return true elseif action == "battery" then + -- Zero is not a dispatch. It is the host handing the device back to + -- itself: forced mode off, setpoint nought. Refusing to write "stop" + -- is a different risk from refusing to write "charge" -- a device + -- left in a forced state stays there, and the safe default is the + -- one path that must never be gated on how much we know about the + -- hardware. It also arrives before the first poll, from the + -- lifecycle rather than from the planner, which is exactly when + -- nothing has been confirmed yet. + -- + -- On a genuine string inverter this writes to a register block the + -- model does not implement. That write fails at the Modbus layer and + -- costs nothing; the outage it protects against is a battery left + -- charging with no way to say stop. + if power_w ~= 0 and model_family ~= "hybrid" and not battery_confirmed then + local why = model_family == "string" + and "this model has no battery registers" + or "no battery register has answered on this device" + host.log("warn", "Sungrow: battery command refused — " .. why) + return false, { + status = "rejected", + code = "no_battery", + message = why, + device_state = "unchanged", + } + end return set_battery_power(power_w) elseif action == "curtail" then return set_pv_curtail_limit(math.abs(power_w)) diff --git a/drivers/victron.lua b/drivers/victron.lua index dc656191..847c5e2e 100644 --- a/drivers/victron.lua +++ b/drivers/victron.lua @@ -8,7 +8,7 @@ DRIVER = { id = "victron", name = "Victron Energy GX", manufacturer = "Victron Energy", - version = "1.0.0", + version = "2.1.1", protocols = { "modbus" }, capabilities = { "meter", "pv", "battery" }, description = "Victron Cerbo GX / Venus GX monitoring via Modbus TCP.", @@ -43,6 +43,39 @@ DRIVER = { PROTOCOL = "modbus" +---------------------------------------------------------------------------- +-- Absent-register guard +---------------------------------------------------------------------------- +-- The host counts every failed modbus_read against the poll whether or not +-- Lua caught it. So a register this driver can live without must stop being +-- read once the GX has made clear it will not answer — otherwise the poll +-- keeps failing, the stale-telemetry watchdog marks the device offline, and +-- the site reports nothing at all instead of one field less. A GX with no +-- battery service, or with no DC-coupled MPPT, is a normal install and must +-- not cost a failed read every five seconds. +-- +-- Three strikes rather than one: a single miss is more often a slow link +-- than a missing register. 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( + "Victron: register %d did not answer %d times; leaving it " .. + "alone until restart", addr, GIVE_UP_AFTER)) + end + return nil +end + ---------------------------------------------------------------------------- -- Initialization ---------------------------------------------------------------------------- @@ -68,16 +101,16 @@ function driver_poll() -- PV AC-coupled output power: registers 808/809/810 (U16, W per phase L1/L2/L3). -- Sum all three phases for total AC-coupled PV. - local ok_pvac, pvac_regs = pcall(host.modbus_read, 808, 3, "holding") + local pvac_regs = probe_read(808, 3, "holding") local pv_ac_w = 0 - if ok_pvac and pvac_regs then + if pvac_regs then pv_ac_w = (pvac_regs[1] or 0) + (pvac_regs[2] or 0) + (pvac_regs[3] or 0) end -- PV DC-coupled MPPT output power: register 850 (U16, W) - local ok_pvdc, pvdc_regs = pcall(host.modbus_read, 850, 1, "holding") + local pvdc_regs = probe_read(850, 1, "holding") local pv_dc_w = 0 - if ok_pvdc and pvdc_regs then + if pvdc_regs then pv_dc_w = pvdc_regs[1] end @@ -96,9 +129,9 @@ function driver_poll() -- Grid per-phase power: 820/821/822 (I16, W), import positive (matches site). -- NOTE: 823-825 are genset power (not voltage), 826 is active-input source -- (not current). Only 820-822 are grid power registers on Venus OS unit 100. - local ok_grid, grid_regs = pcall(host.modbus_read, 820, 3, "holding") + local grid_regs = probe_read(820, 3, "holding") local l1_w, l2_w, l3_w = 0, 0, 0 - if ok_grid and grid_regs then + if grid_regs then l1_w = host.decode_i16(grid_regs[1]) l2_w = host.decode_i16(grid_regs[2]) l3_w = host.decode_i16(grid_regs[3]) @@ -128,9 +161,9 @@ function driver_poll() -- 842 — power (I16, W) — positive = charging (matches site) -- 843 — SoC (U16, %) — convert to 0.0–1.0 fraction -- 844 — temp (I16, 0.1 C) - local ok_bat, bat_regs = pcall(host.modbus_read, 840, 5, "holding") + local bat_regs = probe_read(840, 5, "holding") local bat_v, bat_a, bat_w, bat_soc, bat_temp = 0, 0, 0, 0, 0 - if ok_bat and bat_regs then + if bat_regs then bat_v = bat_regs[1] * 0.1 bat_a = host.decode_i16(bat_regs[2]) * 0.1 bat_w = host.decode_i16(bat_regs[3])