diff --git a/SUPPORT_STATUS.md b/SUPPORT_STATUS.md index 4fec4ef..f602598 100644 --- a/SUPPORT_STATUS.md +++ b/SUPPORT_STATUS.md @@ -72,8 +72,8 @@ Catalog source is not proof that a target can install or run a driver. | goe_http | 1.0.1 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | | goodwe | 2.1.2 | ftw-core | not_assessed | — | not_recorded | not_assessed | | goodwe | 2.1.2 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | -| growatt | 2.1.2 | ftw-core | not_assessed | — | not_recorded | not_assessed | -| growatt | 2.1.2 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | +| growatt | 2.1.3 | ftw-core | not_assessed | — | not_recorded | not_assessed | +| growatt | 2.1.3 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | | hardybarth | 1.0.1 | ftw-core | not_assessed | — | not_recorded | not_assessed | | hardybarth | 1.0.1 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | | heishamon | 0.8.0 | ftw-core | not_assessed | — | not_recorded | not_assessed | diff --git a/devices.yaml b/devices.yaml index 2701aa6..7dfd711 100644 --- a/devices.yaml +++ b/devices.yaml @@ -533,7 +533,7 @@ manufacturers: protocols: - protocol: modbus driver: "growatt" - version: "2.1.2" + version: "2.1.3" ders: [pv, battery, meter] control: false firmware_versions: "" @@ -544,7 +544,7 @@ manufacturers: protocols: - protocol: modbus driver: "growatt" - version: "2.1.2" + version: "2.1.3" ders: [pv, battery, meter] control: false firmware_versions: "" @@ -555,7 +555,7 @@ manufacturers: protocols: - protocol: modbus driver: "growatt" - version: "2.1.2" + version: "2.1.3" ders: [pv, battery, meter] control: false firmware_versions: "" @@ -566,7 +566,7 @@ manufacturers: protocols: - protocol: modbus driver: "growatt" - version: "2.1.2" + version: "2.1.3" ders: [pv, battery, meter] control: false firmware_versions: "" @@ -577,7 +577,7 @@ manufacturers: protocols: - protocol: modbus driver: "growatt" - version: "2.1.2" + version: "2.1.3" ders: [pv, battery, meter] control: false firmware_versions: "" @@ -588,7 +588,7 @@ manufacturers: protocols: - protocol: modbus driver: "growatt" - version: "2.1.2" + version: "2.1.3" ders: [pv, battery, meter] control: false firmware_versions: "" diff --git a/drivers/lua/growatt.lua b/drivers/lua/growatt.lua index afc8789..5abd5ba 100644 --- a/drivers/lua/growatt.lua +++ b/drivers/lua/growatt.lua @@ -21,7 +21,7 @@ DRIVER = { id = "growatt", name = "Growatt hybrid inverter", manufacturer = "Growatt", - version = "2.1.2", + version = "2.1.3", protocols = { "modbus" }, capabilities = { "meter", "pv", "battery" }, read_only = true, @@ -181,14 +181,23 @@ function driver_poll() bat_temp = btemp_regs[1] * 0.1 end - host.emit("battery", { - w = bat_w, - v = bat_v, - soc = bat_soc, - temp_c = bat_temp, - }) - host.emit_metric("battery_dc_v", bat_v) - host.emit_metric("battery_temp_c", bat_temp) + -- A PV-only Growatt (no battery connected) still answers the battery + -- registers with 0, which made FTW render a phantom "Battery" node on + -- Overview even though no battery exists. A real battery reports a + -- terminal voltage and/or SoC above 0 even at rest, so treat all-zero + -- battery readings as "no battery present" and skip the emit. + -- see srcfl/ftw#1669 + local has_battery = bat_v > 0 or bat_soc > 0 or bat_charge_w > 0 or bat_discharge_w > 0 + if has_battery then + host.emit("battery", { + w = bat_w, + v = bat_v, + soc = bat_soc, + temp_c = bat_temp, + }) + host.emit_metric("battery_dc_v", bat_v) + host.emit_metric("battery_temp_c", bat_temp) + end -- ---- Meter ---- diff --git a/drivers/tests/invented-number-baseline.json b/drivers/tests/invented-number-baseline.json index 3cb282b..2c0de0f 100644 --- a/drivers/tests/invented-number-baseline.json +++ b/drivers/tests/invented-number-baseline.json @@ -31,7 +31,7 @@ "fronius": 11, "goe": 8, "goodwe": 24, - "growatt": 20, + "growatt": 16, "hello": 3, "janitza": 13, "keba": 8, diff --git a/drivers/tests/test_growatt_pv_only_battery.py b/drivers/tests/test_growatt_pv_only_battery.py new file mode 100644 index 0000000..42a7fbe --- /dev/null +++ b/drivers/tests/test_growatt_pv_only_battery.py @@ -0,0 +1,107 @@ +"""A PV-only Growatt must not emit a phantom Battery stream. + +A Growatt with no battery connected still answers the battery Modbus +registers with 0. The driver used to emit a `battery` stream on every poll +regardless, which made FTW render a "Battery Growatt" node on Overview even +though no battery exists (srcfl/ftw#1669). The driver now only emits battery +telemetry when a battery is actually present. + +CI builds ./lua55 (see tools/build_luac.sh) and runs this through the host +mock, so the guard holds in the pipeline. pytest skips it locally when the +interpreter is absent. +""" + +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +LUA = ROOT / "lua55" +HARNESS = ROOT / "drivers" / "tests" / "lua_harness" +GROWATT = ROOT / "drivers" / "lua" / "growatt.lua" + +pytestmark = pytest.mark.skipif( + not LUA.exists(), reason="run make check to build ./lua55") + + +def _run(body: str) -> dict[str, str]: + script = f''' +package.path = "{HARNESS}/?.lua;" .. package.path +require("host_mock") +host.reset() + +{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 _poll(registers: dict[str, int]) -> dict[str, str]: + setup = "\n".join( + f"host._modbus_registers.input[{addr}] = {val}" + for addr, val in registers.items() + ) + return _run(f''' +{setup} +dofile("{GROWATT}") +driver_init({{host = '127.0.0.1', port = 502, unit_id = 1}}) +local ok, err = pcall(driver_poll) +if not ok then print("POLL_ERROR " .. tostring(err)) os.exit(1) end +local bat = host._emitted.battery +if bat and #bat > 0 then + print("BATTERY_EMITTED 1") + print("BAT_V " .. tostring(bat[1].v)) + print("BAT_SOC " .. tostring(bat[1].soc)) + print("BAT_W " .. tostring(bat[1].w)) +else + print("BATTERY_EMITTED 0") +end +''') + + +def test_pv_only_install_emits_no_battery_node(): + """A Growatt answering all-zero battery registers must not emit battery.""" + out = _poll({}) # every register unmapped -> the mock returns 0 + assert out["BATTERY_EMITTED"] == "0", ( + "PV-only Growatt emitted a battery stream; Overview would show a " + "phantom Battery node (srcfl/ftw#1669)." + ) + + +def test_hybrid_install_with_battery_emits_battery(): + """A real battery (voltage + SoC + charge power) must still be reported.""" + out = _poll({ + 1009: 0, 1010: 100, # charge power 10 W + 1011: 0, 1012: 0, # discharge power 0 W + 1013: 480, # terminal voltage 48.0 V + 1014: 50, # SoC 50 % + }) + assert out["BATTERY_EMITTED"] == "1", ( + "Hybrid Growatt with a battery present did not emit battery telemetry." + ) + assert float(out["BAT_V"]) == 48.0 + assert float(out["BAT_SOC"]) == 0.5 + assert float(out["BAT_W"]) == 10.0 # charge - discharge + + +def test_battery_at_rest_with_zero_soc_still_emits(): + """A present battery reads a terminal voltage even at 0 % SoC. + + Guards against an over-strict gate that would drop a real battery just + because its SoC register reads 0. + """ + out = _poll({ + 1009: 0, 1010: 0, + 1011: 0, 1012: 0, + 1013: 520, # 52.0 V present + 1014: 0, # SoC 0 % + }) + assert out["BATTERY_EMITTED"] == "1", ( + "A battery at 0 % SoC but with terminal voltage was dropped; the gate " + "must key off voltage/power, not SoC alone." + ) + assert float(out["BAT_V"]) == 52.0 diff --git a/index.yaml b/index.yaml index ebee215..1a99d9a 100644 --- a/index.yaml +++ b/index.yaml @@ -313,14 +313,14 @@ drivers: size_bytes: 9566 sha256: "148d36d25fa630dd11ab1f697c46dde60863dcd3a2a9944e78e322dba55bde49" - name: "growatt" - version: "2.1.2" + version: "2.1.3" tier: community protocol: modbus connectivity: local ders: [pv, battery, meter] control: false - size_bytes: 9376 - sha256: "1e9623cbf3453697f0fba429a5b8dab7d61cf9507587e616d0cd9de3cc3594c8" + size_bytes: 9923 + sha256: "684c788e989c6f90b7a64fd79521f913104486f2c939bf13d4aaa2fbfddd9dad" - name: "hardybarth" version: "1.0.1" tier: community diff --git a/manifests/growatt.yaml b/manifests/growatt.yaml index 2ec89bd..1e1121c 100644 --- a/manifests/growatt.yaml +++ b/manifests/growatt.yaml @@ -1,5 +1,5 @@ name: "growatt" -version: "2.1.2" +version: "2.1.3" tier: community author: "Sourceful Labs AB" protocol: modbus @@ -50,12 +50,12 @@ tested_devices: notes: "Community driver, untested" min_driver_version: "1.0.0" min_host_version: "2.0.0" -size_bytes: 9376 +size_bytes: 9923 dkb_id: "" -sha256: "1e9623cbf3453697f0fba429a5b8dab7d61cf9507587e616d0cd9de3cc3594c8" +sha256: "684c788e989c6f90b7a64fd79521f913104486f2c939bf13d4aaa2fbfddd9dad" signature: "" bytecode_sha256: "" bytecode_signature: "" bytecode_size: 0 -changelog: "" +changelog: "2.1.3: skip battery telemetry when no battery is connected (PV-only installs), fixes phantom Battery node on Overview (srcfl/ftw#1669)" diff --git a/support-status.json b/support-status.json index fd82e8f..309845d 100644 --- a/support-status.json +++ b/support-status.json @@ -716,7 +716,7 @@ }, { "catalog_source": true, - "catalog_version": "2.1.2", + "catalog_version": "2.1.3", "driver_id": "growatt", "targets": { "blixt-l1": {