From 265d9093282b5cd57fea8a44bc5cf05c39e0a20b Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Tue, 13 Oct 2026 20:35:14 +0200 Subject: [PATCH 1/5] feat(sungrow): read live values once a second (1.6.0) driver_poll returns 1000 ms. Power, SoC, per-phase values and the EMS readback are read on every poll; counters, nameplate, temperature, frequency and fault bits keep the five-second rate and ride along on the polls between, only when that poll's own power read succeeded. A read FTW refused during a reconnect wait no longer counts toward treating a register as absent. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Fredrik Ahlgren --- CHANGELOG.md | 17 ++ SUPPORT_STATUS.md | 4 +- devices.yaml | 10 +- drivers/lua/sungrow.lua | 209 ++++++++++++++------- drivers/tests/test_sungrow_model_family.py | 3 + drivers/tests/test_sungrow_poll_cadence.py | 203 ++++++++++++++++++++ index.yaml | 6 +- manifests/sungrow.yaml | 6 +- support-status.json | 2 +- 9 files changed, 373 insertions(+), 87 deletions(-) create mode 100644 drivers/tests/test_sungrow_poll_cadence.py diff --git a/CHANGELOG.md b/CHANGELOG.md index cd9bd2b..f6fcb12 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,22 @@ # Changelog +## sungrow 1.6.0 + +Read the live values once a second. `driver_poll` returns 1000 ms. Each poll +reads grid, PV and battery power, SoC, per-phase values and the EMS readback. +Energy counters, rated power, temperature, frequency and fault bits keep the +five-second rate. Between those reads each blob carries their last values, but +only when its own power read succeeded this poll. FTW stores each emit whole, +and its energy ledger starts over whenever a counter drops out. + +A host that ignores the returned interval still polls every five seconds, and +then every poll reads everything, as before. FTW honours the return from +srcfl/ftw#1616. + +A read FTW refused while it waited to reconnect no longer counts toward +treating a register as absent. At one poll per second, a two-second wait would +otherwise mark a whole block absent after one dropped packet. + ## Tests: no invented numbers - `drivers/tests/lua_harness/no_invented_numbers.lua` runs a driver twice with different registers, config and clock, then makes every read fail and every Modbus reply short. A number the driver still emits that is equal in both runs and was not read is invented: 0 W, 0 A, 0 % SoC, 50 Hz. A failed read must leave the value nil, so the host sends null. Sourceful's driver registry runs the same file on every publish. diff --git a/SUPPORT_STATUS.md b/SUPPORT_STATUS.md index bc95e00..0b0bdf8 100644 --- a/SUPPORT_STATUS.md +++ b/SUPPORT_STATUS.md @@ -156,8 +156,8 @@ Catalog source is not proof that a target can install or run a driver. | solis_string | 1.1.3 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | | sonnen | 2.0.3 | ftw-core | not_assessed | — | not_recorded | not_assessed | | sonnen | 2.0.3 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | -| sungrow | 1.5.10 | ftw-core | not_assessed | — | not_recorded | not_assessed | -| sungrow | 1.5.10 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | +| sungrow | 1.6.0 | ftw-core | not_assessed | — | not_recorded | not_assessed | +| sungrow | 1.6.0 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | | tesla_cloud | 0.1.1 | ftw-core | not_assessed | — | not_recorded | not_assessed | | tesla_cloud | 0.1.1 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | | tesla_vehicle | 0.2.5 | ftw-core | not_assessed | — | not_recorded | not_assessed | diff --git a/devices.yaml b/devices.yaml index 95cdd73..5bd6a3f 100644 --- a/devices.yaml +++ b/devices.yaml @@ -1749,7 +1749,7 @@ manufacturers: protocols: - protocol: modbus driver: "sungrow" - version: "1.5.10" + version: "1.6.0" ders: [battery, meter, pv] control: true firmware_versions: "" @@ -1760,7 +1760,7 @@ manufacturers: protocols: - protocol: modbus driver: "sungrow" - version: "1.5.10" + version: "1.6.0" ders: [battery, meter, pv] control: true firmware_versions: "" @@ -1771,7 +1771,7 @@ manufacturers: protocols: - protocol: modbus driver: "sungrow" - version: "1.5.10" + version: "1.6.0" ders: [battery, meter, pv] control: true firmware_versions: "" @@ -1782,7 +1782,7 @@ manufacturers: protocols: - protocol: modbus driver: "sungrow" - version: "1.5.10" + version: "1.6.0" ders: [battery, meter, pv] control: true firmware_versions: "" @@ -1793,7 +1793,7 @@ manufacturers: protocols: - protocol: modbus driver: "sungrow" - version: "1.5.10" + version: "1.6.0" ders: [battery, meter, pv] control: true firmware_versions: "" diff --git a/drivers/lua/sungrow.lua b/drivers/lua/sungrow.lua index 9c86e13..36efb80 100644 --- a/drivers/lua/sungrow.lua +++ b/drivers/lua/sungrow.lua @@ -9,7 +9,7 @@ DRIVER = { id = "sungrow", name = "Sungrow SH Hybrid Inverter", manufacturer = "Sungrow", - version = "1.5.10", + version = "1.6.0", protocols = { "modbus" }, capabilities = { "meter", "pv", "battery", "pv-curtail" }, description = "Sungrow SH-series hybrid inverters with LFP battery, via Modbus TCP.", @@ -224,11 +224,21 @@ end local MISSES_BEFORE_SKIP = 3 local miss_counts = {} +-- FTW refuses a read without sending it while it waits to reconnect. That +-- proves nothing about the register, and a two-second wait spans two polls +-- at one per second: counting it would mark a whole block absent after one +-- dropped packet. +local function never_sent(ok, first, second) + local why = ok and second or first + return type(why) == "string" and why:find("reconnect backoff", 1, true) ~= nil +end + local function optional_read(address, count, kind) if (miss_counts[address] or 0) >= MISSES_BEFORE_SKIP then return nil end - local ok, registers = pcall(host.modbus_read, address, count, kind) + local ok, registers, err = pcall(host.modbus_read, address, count, kind) + if never_sent(ok, registers, err) then return nil end -- Check every register asked for, not just the first. A short reply leaves -- the rest nil, and arithmetic on nil ends the whole poll. if ok and registers ~= nil then @@ -403,9 +413,34 @@ end ---------------------------------------------------------------------------- -- Telemetry polling +-- +-- The live view moves once a second, so power, SoC, per-phase values and the +-- EMS readback are read on every poll. Energy counters, nameplate, +-- temperature, frequency and fault bits change slowly and keep their +-- five-second rate. Between those full reads each blob carries their last +-- values, so a counter never drops out of the energy ledger -- but only on a +-- poll whose own power read succeeded, so they never outlive the device. +-- +-- A host that ignores the returned interval polls every five seconds, and +-- then every poll is a full read, as before. ---------------------------------------------------------------------------- +local POLL_MS = 1000 +local FULL_READ_MS = 5000 +local last_full_read_ms = nil +local slow = {} + +local function full_read_due(now_ms) + -- Half a poll early, so jitter cannot push each full read a second late. + return last_full_read_ms == nil + or now_ms - last_full_read_ms >= FULL_READ_MS - POLL_MS / 2 +end + function driver_poll() + local now_ms = host.millis() + local full = full_read_due(now_ms) + if full then last_full_read_ms = now_ms end + -- driver_init already asked once. Keep asking while the answer is unknown, -- up to DETECT_ATTEMPTS, then settle on probing. if model_family == nil and detect_tries < DETECT_ATTEMPTS then @@ -444,11 +479,14 @@ function driver_poll() -- reachable-but-stopped inverter is no longer misreported as merely 0 W. -- Hybrid only: an SG string inverter answers neither, and asking fails -- the whole poll. + -- The running state is read on full polls only; the fault flag it sets + -- stays with the host until a later full poll clears it. The flow status + -- signs the battery power, so it is read with it on every poll. local running_state = 0 local power_flow_status = 0 local running_state_known = false if hybrid_block_worth_reading() then - local running_regs = optional_read(12999, 1, "input") + local running_regs = full and optional_read(12999, 1, "input") if running_regs then running_state = running_regs[1] running_state_known = true @@ -502,35 +540,42 @@ function driver_poll() pv_source = "mppt_sum" end - -- PV lifetime energy: 13002-13003, U32 LE × 0.1 kWh + -- Slow readings, on full polls only: lifetime energy, rated power, + -- heatsink temperature and grid frequency. local pv_gen_wh = 0 - if hybrid_block_worth_reading() then - local pvgen_regs = optional_read(13002, 2, "input") - if pvgen_regs then - pv_gen_wh = host.decode_u32_le(pvgen_regs[1], pvgen_regs[2]) * 0.1 * 1000 + local rated_w = rated_ac_w + local heatsink_c = 0 + local hz = 0 + if full then + -- PV lifetime energy: 13002-13003, U32 LE × 0.1 kWh + if hybrid_block_worth_reading() then + local pvgen_regs = optional_read(13002, 2, "input") + if pvgen_regs then + pv_gen_wh = host.decode_u32_le(pvgen_regs[1], pvgen_regs[2]) * 0.1 * 1000 + end end - end - -- Rated power: 5000, U16 × 0.1 kW - local rated_regs = optional_read(5000, 1, "input") - local rated_w = rated_ac_w - if rated_regs then - rated_w = rated_regs[1] * 0.1 * 1000 - if rated_w > 0 then rated_ac_w = rated_w end - end + -- Rated power: 5000, U16 × 0.1 kW + local rated_regs = optional_read(5000, 1, "input") + 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 temp_regs = optional_read(5007, 1, "input") - local heatsink_c = 0 - if temp_regs then - heatsink_c = host.decode_i16(temp_regs[1]) * 0.1 - end + -- Heatsink temp: 5007, I16 × 0.1 C + local temp_regs = optional_read(5007, 1, "input") + if temp_regs then + heatsink_c = host.decode_i16(temp_regs[1]) * 0.1 + end - -- Grid frequency: 5241, U16 × 0.01 Hz - local hz_regs = optional_read(5241, 1, "input") - local hz = 0 - if hz_regs then - hz = hz_regs[1] * 0.01 + -- Grid frequency: 5241, U16 × 0.01 Hz + local hz_regs = optional_read(5241, 1, "input") + if hz_regs then + hz = hz_regs[1] * 0.01 + end + slow.pv_gen_wh, slow.heatsink_c, slow.hz = pv_gen_wh, heatsink_c, hz + elseif pv_regs or mppt_regs then + pv_gen_wh, heatsink_c = slow.pv_gen_wh, slow.heatsink_c end host.emit("pv", { @@ -550,27 +595,29 @@ function driver_poll() -- Diagnostics: long-format TS DB. Surface BOTH the primary-register -- reading and the MPPT-derived fallback so operators can see when -- the two disagree (= firmware register quirk) directly in the - -- metric browser. - host.emit_metric("pv_w_primary", pv_w_primary) - host.emit_metric("pv_w_mppt_sum", pv_w_mppt) - host.emit_metric("pv_raw_u32", pv_raw_u32) - host.emit_metric("pv_mppt1_v", mppt1_v) - host.emit_metric("pv_mppt1_a", mppt1_a) - host.emit_metric("pv_mppt1_w", mppt1_w) - host.emit_metric("pv_mppt2_v", mppt2_v) - host.emit_metric("pv_mppt2_a", mppt2_a) - host.emit_metric("pv_mppt2_w", mppt2_w) - host.emit_metric("inverter_temp_c", heatsink_c) - host.emit_metric("grid_hz", hz) - host.emit_metric("sungrow_running_state", running_state) - host.emit_metric("sungrow_power_flow_status", power_flow_status) - host.emit_metric("sungrow_pv_curtail_method", pv_curtail_method == "feed_in" and 2 or 1) + -- metric browser. Metrics keep the full-read rate. + if full then + host.emit_metric("pv_w_primary", pv_w_primary) + host.emit_metric("pv_w_mppt_sum", pv_w_mppt) + host.emit_metric("pv_raw_u32", pv_raw_u32) + host.emit_metric("pv_mppt1_v", mppt1_v) + host.emit_metric("pv_mppt1_a", mppt1_a) + host.emit_metric("pv_mppt1_w", mppt1_w) + host.emit_metric("pv_mppt2_v", mppt2_v) + host.emit_metric("pv_mppt2_a", mppt2_a) + host.emit_metric("pv_mppt2_w", mppt2_w) + host.emit_metric("inverter_temp_c", heatsink_c) + host.emit_metric("grid_hz", hz) + host.emit_metric("sungrow_running_state", running_state) + host.emit_metric("sungrow_power_flow_status", power_flow_status) + host.emit_metric("sungrow_pv_curtail_method", pv_curtail_method == "feed_in" and 2 or 1) + end -- Contiguous bitfields from documented input registers 13050-13079 -- (zero-based host addresses 13049-13078). One read gives enough evidence -- to distinguish thermal, grid, PV/DC, battery and BMS shutdowns. local fault_regs = nil - if hybrid_block_worth_reading() then + if full and hybrid_block_worth_reading() then fault_regs = optional_read(13049, 30, "input") end local fault_values = {} @@ -650,7 +697,7 @@ function driver_poll() if hybrid_block_worth_reading() then emsd = optional_read(13049, 3, "holding") end - if emsd then + if emsd and full then host.emit_metric("sungrow_ems_mode", emsd[1]) -- 0=self, 2=forced, 3=ext host.emit_metric("sungrow_force_cmd", emsd[2]) -- 0xAA=170 chg, 0xBB=187 dis, 0xCC=204 stop host.emit_metric("sungrow_force_w", emsd[3]) @@ -673,21 +720,30 @@ function driver_poll() -- Energy counters are separate reads. Add each only if it answered: -- a counter reported as zero would look like a reset meter. - local bchg_regs = optional_read(13040, 2, "input") - if bchg_regs then - battery.charge_wh = - host.decode_u32_le(bchg_regs[1], bchg_regs[2]) * 0.1 * 1000 - end + if full then + local bchg_regs = optional_read(13040, 2, "input") + if bchg_regs then + battery.charge_wh = + host.decode_u32_le(bchg_regs[1], bchg_regs[2]) * 0.1 * 1000 + end - local bdis_regs = optional_read(13026, 2, "input") - if bdis_regs then - battery.discharge_wh = - host.decode_u32_le(bdis_regs[1], bdis_regs[2]) * 0.1 * 1000 + local bdis_regs = optional_read(13026, 2, "input") + if bdis_regs then + battery.discharge_wh = + host.decode_u32_le(bdis_regs[1], bdis_regs[2]) * 0.1 * 1000 + end + slow.charge_wh, slow.discharge_wh = battery.charge_wh, battery.discharge_wh + else + battery.charge_wh, battery.discharge_wh = slow.charge_wh, slow.discharge_wh end host.emit("battery", battery) - host.emit_metric("battery_dc_v", bat_v) - host.emit_metric("battery_dc_a", bat_a) + if full then + host.emit_metric("battery_dc_v", bat_v) + host.emit_metric("battery_dc_a", bat_a) + end + elseif full then + slow.charge_wh, slow.discharge_wh = nil, nil end -- Grid meter power: 5600-5601, I32 LE, watts (positive=import, negative=export) @@ -729,15 +785,20 @@ function driver_poll() -- rather than zeroed when absent: a counter reading zero looks like a -- meter that was reset. local import_wh, export_wh = nil, nil - if hybrid_block_worth_reading() then - local imp_regs = optional_read(13036, 2, "input") - if imp_regs then - import_wh = host.decode_u32_le(imp_regs[1], imp_regs[2]) * 0.1 * 1000 - end - local exp_regs = optional_read(13045, 2, "input") - if exp_regs then - export_wh = host.decode_u32_le(exp_regs[1], exp_regs[2]) * 0.1 * 1000 + if full then + if hybrid_block_worth_reading() then + local imp_regs = optional_read(13036, 2, "input") + if imp_regs then + import_wh = host.decode_u32_le(imp_regs[1], imp_regs[2]) * 0.1 * 1000 + end + local exp_regs = optional_read(13045, 2, "input") + if exp_regs then + export_wh = host.decode_u32_le(exp_regs[1], exp_regs[2]) * 0.1 * 1000 + end end + slow.import_wh, slow.export_wh = import_wh, export_wh + elseif mw_regs then + import_wh, export_wh, hz = slow.import_wh, slow.export_wh, slow.hz end -- 5600-5601 relay the external meter on the known SH register map. Only an @@ -763,15 +824,17 @@ function driver_poll() import_wh = import_wh, export_wh = export_wh, }) - host.emit_metric("meter_l1_w", l1_w) - host.emit_metric("meter_l2_w", l2_w) - host.emit_metric("meter_l3_w", l3_w) - host.emit_metric("meter_l1_v", l1_v) - host.emit_metric("meter_l2_v", l2_v) - host.emit_metric("meter_l3_v", l3_v) - host.emit_metric("meter_l1_a", l1_a) - host.emit_metric("meter_l2_a", l2_a) - host.emit_metric("meter_l3_a", l3_a) + if full then + host.emit_metric("meter_l1_w", l1_w) + host.emit_metric("meter_l2_w", l2_w) + host.emit_metric("meter_l3_w", l3_w) + host.emit_metric("meter_l1_v", l1_v) + host.emit_metric("meter_l2_v", l2_v) + host.emit_metric("meter_l3_v", l3_v) + host.emit_metric("meter_l1_a", l1_a) + host.emit_metric("meter_l2_a", l2_a) + host.emit_metric("meter_l3_a", l3_a) + end -- Feed-in fallback translation: a PV allowance is the power that may -- serve local load, charge the battery, or leave the site. Sungrow's @@ -783,7 +846,7 @@ function driver_poll() local load_w = meter_w - bat_w + pv_w latest_non_export_w = math.max(0, load_w + math.max(0, bat_w)) - return 5000 + return POLL_MS end ---------------------------------------------------------------------------- diff --git a/drivers/tests/test_sungrow_model_family.py b/drivers/tests/test_sungrow_model_family.py index f459836..4714598 100644 --- a/drivers/tests/test_sungrow_model_family.py +++ b/drivers/tests/test_sungrow_model_family.py @@ -177,6 +177,8 @@ def test_fault_clears_on_a_running_state_of_zero() -> None: The previous version cleared the fault only on a non-zero value, so an inverter that recovered into 0x0000 stayed reported as faulted. + + The running state is read on full polls, five seconds apart. """ body = HEALTHY_HYBRID + f''' dofile("{DRIVER}") @@ -184,6 +186,7 @@ def test_fault_clears_on_a_running_state_of_zero() -> None: local states = {{}} local function poll_with(state) host._modbus_registers.input[12999] = {{state}} + host._millis_counter = host._millis_counter + 5000 pcall(driver_poll) states[#states + 1] = host._faulted and "1" or "0" end diff --git a/drivers/tests/test_sungrow_poll_cadence.py b/drivers/tests/test_sungrow_poll_cadence.py new file mode 100644 index 0000000..b7016f0 --- /dev/null +++ b/drivers/tests/test_sungrow_poll_cadence.py @@ -0,0 +1,203 @@ +"""Sungrow reads what the live view needs every second, and the rest every five. + +FTW sends live frames once a second. A site meter read every five seconds +makes the figures on a phone jump every five seconds. The driver now returns +1000 ms and splits its reads: power, SoC, per-phase values and the EMS +readback on every poll; counters, nameplate, temperature, frequency and fault +bits on a full poll every five seconds. + +Between full polls the blobs carry the slow values, because FTW stores each +emit whole and its energy ledger re-baselines whenever a counter disappears. +They ride only on a poll whose own power read succeeded. + +A host that ignores the returned interval polls every five seconds; then every +poll is a full read, exactly as before. +""" + +from __future__ import annotations + +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +LUA = ROOT / "lua55" +HARNESS = ROOT / "drivers" / "tests" / "lua_harness" +DRIVER = ROOT / "drivers" / "lua" / "sungrow.lua" + +pytestmark = pytest.mark.skipif( + not LUA.exists(), reason="run make check to build ./lua55") + +# What the live view and Core's control need on every poll. +FAST_READS = { + "input:13000", "input:13019", "holding:13049", + "input:5016", "input:5010", + "input:5600", "input:5602", "input:5740", "input:5743", +} + +# Read on a full poll as well. +SLOW_READS = { + "input:12999", "input:13002", "input:5000", "input:5007", "input:5241", + "input:13049", "input:13040", "input:13026", "input:13036", "input:13045", +} + +HEALTHY_HYBRID = ''' +host._modbus_registers.input[4990] = {0x4131, 0x4232, 0x4333, 0x4434, 0x4535, + 0x4636, 0x4737, 0x4838, 0x4939, 0x4A30} +host._modbus_registers.input[4999] = {0x0E0E} +host._modbus_registers.input[5000] = {100} +host._modbus_registers.input[5007] = {412} +host._modbus_registers.input[5016] = {4000, 0} +host._modbus_registers.input[5241] = {4998} +host._modbus_registers.input[12999] = {0x0000} +host._modbus_registers.input[13000] = {0x0004} -- bit 2 set: discharging +host._modbus_registers.input[13002] = {5000, 0} +host._modbus_registers.input[13019] = {4800, 120, 900, 550} +host._modbus_registers.input[13026] = {1000, 0} +host._modbus_registers.input[13036] = {2000, 0} +host._modbus_registers.input[13040] = {3000, 0} +host._modbus_registers.input[13045] = {4000, 0} +host._modbus_registers.input[5600] = {1500, 0} +host._modbus_registers.input[5743] = {210, 62, 34} +''' + +# Polls n_polls times, `step_ms` apart on the host clock, and prints what each +# poll read, returned and emitted. +POLL_REPORT = ''' +dofile("{driver}") +driver_init({{}}) +for poll = 1, {n_polls} do + host._millis_counter = host._millis_counter + {step_ms} - host._millis_step + {before_poll} + local before = #host._calls + local m_before = #(host._emitted["meter"] or {{}}) + local b_before = #(host._emitted["battery"] or {{}}) + local p_before = #(host._emitted["pv"] or {{}}) + local ok, ret = pcall(driver_poll) + if not ok then print("POLL_ERROR " .. tostring(ret)) os.exit(1) end + local reads = {{}} + for i = before + 1, #host._calls do + local call = host._calls[i] + if call.func == "modbus_read" then + reads[#reads + 1] = call.args[3] .. ":" .. tostring(call.args[1]) + end + end + table.sort(reads) + print("READS" .. poll .. " " .. table.concat(reads, ",")) + print("RETURN" .. poll .. " " .. tostring(ret)) + local m = (host._emitted["meter"] or {{}})[m_before + 1] or {{}} + local b = (host._emitted["battery"] or {{}})[b_before + 1] or {{}} + local p = (host._emitted["pv"] or {{}})[p_before + 1] or {{}} + print("METER" .. poll .. " " .. tostring(m.w) .. " " .. tostring(m.import_wh) + .. " " .. tostring(m.export_wh) .. " " .. tostring(m.hz) + .. " " .. tostring(m.l1_a)) + print("BATTERY" .. poll .. " " .. tostring(b.w) .. " " .. tostring(b.soc) + .. " " .. tostring(b.charge_wh) .. " " .. tostring(b.discharge_wh)) + print("PV" .. poll .. " " .. tostring(p.w) .. " " .. tostring(p.lifetime_wh) + .. " " .. tostring(p.temp_c) .. " " .. tostring(p.rated_w)) +end +''' + + +def run_polls(n_polls: int, step_ms: int, setup: str = "", before_poll: str = "") -> dict[str, str]: + script = f''' +package.path = "{HARNESS}/?.lua;" .. package.path +require("host_mock") +host.reset() +{HEALTHY_HYBRID} +{setup} +''' + POLL_REPORT.format(driver=DRIVER, n_polls=n_polls, step_ms=step_ms, + before_poll=before_poll) + result = subprocess.run([str(LUA), "-e", script], + capture_output=True, text=True, cwd=ROOT) + assert result.returncode == 0, result.stdout + result.stderr + return dict(line.split(" ", 1) + for line in result.stdout.strip().splitlines() if " " in line) + + +def reads(out: dict[str, str], poll: int) -> set[str]: + return set(out[f"READS{poll}"].split(",")) + + +def test_driver_asks_for_one_second() -> None: + out = run_polls(2, 1000) + assert out["RETURN1"] == "1000" + assert out["RETURN2"] == "1000" + + +def test_reads_split_into_every_second_and_every_five() -> None: + # Polls at 1, 2, ... 11 s. The first is full; then one full read about + # every five seconds, never more than one apart from the next. + out = run_polls(11, 1000) + full = [poll for poll in range(1, 12) if SLOW_READS <= reads(out, poll)] + assert full == [1, 6, 11], f"full reads on polls {full}" + for poll in range(2, 6): + assert reads(out, poll) == FAST_READS, ( + f"poll {poll} read {sorted(reads(out, poll))}, not just what the " + f"live view needs") + + +def test_a_five_second_host_reads_everything_every_poll() -> None: + """Core before srcfl/ftw honoured driver_poll's return polls every 5 s.""" + out = run_polls(4, 5000) + for poll in range(1, 5): + assert SLOW_READS | FAST_READS <= reads(out, poll), ( + f"poll {poll} at five seconds skipped {sorted(SLOW_READS - reads(out, poll))}") + + +def test_fast_polls_carry_the_last_full_values() -> None: + """A counter that comes and goes makes FTW's energy ledger re-baseline.""" + change_power = ''' +if poll == 2 then + host._modbus_registers.input[5600] = {2500, 0} + host._modbus_registers.input[13019] = {4800, 120, 1200, 551} + host._modbus_registers.input[5016] = {5000, 0} + host._modbus_registers.input[13036] = {9999, 0} + host._modbus_registers.input[13040] = {9999, 0} + host._modbus_registers.input[13002] = {9999, 0} +end''' + out = run_polls(2, 1000, before_poll=change_power) + # Power and SoC move on the fast poll. + assert out["METER2"].split()[0] == "2500" + assert out["BATTERY2"].split()[:2] == ["-1200", "0.551"] + assert out["PV2"].split()[0] == "-5000" + # Counters, frequency, temperature and phase current stay as last read. + assert out["METER2"].split()[1:] == out["METER1"].split()[1:], out + assert out["BATTERY2"].split()[2:] == out["BATTERY1"].split()[2:], out + assert out["PV2"].split()[1:] == out["PV1"].split()[1:], out + + +def test_carried_values_need_this_polls_power_read() -> None: + """If the meter stops answering, its counters stop too.""" + drop_meter = ''' +if poll == 2 then + host._modbus_read_fail_addresses[5600] = "Illegal Data Address" +end''' + out = run_polls(2, 1000, before_poll=drop_meter) + meter = out["METER2"].split() + assert meter[1:3] == ["nil", "nil"], f"meter counters outlived the meter: {meter}" + + +def test_reconnect_backoff_does_not_count_as_a_missing_register() -> None: + """FTW refuses a read without sending it while it waits to reconnect. + + A two-second wait spans two polls at one per second. Counted as misses, + one dropped packet would mark the meter absent for the rest of the session. + """ + backoff = ''' +if poll >= 2 and poll <= 5 then + host._modbus_read_fail_addresses[5600] = "modbus reconnect backoff active for 2s" +else + host._modbus_read_fail_addresses[5600] = nil +end''' + out = run_polls(6, 1000, before_poll=backoff) + assert "input:5600" in reads(out, 6), "the meter was given up after a reconnect wait" + assert out["METER6"].split()[0] == "1500" + + +def test_a_refused_register_is_still_given_up() -> None: + refused = 'host._modbus_read_fail_addresses[5743] = "Illegal Data Address"' + out = run_polls(5, 1000, setup=refused) + assert "input:5743" in reads(out, 3) + assert "input:5743" not in reads(out, 4), "a refused register was read forever" diff --git a/index.yaml b/index.yaml index 2dfe701..4e4ba0f 100644 --- a/index.yaml +++ b/index.yaml @@ -700,14 +700,14 @@ drivers: size_bytes: 6631 sha256: "94ae5e0065f5793051acb89b7ef04a679b2898908dfac6f4ffd6e8deb77fa2c0" - name: "sungrow" - version: "1.5.10" + version: "1.6.0" tier: core protocol: modbus connectivity: local ders: [battery, meter, pv] control: true - size_bytes: 49876 - sha256: "25407256014764245bedf1cfc193f94a696854ddedbd224f66fef827df92fb82" + size_bytes: 52811 + sha256: "db4e378f49f6421c93621e0f101f00c64e5459a38190171003476f9f2b2cc4c1" - name: "tesla_cloud" version: "0.1.1" tier: community diff --git a/manifests/sungrow.yaml b/manifests/sungrow.yaml index 278ce98..0f32ba3 100644 --- a/manifests/sungrow.yaml +++ b/manifests/sungrow.yaml @@ -1,5 +1,5 @@ name: "sungrow" -version: "1.5.10" +version: "1.6.0" tier: core author: "Sourceful Labs AB" protocol: modbus @@ -43,9 +43,9 @@ tested_devices: notes: "Commercial/industrial three-phase string inverter. Needs 1.5.3: 1.2.1 stopped polling the SH battery registers this family does not answer, and 1.5.3 refuses a battery dispatch instead of writing them. A zero-watt command is still written on every family from 1.5.4 -- it is the release, and on a model without the registers it fails harmlessly." min_driver_version: "1.5.3" min_host_version: "2.0.0" -size_bytes: 49876 +size_bytes: 52811 dkb_id: "sungrow_sh_rt" -sha256: "25407256014764245bedf1cfc193f94a696854ddedbd224f66fef827df92fb82" +sha256: "db4e378f49f6421c93621e0f101f00c64e5459a38190171003476f9f2b2cc4c1" signature: "" bytecode_sha256: "" diff --git a/support-status.json b/support-status.json index e822c1c..67eef26 100644 --- a/support-status.json +++ b/support-status.json @@ -1598,7 +1598,7 @@ }, { "catalog_source": true, - "catalog_version": "1.5.10", + "catalog_version": "1.6.0", "driver_id": "sungrow", "targets": { "blixt-l1": { From 48cae6cb3d6e5da7468de9288a97024f47a5960e Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Wed, 14 Oct 2026 07:45:14 +0200 Subject: [PATCH 2/5] fix(sdm630): stay at five seconds until seen at one second (2.0.3) driver_poll returned 1000 ms, which FTW ignored until srcfl/ftw#1616. An SDM630 often shares an RS485 bus with an inverter, and its one 76-register read takes about 0.18 s at 9600 baud. Return 5000 until hardware shows one second is safe. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Fredrik Ahlgren --- CHANGELOG.md | 8 ++++++++ SUPPORT_STATUS.md | 4 ++-- devices.yaml | 2 +- drivers/lua/sdm630.lua | 12 +++++++++--- index.yaml | 6 +++--- manifests/sdm630.yaml | 6 +++--- support-status.json | 2 +- 7 files changed, 27 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f6fcb12..9f54c0e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## sdm630 2.0.3 + +Return 5000 ms from `driver_poll` instead of 1000. FTW ignored the return until +srcfl/ftw#1616 and read this meter every five seconds; that PR makes the return +count. An SDM630 often shares an RS485 bus with an inverter, and its one +76-register read takes about 0.18 s at 9600 baud. It stays at five seconds +until it has been seen at one second on hardware. + ## sungrow 1.6.0 Read the live values once a second. `driver_poll` returns 1000 ms. Each poll diff --git a/SUPPORT_STATUS.md b/SUPPORT_STATUS.md index 0b0bdf8..a17eb86 100644 --- a/SUPPORT_STATUS.md +++ b/SUPPORT_STATUS.md @@ -124,8 +124,8 @@ Catalog source is not proof that a target can install or run a driver. | schneider_meter | 1.0.5 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | | schrack_ev | 1.0.3 | ftw-core | not_assessed | — | not_recorded | not_assessed | | schrack_ev | 1.0.3 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | -| sdm630 | 2.0.2 | ftw-core | not_assessed | — | not_recorded | not_assessed | -| sdm630 | 2.0.2 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | +| sdm630 | 2.0.3 | ftw-core | not_assessed | — | not_recorded | not_assessed | +| sdm630 | 2.0.3 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | | shelly | 1.1.2 | ftw-core | not_assessed | — | not_recorded | not_assessed | | shelly | 1.1.2 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | | siemens_pac | 1.0.5 | ftw-core | not_assessed | — | not_recorded | not_assessed | diff --git a/devices.yaml b/devices.yaml index 5bd6a3f..3dd4a5e 100644 --- a/devices.yaml +++ b/devices.yaml @@ -351,7 +351,7 @@ manufacturers: protocols: - protocol: modbus driver: "sdm630" - version: "2.0.2" + version: "2.0.3" ders: [meter] control: false firmware_versions: "" diff --git a/drivers/lua/sdm630.lua b/drivers/lua/sdm630.lua index d5911f9..002539c 100644 --- a/drivers/lua/sdm630.lua +++ b/drivers/lua/sdm630.lua @@ -14,7 +14,7 @@ DRIVER = { id = "sdm630", name = "Eastron SDM630 meter", manufacturer = "Eastron", - version = "2.0.2", + version = "2.0.3", protocols = { "modbus" }, capabilities = { "meter" }, read_only = true, @@ -74,13 +74,19 @@ function driver_init(config) return true end +-- Five seconds until an SDM630 has been seen at one second on hardware. It +-- often sits on an RS485 bus shared with an inverter, and one 76-register +-- read takes about 0.18 s at 9600 baud. Hosts that honour this return would +-- otherwise poll it every second. +local POLL_MS = 5000 + function driver_poll() -- One FC04 request covers the measurement window 0x0000..0x004B. local ok, regs = pcall(host.modbus_read, 0x0000, 76, "input") if not ok or not regs or #regs < 76 then -- Do not emit fabricated zeros on a failed primary read. Both hosts -- then expose staleness to their own safety/watchdog layer. - return 1000 + return POLL_MS end local base = 0x0000 @@ -130,7 +136,7 @@ function driver_poll() total_export_Wh = export_wh, }) - return 1000 + return POLL_MS end function driver_command(action, value, context) diff --git a/index.yaml b/index.yaml index 4e4ba0f..681b037 100644 --- a/index.yaml +++ b/index.yaml @@ -554,14 +554,14 @@ drivers: size_bytes: 3554 sha256: "652db1569e74d8a451ddfcefb4f55d897e144b9a955608d20b4278ed6b9a2fdd" - name: "sdm630" - version: "2.0.2" + version: "2.0.3" tier: core protocol: modbus connectivity: local ders: [meter] control: false - size_bytes: 5024 - sha256: "d879dd48e0ab6bf42547745659798473267e52bdaa84c93b94b5a6dc9969ac4c" + size_bytes: 5315 + sha256: "fe01f6156eb956f3445f78d668e9c01a86bcd479983def73fcf4383edea82eed" - name: "shelly" version: "1.1.2" tier: community diff --git a/manifests/sdm630.yaml b/manifests/sdm630.yaml index 1463ed3..e7ea691 100644 --- a/manifests/sdm630.yaml +++ b/manifests/sdm630.yaml @@ -1,5 +1,5 @@ name: "sdm630" -version: "2.0.2" +version: "2.0.3" tier: core author: "Sourceful Labs AB" protocol: modbus @@ -15,9 +15,9 @@ tested_devices: notes: "" min_driver_version: "1.1.1" min_host_version: "2.0.0" -size_bytes: 5024 +size_bytes: 5315 dkb_id: "sdm630" -sha256: "d879dd48e0ab6bf42547745659798473267e52bdaa84c93b94b5a6dc9969ac4c" +sha256: "fe01f6156eb956f3445f78d668e9c01a86bcd479983def73fcf4383edea82eed" signature: "" bytecode_sha256: "" diff --git a/support-status.json b/support-status.json index 67eef26..139b53b 100644 --- a/support-status.json +++ b/support-status.json @@ -1262,7 +1262,7 @@ }, { "catalog_source": true, - "catalog_version": "2.0.2", + "catalog_version": "2.0.3", "driver_id": "sdm630", "targets": { "blixt-l1": { From b5af1856e94a03f4afe933f65c03f7ef2d50a141 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Wed, 14 Oct 2026 07:49:25 +0200 Subject: [PATCH 3/5] fix(pixii_pv): stay at five seconds until seen at one second (0.3.3) driver_poll returned 1000 ms, which FTW ignored until srcfl/ftw#1616. Hold it at the five seconds it has run at until hardware shows one second works. It reads MQTT, so the hold is about unproven behaviour, not bus load. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Fredrik Ahlgren --- CHANGELOG.md | 7 +++++++ SUPPORT_STATUS.md | 4 ++-- devices.yaml | 2 +- drivers/lua/pixii_pv.lua | 11 ++++++++--- index.yaml | 6 +++--- manifests/pixii_pv.yaml | 6 +++--- support-status.json | 2 +- 7 files changed, 25 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f54c0e..5e1eaa2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## pixii_pv 0.3.3 + +Return 5000 ms from `driver_poll` instead of 1000. FTW ignored the return until +srcfl/ftw#1616 and read this driver every five seconds. It stays at five seconds +until it has been seen at one second on hardware. It reads MQTT, so this is +about unproven behaviour, not bus load. + ## sdm630 2.0.3 Return 5000 ms from `driver_poll` instead of 1000. FTW ignored the return until diff --git a/SUPPORT_STATUS.md b/SUPPORT_STATUS.md index a17eb86..a44e971 100644 --- a/SUPPORT_STATUS.md +++ b/SUPPORT_STATUS.md @@ -114,8 +114,8 @@ Catalog source is not proof that a target can install or run a driver. | p1_meter | 2.0.2 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | | pixii | 2.1.8 | ftw-core | not_assessed | — | not_recorded | not_assessed | | pixii | 2.1.8 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | -| pixii_pv | 0.3.2 | ftw-core | not_assessed | — | not_recorded | not_assessed | -| pixii_pv | 0.3.2 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | +| pixii_pv | 0.3.3 | ftw-core | not_assessed | — | not_recorded | not_assessed | +| pixii_pv | 0.3.3 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | | remne_p1ib | 0.1.0 | ftw-core | not_assessed | — | not_recorded | not_assessed | | remne_p1ib | 0.1.0 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | | saj | 1.2.0 | ftw-core | not_assessed | — | not_recorded | not_assessed | diff --git a/devices.yaml b/devices.yaml index 3dd4a5e..ce6b80c 100644 --- a/devices.yaml +++ b/devices.yaml @@ -1021,7 +1021,7 @@ manufacturers: protocols: - protocol: mqtt driver: "pixii_pv" - version: "0.3.2" + version: "0.3.3" ders: [pv, meter] control: false firmware_versions: "" diff --git a/drivers/lua/pixii_pv.lua b/drivers/lua/pixii_pv.lua index 2b50c6b..eba5441 100644 --- a/drivers/lua/pixii_pv.lua +++ b/drivers/lua/pixii_pv.lua @@ -14,7 +14,7 @@ DRIVER = { id = "pixii_pv", name = "Pixii PowerShaper (PV + meter)", manufacturer = "Pixii", - version = "0.3.2", + version = "0.3.3", protocols = { "mqtt" }, capabilities = { "pv", "meter" }, read_only = true, @@ -79,9 +79,14 @@ function driver_init(config) host.log("info", "Pixii: subscribed to meter_ext6 + meter") end +-- Five seconds until this driver has been seen at one second on hardware. +-- Hosts that honour this return would otherwise poll it every second. It +-- reads MQTT, so the hold is about unproven behaviour, not bus load. +local POLL_MS = 5000 + function driver_poll() local messages = host.mqtt_messages() - if not messages then return 1000 end + if not messages then return POLL_MS end local pv_payload, meter_payload = nil, nil @@ -162,7 +167,7 @@ function driver_poll() if building_w then host.emit_metric("building_ac_w", building_w) end end - return 1000 + return POLL_MS end function driver_command(action) diff --git a/index.yaml b/index.yaml index 681b037..59ac4fd 100644 --- a/index.yaml +++ b/index.yaml @@ -507,14 +507,14 @@ drivers: size_bytes: 29119 sha256: "5fc8e600eac7a70020e01e32c531f350f49bd076cac8a5598c2c06205b05f6b8" - name: "pixii_pv" - version: "0.3.2" + version: "0.3.3" tier: core protocol: mqtt connectivity: local ders: [pv, meter] control: false - size_bytes: 6699 - sha256: "7a27b937bdc845dff8d4032cb96ccca16242cc8378d1cbf5cfc8030660b871db" + size_bytes: 6946 + sha256: "566d789af826aad67ad6d72eb947e06e30a4cbbe070cfc5bdacc737755f55897" - name: "remne_p1ib" version: "0.1.0" tier: community diff --git a/manifests/pixii_pv.yaml b/manifests/pixii_pv.yaml index 2c26a15..fb41f61 100644 --- a/manifests/pixii_pv.yaml +++ b/manifests/pixii_pv.yaml @@ -1,5 +1,5 @@ name: "pixii_pv" -version: "0.3.2" +version: "0.3.3" tier: core author: "Sourceful Labs AB" protocol: mqtt @@ -15,9 +15,9 @@ tested_devices: notes: "Read-only Pixii telemetry: PV (external CTs) + grid meter (meter_w). Battery / SoC comes from the Modbus driver, not this one." min_driver_version: "0.2.0" min_host_version: "2.0.0" -size_bytes: 6699 +size_bytes: 6946 dkb_id: "pixii_pv" -sha256: "7a27b937bdc845dff8d4032cb96ccca16242cc8378d1cbf5cfc8030660b871db" +sha256: "566d789af826aad67ad6d72eb947e06e30a4cbbe070cfc5bdacc737755f55897" signature: "" bytecode_sha256: "" diff --git a/support-status.json b/support-status.json index 139b53b..f0a9b85 100644 --- a/support-status.json +++ b/support-status.json @@ -1157,7 +1157,7 @@ }, { "catalog_source": true, - "catalog_version": "0.3.2", + "catalog_version": "0.3.3", "driver_id": "pixii_pv", "targets": { "blixt-l1": { From d40ae98c17f20ee440f95aee56892c35ff5bfc71 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Wed, 14 Oct 2026 07:53:09 +0200 Subject: [PATCH 4/5] Revert "fix(pixii_pv): stay at five seconds until seen at one second (0.3.3)" pixii_pv reads Core's local MQTT queue, so the bus-load reason for the hold does not apply. The owner chose to let it run at one second like ferroamp. This reverts commit b5af1856e94a03f4afe933f65c03f7ef2d50a141. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Fredrik Ahlgren --- CHANGELOG.md | 7 ------- SUPPORT_STATUS.md | 4 ++-- devices.yaml | 2 +- drivers/lua/pixii_pv.lua | 11 +++-------- index.yaml | 6 +++--- manifests/pixii_pv.yaml | 6 +++--- support-status.json | 2 +- 7 files changed, 13 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e1eaa2..9f54c0e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,12 +1,5 @@ # Changelog -## pixii_pv 0.3.3 - -Return 5000 ms from `driver_poll` instead of 1000. FTW ignored the return until -srcfl/ftw#1616 and read this driver every five seconds. It stays at five seconds -until it has been seen at one second on hardware. It reads MQTT, so this is -about unproven behaviour, not bus load. - ## sdm630 2.0.3 Return 5000 ms from `driver_poll` instead of 1000. FTW ignored the return until diff --git a/SUPPORT_STATUS.md b/SUPPORT_STATUS.md index a44e971..a17eb86 100644 --- a/SUPPORT_STATUS.md +++ b/SUPPORT_STATUS.md @@ -114,8 +114,8 @@ Catalog source is not proof that a target can install or run a driver. | p1_meter | 2.0.2 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | | pixii | 2.1.8 | ftw-core | not_assessed | — | not_recorded | not_assessed | | pixii | 2.1.8 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | -| pixii_pv | 0.3.3 | ftw-core | not_assessed | — | not_recorded | not_assessed | -| pixii_pv | 0.3.3 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | +| pixii_pv | 0.3.2 | ftw-core | not_assessed | — | not_recorded | not_assessed | +| pixii_pv | 0.3.2 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | | remne_p1ib | 0.1.0 | ftw-core | not_assessed | — | not_recorded | not_assessed | | remne_p1ib | 0.1.0 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | | saj | 1.2.0 | ftw-core | not_assessed | — | not_recorded | not_assessed | diff --git a/devices.yaml b/devices.yaml index ce6b80c..3dd4a5e 100644 --- a/devices.yaml +++ b/devices.yaml @@ -1021,7 +1021,7 @@ manufacturers: protocols: - protocol: mqtt driver: "pixii_pv" - version: "0.3.3" + version: "0.3.2" ders: [pv, meter] control: false firmware_versions: "" diff --git a/drivers/lua/pixii_pv.lua b/drivers/lua/pixii_pv.lua index eba5441..2b50c6b 100644 --- a/drivers/lua/pixii_pv.lua +++ b/drivers/lua/pixii_pv.lua @@ -14,7 +14,7 @@ DRIVER = { id = "pixii_pv", name = "Pixii PowerShaper (PV + meter)", manufacturer = "Pixii", - version = "0.3.3", + version = "0.3.2", protocols = { "mqtt" }, capabilities = { "pv", "meter" }, read_only = true, @@ -79,14 +79,9 @@ function driver_init(config) host.log("info", "Pixii: subscribed to meter_ext6 + meter") end --- Five seconds until this driver has been seen at one second on hardware. --- Hosts that honour this return would otherwise poll it every second. It --- reads MQTT, so the hold is about unproven behaviour, not bus load. -local POLL_MS = 5000 - function driver_poll() local messages = host.mqtt_messages() - if not messages then return POLL_MS end + if not messages then return 1000 end local pv_payload, meter_payload = nil, nil @@ -167,7 +162,7 @@ function driver_poll() if building_w then host.emit_metric("building_ac_w", building_w) end end - return POLL_MS + return 1000 end function driver_command(action) diff --git a/index.yaml b/index.yaml index 59ac4fd..681b037 100644 --- a/index.yaml +++ b/index.yaml @@ -507,14 +507,14 @@ drivers: size_bytes: 29119 sha256: "5fc8e600eac7a70020e01e32c531f350f49bd076cac8a5598c2c06205b05f6b8" - name: "pixii_pv" - version: "0.3.3" + version: "0.3.2" tier: core protocol: mqtt connectivity: local ders: [pv, meter] control: false - size_bytes: 6946 - sha256: "566d789af826aad67ad6d72eb947e06e30a4cbbe070cfc5bdacc737755f55897" + size_bytes: 6699 + sha256: "7a27b937bdc845dff8d4032cb96ccca16242cc8378d1cbf5cfc8030660b871db" - name: "remne_p1ib" version: "0.1.0" tier: community diff --git a/manifests/pixii_pv.yaml b/manifests/pixii_pv.yaml index fb41f61..2c26a15 100644 --- a/manifests/pixii_pv.yaml +++ b/manifests/pixii_pv.yaml @@ -1,5 +1,5 @@ name: "pixii_pv" -version: "0.3.3" +version: "0.3.2" tier: core author: "Sourceful Labs AB" protocol: mqtt @@ -15,9 +15,9 @@ tested_devices: notes: "Read-only Pixii telemetry: PV (external CTs) + grid meter (meter_w). Battery / SoC comes from the Modbus driver, not this one." min_driver_version: "0.2.0" min_host_version: "2.0.0" -size_bytes: 6946 +size_bytes: 6699 dkb_id: "pixii_pv" -sha256: "566d789af826aad67ad6d72eb947e06e30a4cbbe070cfc5bdacc737755f55897" +sha256: "7a27b937bdc845dff8d4032cb96ccca16242cc8378d1cbf5cfc8030660b871db" signature: "" bytecode_sha256: "" diff --git a/support-status.json b/support-status.json index f0a9b85..139b53b 100644 --- a/support-status.json +++ b/support-status.json @@ -1157,7 +1157,7 @@ }, { "catalog_source": true, - "catalog_version": "0.3.3", + "catalog_version": "0.3.2", "driver_id": "pixii_pv", "targets": { "blixt-l1": { From 3802883cd2a67f58e66f15277695cc06e5df82ed Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Wed, 14 Oct 2026 07:54:16 +0200 Subject: [PATCH 5/5] Revert "fix(sdm630): stay at five seconds until seen at one second (2.0.3)" The owner says an SDM630 easily reads at one second, so it runs at the 1000 ms its driver asks for. This reverts commit 48cae6cb3d6e5da7468de9288a97024f47a5960e. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Fredrik Ahlgren --- CHANGELOG.md | 8 -------- SUPPORT_STATUS.md | 4 ++-- devices.yaml | 2 +- drivers/lua/sdm630.lua | 12 +++--------- index.yaml | 6 +++--- manifests/sdm630.yaml | 6 +++--- support-status.json | 2 +- 7 files changed, 13 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f54c0e..f6fcb12 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,13 +1,5 @@ # Changelog -## sdm630 2.0.3 - -Return 5000 ms from `driver_poll` instead of 1000. FTW ignored the return until -srcfl/ftw#1616 and read this meter every five seconds; that PR makes the return -count. An SDM630 often shares an RS485 bus with an inverter, and its one -76-register read takes about 0.18 s at 9600 baud. It stays at five seconds -until it has been seen at one second on hardware. - ## sungrow 1.6.0 Read the live values once a second. `driver_poll` returns 1000 ms. Each poll diff --git a/SUPPORT_STATUS.md b/SUPPORT_STATUS.md index a17eb86..0b0bdf8 100644 --- a/SUPPORT_STATUS.md +++ b/SUPPORT_STATUS.md @@ -124,8 +124,8 @@ Catalog source is not proof that a target can install or run a driver. | schneider_meter | 1.0.5 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | | schrack_ev | 1.0.3 | ftw-core | not_assessed | — | not_recorded | not_assessed | | schrack_ev | 1.0.3 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | -| sdm630 | 2.0.3 | ftw-core | not_assessed | — | not_recorded | not_assessed | -| sdm630 | 2.0.3 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | +| sdm630 | 2.0.2 | ftw-core | not_assessed | — | not_recorded | not_assessed | +| sdm630 | 2.0.2 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | | shelly | 1.1.2 | ftw-core | not_assessed | — | not_recorded | not_assessed | | shelly | 1.1.2 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | | siemens_pac | 1.0.5 | ftw-core | not_assessed | — | not_recorded | not_assessed | diff --git a/devices.yaml b/devices.yaml index 3dd4a5e..5bd6a3f 100644 --- a/devices.yaml +++ b/devices.yaml @@ -351,7 +351,7 @@ manufacturers: protocols: - protocol: modbus driver: "sdm630" - version: "2.0.3" + version: "2.0.2" ders: [meter] control: false firmware_versions: "" diff --git a/drivers/lua/sdm630.lua b/drivers/lua/sdm630.lua index 002539c..d5911f9 100644 --- a/drivers/lua/sdm630.lua +++ b/drivers/lua/sdm630.lua @@ -14,7 +14,7 @@ DRIVER = { id = "sdm630", name = "Eastron SDM630 meter", manufacturer = "Eastron", - version = "2.0.3", + version = "2.0.2", protocols = { "modbus" }, capabilities = { "meter" }, read_only = true, @@ -74,19 +74,13 @@ function driver_init(config) return true end --- Five seconds until an SDM630 has been seen at one second on hardware. It --- often sits on an RS485 bus shared with an inverter, and one 76-register --- read takes about 0.18 s at 9600 baud. Hosts that honour this return would --- otherwise poll it every second. -local POLL_MS = 5000 - function driver_poll() -- One FC04 request covers the measurement window 0x0000..0x004B. local ok, regs = pcall(host.modbus_read, 0x0000, 76, "input") if not ok or not regs or #regs < 76 then -- Do not emit fabricated zeros on a failed primary read. Both hosts -- then expose staleness to their own safety/watchdog layer. - return POLL_MS + return 1000 end local base = 0x0000 @@ -136,7 +130,7 @@ function driver_poll() total_export_Wh = export_wh, }) - return POLL_MS + return 1000 end function driver_command(action, value, context) diff --git a/index.yaml b/index.yaml index 681b037..4e4ba0f 100644 --- a/index.yaml +++ b/index.yaml @@ -554,14 +554,14 @@ drivers: size_bytes: 3554 sha256: "652db1569e74d8a451ddfcefb4f55d897e144b9a955608d20b4278ed6b9a2fdd" - name: "sdm630" - version: "2.0.3" + version: "2.0.2" tier: core protocol: modbus connectivity: local ders: [meter] control: false - size_bytes: 5315 - sha256: "fe01f6156eb956f3445f78d668e9c01a86bcd479983def73fcf4383edea82eed" + size_bytes: 5024 + sha256: "d879dd48e0ab6bf42547745659798473267e52bdaa84c93b94b5a6dc9969ac4c" - name: "shelly" version: "1.1.2" tier: community diff --git a/manifests/sdm630.yaml b/manifests/sdm630.yaml index e7ea691..1463ed3 100644 --- a/manifests/sdm630.yaml +++ b/manifests/sdm630.yaml @@ -1,5 +1,5 @@ name: "sdm630" -version: "2.0.3" +version: "2.0.2" tier: core author: "Sourceful Labs AB" protocol: modbus @@ -15,9 +15,9 @@ tested_devices: notes: "" min_driver_version: "1.1.1" min_host_version: "2.0.0" -size_bytes: 5315 +size_bytes: 5024 dkb_id: "sdm630" -sha256: "fe01f6156eb956f3445f78d668e9c01a86bcd479983def73fcf4383edea82eed" +sha256: "d879dd48e0ab6bf42547745659798473267e52bdaa84c93b94b5a6dc9969ac4c" signature: "" bytecode_sha256: "" diff --git a/support-status.json b/support-status.json index 139b53b..67eef26 100644 --- a/support-status.json +++ b/support-status.json @@ -1262,7 +1262,7 @@ }, { "catalog_source": true, - "catalog_version": "2.0.3", + "catalog_version": "2.0.2", "driver_id": "sdm630", "targets": { "blixt-l1": {