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": {