From cc16f0e88482c49452070ca932a783859a531032 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Tue, 27 Oct 2026 17:59:23 +0100 Subject: [PATCH] fix(sungrow): an outage no longer drops registers or the release (1.6.1) Count a failed read or EMS write only when the device answers with a Modbus exception. A failed read also counts when another read in the same poll returned data. Transport errors, reconnect backoff and gateway or busy exceptions never count. The first poll that reads data after a poll that read nothing clears the read misses and the EMS write count. Once forced mode has landed, set_self_consumption always writes the release and never reports the default as held until the inverter takes it. FTW's random site rig (27 October 2026, master 83c2b24c) found both: 13000 dropped after an outage flipped the battery sign (207539, 207534), and the release was skipped while the inverter held its last forced command for up to 5 minutes (8 of 10 seeds). Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 26 +++ SUPPORT_STATUS.md | 4 +- devices.yaml | 10 +- drivers/lua/sungrow.lua | 123 +++++++++-- drivers/tests/test_sungrow_outage.py | 297 +++++++++++++++++++++++++++ index.yaml | 6 +- manifests/sungrow.yaml | 6 +- support-status.json | 2 +- 8 files changed, 443 insertions(+), 31 deletions(-) create mode 100644 drivers/tests/test_sungrow_outage.py 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": {