diff --git a/CHANGELOG.md b/CHANGELOG.md index 52a8522..399f8d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,32 @@ ## [Unreleased] +### sungrow 1.6.1 + +An inverter that drops off the network no longer loses registers or its +release. FTW's random site rig takes the inverter offline for 20 to 90 s. +Every read and write then fails, and the driver counted those failures as the +device refusing them. + +- Reads: 12999, 13000 and the PV registers were dropped for the session after + three failed polls. 13000 signs the battery, so FTW read a discharge as a + charge for the rest of the run. A failed read now counts only when the + device answers with a Modbus exception, or when another read in the same + poll returned data. The first poll that reads data after a poll that read + nothing starts every count over. +- Writes: three failed writes marked the EMS block absent, so the watchdog's + release was reported as held without being written. The inverter kept its + last forced command for up to five minutes. Only a Modbus exception now + counts toward that, and a reconnect resets the count. Once this driver has + put the inverter in forced mode, the release is always written, and is never + reported as held, until the inverter takes it. + +Gateway exceptions 0x0A and 0x0B and the busy exception 0x06 do not count: +they say the device could not answer, not that the register is missing. + +FTW's bundled copy does not move with this commit. Bump the pin and run +`scripts/sync-bundled-drivers.sh`. + ### easee_cloud 1.4.0 Read Easee's SignalR stream when the host grants a WebSocket. Easee diff --git a/SUPPORT_STATUS.md b/SUPPORT_STATUS.md index 3cff62e..2285c99 100644 --- a/SUPPORT_STATUS.md +++ b/SUPPORT_STATUS.md @@ -158,8 +158,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.6.0 | ftw-core | not_assessed | — | not_recorded | not_assessed | -| sungrow | 1.6.0 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | +| sungrow | 1.6.1 | ftw-core | not_assessed | — | not_recorded | not_assessed | +| sungrow | 1.6.1 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | | tesla_cloud | 0.2.0 | ftw-core | not_assessed | — | not_recorded | not_assessed | | tesla_cloud | 0.2.0 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | | tesla_vehicle | 0.2.6 | ftw-core | not_assessed | — | not_recorded | not_assessed | diff --git a/devices.yaml b/devices.yaml index 478a570..723fd8b 100644 --- a/devices.yaml +++ b/devices.yaml @@ -1762,7 +1762,7 @@ manufacturers: protocols: - protocol: modbus driver: "sungrow" - version: "1.6.0" + version: "1.6.1" ders: [battery, meter, pv] control: true firmware_versions: "" @@ -1773,7 +1773,7 @@ manufacturers: protocols: - protocol: modbus driver: "sungrow" - version: "1.6.0" + version: "1.6.1" ders: [battery, meter, pv] control: true firmware_versions: "" @@ -1784,7 +1784,7 @@ manufacturers: protocols: - protocol: modbus driver: "sungrow" - version: "1.6.0" + version: "1.6.1" ders: [battery, meter, pv] control: true firmware_versions: "" @@ -1795,7 +1795,7 @@ manufacturers: protocols: - protocol: modbus driver: "sungrow" - version: "1.6.0" + version: "1.6.1" ders: [battery, meter, pv] control: true firmware_versions: "" @@ -1806,7 +1806,7 @@ manufacturers: protocols: - protocol: modbus driver: "sungrow" - version: "1.6.0" + version: "1.6.1" ders: [battery, meter, pv] control: true firmware_versions: "" diff --git a/drivers/lua/sungrow.lua b/drivers/lua/sungrow.lua index 36efb80..b035dc8 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.6.0", + version = "1.6.1", protocols = { "modbus" }, capabilities = { "meter", "pv", "battery", "pv-curtail" }, description = "Sungrow SH-series hybrid inverters with LFP battery, via Modbus TCP.", @@ -150,6 +150,37 @@ local function known_to_have_no_battery() return model_family == "string" end +---------------------------------------------------------------------------- +-- What a failure proves +-- +-- A Modbus exception is the device itself saying no: it is up, and it does +-- not take that register. Anything else -- a refused or reset connection, a +-- timeout, FTW's reconnect backoff -- says only that the link is down, and +-- proves nothing about the register. +-- +-- FTW's random site rig showed what counting both costs (27 October 2026). +-- An inverter offline for 20 to 90 s failed every read and every write. The +-- driver counted those as refusals: it dropped 12999, 13000 and the PV +-- registers for the rest of the session, so FTW read the battery with the +-- wrong sign, and it stopped writing the EMS block, so the watchdog heard +-- that the default held while the inverter kept its last forced discharge. +-- +-- Codes 0x0A and 0x0B mean a gateway could not reach the device, and 0x06 +-- means it was busy. None of them says the register is absent. +local LINK_EXCEPTION_CODES = { [0x06] = true, [0x0A] = true, [0x0B] = true } + +local function device_refused(why) + if type(why) ~= "string" then return false end + local text = why:lower() + if text:find("gateway", 1, true) or text:find("busy", 1, true) then + return false + end + local code = text:match("exception.-code=0x(%x+)") + if code then return not LINK_EXCEPTION_CODES[tonumber(code, 16)] end + return text:find("modbus exception", 1, true) ~= nil + or text:find("illegal", 1, true) ~= nil +end + ---------------------------------------------------------------------------- -- The EMS control block, 13049-13051 -- @@ -186,19 +217,26 @@ end local EMS_WRITE_ATTEMPTS = 3 local ems_write_failures = 0 +-- Set once the device has taken forced mode from this driver, cleared once it +-- has taken the release. While set, the device may be holding a forced state, +-- so the release is always written and never reported as held unwritten. +local forced_mode_written = false + -- True while the EMS block is still worth writing unprompted. local function ems_block_worth_writing() - return ems_write_failures < EMS_WRITE_ATTEMPTS + return ems_write_failures < EMS_WRITE_ATTEMPTS or forced_mode_written end -- Record what the device did with one EMS operation. Counts the operation, -- not each of its three registers: the trio goes out together and fails --- together, so one rejected trio is one piece of evidence. +-- together, so one rejected trio is one piece of evidence. Only a refusal +-- from the device counts; a write that never reached it proves nothing. local function note_ems_write(write_err) if write_err == nil then ems_write_failures = 0 return end + if not device_refused(write_err) then return end ems_write_failures = ems_write_failures + 1 if ems_write_failures == EMS_WRITE_ATTEMPTS then host.log("info", "Sungrow: the EMS control block rejected " @@ -233,6 +271,44 @@ local function never_sent(ok, first, second) return type(why) == "string" and why:find("reconnect backoff", 1, true) ~= nil end +-- A failure that is not a refusal counts only if another read in the same +-- poll returned data: then the link was up and this register still did not +-- answer. A poll that read nothing is an outage, and the first poll that reads +-- again after one is a reconnect, which starts every count over. +local poll_read_data = false +local poll_unproved = {} +local link_down = false + +local function count_miss(address) + local misses = (miss_counts[address] or 0) + 1 + miss_counts[address] = misses + if misses >= MISSES_BEFORE_SKIP then + host.log("info", "Sungrow: register " .. address .. " failed " + .. misses .. " reads in a row; treating it as absent") + end +end + +local function begin_poll() + poll_read_data = false + poll_unproved = {} +end + +local function settle_poll() + if not poll_read_data then + link_down = true + return + end + if link_down then + link_down = false + miss_counts = {} + ems_write_failures = 0 + host.log("info", "Sungrow: the device answers again; " + .. "every register and the EMS block start over") + return + end + for _, address in ipairs(poll_unproved) do count_miss(address) end +end + local function optional_read(address, count, kind) if (miss_counts[address] or 0) >= MISSES_BEFORE_SKIP then return nil @@ -240,21 +316,24 @@ local function optional_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. + -- the rest nil, and arithmetic on nil ends the whole poll. The device did + -- reply, so a short reply counts like a refusal. + local short = false if ok and registers ~= nil then for i = 1, count do - if registers[i] == nil then ok = false break end + if registers[i] == nil then ok = false short = true break end end end if ok and registers ~= nil then miss_counts[address] = nil + poll_read_data = true return registers end - local misses = (miss_counts[address] or 0) + 1 - miss_counts[address] = misses - if misses >= MISSES_BEFORE_SKIP then - host.log("info", "Sungrow: register " .. address .. " failed " - .. misses .. " reads in a row; treating it as absent") + local why = ok and err or registers + if short or device_refused(why) then + count_miss(address) + else + poll_unproved[#poll_unproved + 1] = address end return nil end @@ -436,7 +515,7 @@ local function full_read_due(now_ms) or now_ms - last_full_read_ms >= FULL_READ_MS - POLL_MS / 2 end -function driver_poll() +local function read_and_emit() local now_ms = host.millis() local full = full_read_due(now_ms) if full then last_full_read_ms = now_ms end @@ -849,6 +928,13 @@ function driver_poll() return POLL_MS end +function driver_poll() + begin_poll() + local interval = read_and_emit() + settle_poll() + return interval +end + ---------------------------------------------------------------------------- -- Battery control ---------------------------------------------------------------------------- @@ -943,6 +1029,7 @@ function set_battery_power(power_w) local mode_err = host.modbus_write(13049, 2) -- 1. forced mode local cmd_err = host.modbus_write(13050, want_cmd) -- 2. charge/discharge cmd local power_err = host.modbus_write(13051, watts) -- 3. power setpoint + if mode_err == nil then forced_mode_written = true end local write_err = first_write_error(mode_err, cmd_err, power_err) -- A command always attempts, however many times the block has refused -- before: only the unprompted paths give up. But the outcome still counts, @@ -987,6 +1074,7 @@ function set_battery_idle() local mode_err = host.modbus_write(13049, 2) -- forced mode local cmd_err = host.modbus_write(13050, 0xCC) -- stop forced charge/discharge local power_err = host.modbus_write(13051, 0) -- zero power setpoint + if mode_err == nil then forced_mode_written = true end local write_err = first_write_error(mode_err, cmd_err, power_err) -- Reached by a zero-watt command, which 1.5.4 accepts on every family. -- It still attempts; the count only decides the unprompted paths. @@ -1020,10 +1108,11 @@ end -- write has to be let go of. function set_self_consumption() -- Nothing to hand back on a device that has refused this block three - -- times: a write it rejects cannot have latched a forced state. Report the - -- default as held rather than failed -- the watchdog reads a false as the - -- safe state being unreachable and escalates, which is the wrong answer - -- for an inverter that was never under control to begin with. + -- times and never took forced mode from this driver. Report the default + -- as held rather than failed -- the watchdog reads a false as the safe + -- state being unreachable and escalates, which is the wrong answer for an + -- inverter that was never under control to begin with. Once forced mode + -- has landed, ems_block_worth_writing stays true until the release lands. if not ems_block_worth_writing() then return true end @@ -1043,13 +1132,13 @@ function set_self_consumption() host.log("debug", "Sungrow: self-consumption mode") local ok, ems = pcall(host.modbus_read, 13049, 3, "holding") - if not ok or not ems then return true end - if ems[1] ~= 0 or ems[2] ~= 0xCC or ems[3] ~= 0 then + if ok and ems and (ems[1] ~= 0 or ems[2] ~= 0xCC or ems[3] ~= 0) then host.log("warn", string.format( "Sungrow: self-consumption reset not latched (mode=%d cmd=0x%02x power=%dW)", ems[1] or -1, ems[2] or -1, ems[3] or -1)) return false end + forced_mode_written = false return true end diff --git a/drivers/tests/test_sungrow_outage.py b/drivers/tests/test_sungrow_outage.py new file mode 100644 index 0000000..09157a5 --- /dev/null +++ b/drivers/tests/test_sungrow_outage.py @@ -0,0 +1,297 @@ +"""An inverter that drops off the network is not an inverter without registers. + +FTW's random site rig takes the Sungrow offline for 20 to 90 s and brings it +back (srcfl/ftw nightly, 27 October 2026). Every read and write fails while it +is gone, and none of those failures came from the device. Before 1.6.1 the +driver counted them as if they had: + +- reads: 12999, 13000 and the PV registers were dropped for the session. 13000 + signs the battery, so FTW read a discharge as a charge for the rest of the + run (seed 207539: 105 samples). +- writes: three failed EMS writes marked the block absent, so the release was + reported as held without being written, while the inverter kept the last + forced discharge (seed 207534: 5000 W for up to five minutes). + +Only a Modbus exception, the device's own refusal, proves a register absent. +""" + +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") + +HEALTHY_HYBRID = ''' +host._modbus_registers.input[4999] = {0x0E0E} +host._modbus_registers.input[5000] = {100} +host._modbus_registers.input[5016] = {4000, 0} +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} +''' + +# What FTW's host hands the driver while the device is gone. The first is the +# dial error the rig logged; the last two are what a Modbus gateway answers +# when it cannot reach the device behind it. +LINK_ERRORS = [ + "dial tcp 127.0.0.1:502: connect: connection refused", + "read after reconnect: modbus reconnect backoff active for 1.243s " + "(original: EOF)", + "modbus transport failure: i/o timeout", + "modbus exception function=0x03 code=0x0b", + "modbus exception function=0x06 code=0x0a", +] + +REFUSAL = "modbus exception function=0x06 code=0x02" + + +def run_lua(body: str) -> dict[str, str]: + script = f''' +package.path = "{HARNESS}/?.lua;" .. package.path +require("host_mock") +host.reset() +host._millis_step = 1000 -- one poll a second, a full read every five +{HEALTHY_HYBRID} +dofile("{DRIVER}") +driver_init({{}}) +for _ = 1, 3 do driver_poll() end +{body} +''' + 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 offline(err: str, polls: int = 5, defaults: int = 5) -> str: + return f''' +host._modbus_read_error = "{err}" +host._modbus_write_error = "{err}" +for _ = 1, {polls} do driver_poll() end +for _ = 1, {defaults} do driver_default_mode() end +host._modbus_read_error = nil +host._modbus_write_error = nil +''' + + +def reads_on_next_poll() -> str: + return ''' +local before = #host._calls +driver_poll() +local read = {} +for i = before + 1, #host._calls do + local call = host._calls[i] + if call.func == "modbus_read" then read[call.args[1]] = true end +end +for _, addr in ipairs({12999, 13000, 5016, 5010}) do + print("READ_" .. addr .. " " .. tostring(read[addr] == true)) +end +local emits = host._emitted.battery +print("BATTERY_W " .. tostring(emits[#emits].w)) +''' + + +@pytest.mark.parametrize("err", LINK_ERRORS) +def test_an_outage_does_not_drop_registers(err: str) -> None: + """The device comes back; so must 13000 and the PV registers.""" + # Five polls: one full read lands every five, so 12999 is read too. + out = run_lua(offline(err, polls=12, defaults=0) + + "for _ = 1, 4 do driver_poll() end\n" + + reads_on_next_poll()) + + for addr in (13000, 5016, 5010): + assert out[f"READ_{addr}"] == "true", ( + f"register {addr} was not read after the device came back: the " + f"outage ({err!r}) was counted as proof it is absent. {out}") + assert out["BATTERY_W"] == "-900", ( + f"the battery discharges at 900 W but reads {out['BATTERY_W']}: " + f"13000, which signs it, was dropped during the outage") + + +def test_an_outage_does_not_drop_the_running_state() -> None: + """12999 is read on full polls only, so check it across five.""" + out = run_lua(offline(LINK_ERRORS[0], polls=20, defaults=0) + ''' +local before = #host._calls +for _ = 1, 6 do driver_poll() end +local seen = false +for i = before + 1, #host._calls do + local call = host._calls[i] + if call.func == "modbus_read" and call.args[1] == 12999 then seen = true end +end +print("READ_12999 " .. tostring(seen)) +''') + assert out["READ_12999"] == "true", out + + +def test_a_refused_register_is_still_given_up_while_others_answer() -> None: + """The bound still works: an exception is the device saying no.""" + out = run_lua(''' +host._modbus_read_fail_addresses[5743] = "Illegal Data Address" +for _ = 1, 4 do driver_poll() end +''' + ''' +local before = #host._calls +driver_poll() +local read = false +for i = before + 1, #host._calls do + if host._calls[i].func == "modbus_read" and host._calls[i].args[1] == 5743 then + read = true + end +end +print("READ_5743 " .. tostring(read)) +''') + assert out["READ_5743"] == "false", "a refused register was read forever" + + +def test_a_timeout_on_one_register_counts_while_the_rest_answer() -> None: + """A transport-shaped failure still counts when the link is plainly up.""" + out = run_lua(''' +host._modbus_read_fail_addresses[5743] = "i/o timeout" +for _ = 1, 4 do driver_poll() end +local before = #host._calls +driver_poll() +local read = false +for i = before + 1, #host._calls do + if host._calls[i].func == "modbus_read" and host._calls[i].args[1] == 5743 then + read = true + end +end +print("READ_5743 " .. tostring(read)) +''') + assert out["READ_5743"] == "false", ( + "a register that times out while every other read answers was read " + "forever") + + +def test_a_reconnect_starts_the_read_count_over() -> None: + """Two refusals, an outage, then one more refusal: not yet three.""" + out = run_lua(''' +host._modbus_read_fail_addresses[5743] = "Illegal Data Address" +driver_poll() +driver_poll() +host._modbus_read_error = "dial tcp 127.0.0.1:502: connect: connection refused" +driver_poll() +host._modbus_read_error = nil +driver_poll() +local before = #host._calls +driver_poll() +local read = false +for i = before + 1, #host._calls do + if host._calls[i].func == "modbus_read" and host._calls[i].args[1] == 5743 then + read = true + end +end +print("READ_5743 " .. tostring(read)) +''') + assert out["READ_5743"] == "true", ( + "misses counted before an outage carried over the reconnect") + + +def forced_then(body: str) -> str: + return ''' +print("FORCED " .. tostring(driver_command("battery", -3000, {}) == true)) +''' + body + ''' +host._modbus_write_attempts = 0 +local held = driver_default_mode() +print("RELEASE_WRITES " .. tostring(host._modbus_write_attempts)) +print("RELEASED " .. tostring(held == true)) +print("MODE " .. tostring(host._modbus_registers.holding[13049])) +''' + + +@pytest.mark.parametrize("err", LINK_ERRORS) +def test_the_release_is_written_when_the_device_returns(err: str) -> None: + """Seeds 207533-207539: a forced discharge outlived the outage.""" + out = run_lua(forced_then(offline(err))) + + assert out["FORCED"] == "true" + assert int(out["RELEASE_WRITES"]) >= 3, ( + f"the inverter came back holding forced discharge and the watchdog's " + f"release wrote {out['RELEASE_WRITES']} registers. The outage " + f"({err!r}) was counted as the device refusing the EMS block.") + assert out["MODE"] == "0", f"EMS mode left at {out['MODE']}, not 0" + assert out["RELEASED"] == "true" + + +def test_the_release_is_never_reported_held_while_forced_mode_may_be_latched() -> None: + """Even three real refusals do not excuse it once forced mode landed.""" + out = run_lua(forced_then(f''' +host._modbus_write_error = "{REFUSAL}" +local answers = {{}} +for _ = 1, 5 do answers[#answers + 1] = tostring(driver_default_mode()) end +print("DURING " .. table.concat(answers, ",")) +host._modbus_write_error = nil +''')) + + assert "true" not in out["DURING"].split(","), ( + f"driver_default_mode reported the default held without writing it: " + f"{out['DURING']}. This driver put the inverter in forced mode, so " + f"the inverter may still be holding it.") + assert int(out["RELEASE_WRITES"]) >= 3 + assert out["MODE"] == "0" + + +def test_failures_during_an_outage_report_the_release_as_failed() -> None: + """While nothing reaches the device, the honest answer is false.""" + out = run_lua(forced_then(''' +host._modbus_read_error = "dial tcp 127.0.0.1:502: connect: connection refused" +host._modbus_write_error = host._modbus_read_error +local answers = {} +for _ = 1, 5 do answers[#answers + 1] = tostring(driver_default_mode()) end +print("DURING " .. table.concat(answers, ",")) +host._modbus_read_error = nil +host._modbus_write_error = nil +''')) + assert out["DURING"] == ",".join(["false"] * 5), out["DURING"] + + +def test_a_reconnect_starts_the_write_count_over() -> None: + """Two refusals, an outage, then two more: not yet three in a row.""" + out = run_lua(f''' +host._modbus_write_error = "{REFUSAL}" +driver_default_mode() +driver_default_mode() +host._modbus_write_error = nil +host._modbus_read_error = "dial tcp 127.0.0.1:502: connect: connection refused" +driver_poll() +host._modbus_read_error = nil +driver_poll() +host._modbus_write_error = "{REFUSAL}" +driver_default_mode() +driver_default_mode() +host._modbus_write_attempts = 0 +driver_default_mode() +print("WRITES " .. tostring(host._modbus_write_attempts)) +''') + assert int(out["WRITES"]) >= 3, ( + "refusals counted before an outage carried over the reconnect") + + +def test_a_device_that_refuses_the_block_still_settles() -> None: + """The SG12RT rule from 1.5.7 holds: never forced, three refusals, stop.""" + out = run_lua(f''' +host._modbus_write_error = "{REFUSAL}" +local writes = {{}} +for _ = 1, 6 do + host._modbus_write_attempts = 0 + driver_default_mode() + writes[#writes + 1] = host._modbus_write_attempts +end +print("WRITES " .. table.concat(writes, ",")) +''') + assert out["WRITES"].split(",")[-3:] == ["0", "0", "0"], out["WRITES"] diff --git a/index.yaml b/index.yaml index e2744f1..b701ed6 100644 --- a/index.yaml +++ b/index.yaml @@ -710,14 +710,14 @@ drivers: size_bytes: 6631 sha256: "94ae5e0065f5793051acb89b7ef04a679b2898908dfac6f4ffd6e8deb77fa2c0" - name: "sungrow" - version: "1.6.0" + version: "1.6.1" tier: core protocol: modbus connectivity: local ders: [battery, meter, pv] control: true - size_bytes: 52811 - sha256: "db4e378f49f6421c93621e0f101f00c64e5459a38190171003476f9f2b2cc4c1" + size_bytes: 56325 + sha256: "b89fd9cd70ea67be3c7f0089a3c6d5552d8bbc994e39b8720dbd8d4d9892261b" - name: "tesla_cloud" version: "0.2.0" tier: community diff --git a/manifests/sungrow.yaml b/manifests/sungrow.yaml index 0f32ba3..cfe3857 100644 --- a/manifests/sungrow.yaml +++ b/manifests/sungrow.yaml @@ -1,5 +1,5 @@ name: "sungrow" -version: "1.6.0" +version: "1.6.1" 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: 52811 +size_bytes: 56325 dkb_id: "sungrow_sh_rt" -sha256: "db4e378f49f6421c93621e0f101f00c64e5459a38190171003476f9f2b2cc4c1" +sha256: "b89fd9cd70ea67be3c7f0089a3c6d5552d8bbc994e39b8720dbd8d4d9892261b" signature: "" bytecode_sha256: "" diff --git a/support-status.json b/support-status.json index 054edf7..4f61ce4 100644 --- a/support-status.json +++ b/support-status.json @@ -1619,7 +1619,7 @@ }, { "catalog_source": true, - "catalog_version": "1.6.0", + "catalog_version": "1.6.1", "driver_id": "sungrow", "targets": { "blixt-l1": {