Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 18 additions & 9 deletions drivers/lua/growatt.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 ----

Expand Down
107 changes: 107 additions & 0 deletions drivers/tests/test_growatt_pv_only_battery.py
Original file line number Diff line number Diff line change
@@ -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
8 changes: 4 additions & 4 deletions manifests/growatt.yaml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
name: "growatt"
version: "2.1.2"
version: "2.1.3"
tier: community
author: "Sourceful Labs AB"
protocol: modbus
Expand Down Expand Up @@ -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)"