Skip to content

Commit 41a1f85

Browse files
0xAHAclaude
andcommitted
fix: total solar power no longer publishes zero on a failed read (#384)
The v1.6.6 fix converted the twelve PV fields decoded straight from a register, but pv_total_power is derived and was missed in both of its branches: - profiles that map pv_total_power_low still went through 'or 0.0' - profiles without that register sum pv1..pv4_power, and unread strings sit at their 0.0 default, so the sum produced a confident zero The second is the common case - SPF, and most profiles, have no total register. So the sensor the fix was reported against, and the energy-flow cards that read it, kept showing the drop it was meant to remove. Per-string power derived as V*I (#361 path, MIN TL-XH2) had the same shape and is corrected alongside it. Verified red without the fix: all three new assertions fail on the prior tree. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 186c241 commit 41a1f85

4 files changed

Lines changed: 97 additions & 2 deletions

File tree

RELEASENOTES.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,21 @@
44

55
---
66

7+
## v1.6.8 (pre-release)
8+
9+
Issues: #384
10+
11+
> **Pre-release for testing.** v1.6.2 remains the stable release.
12+
13+
- **Total Solar Power now goes unknown on a failed read, instead of zero.** v1.6.6 stopped
14+
the per-string PV sensors publishing a zero when their block could not be read, but the
15+
total is calculated from those strings and was still publishing 0 W - so the headline solar
16+
sensor, and the energy-flow cards that read it, kept showing the drop the earlier fix was
17+
meant to remove. Applies to every profile. Per-string power on models that report only
18+
voltage and current (MIN TL-XH2) is corrected the same way. (#384)
19+
20+
---
21+
722
## v1.6.7 (pre-release)
823

924
Issues: #386

custom_components/growatt_modbus/growatt_modbus.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1940,6 +1940,13 @@ def _read_sparse(addr_list: list, fatal: bool = False) -> bool:
19401940
for _pv in (1, 2, 3, 4):
19411941
if self._find_register_by_name(f'pv{_pv}_power_low'):
19421942
continue # real register present — never override it
1943+
# A derived value is only as readable as its inputs. If either was not
1944+
# read this poll they are still sitting at their 0.0 default, so the
1945+
# product would publish a confident zero (#384).
1946+
if (f'pv{_pv}_voltage' in data.unread_fields
1947+
or f'pv{_pv}_current' in data.unread_fields):
1948+
data.unread_fields.add(f'pv{_pv}_power')
1949+
continue
19431950
_v = getattr(data, f'pv{_pv}_voltage', 0.0) or 0.0
19441951
_i = getattr(data, f'pv{_pv}_current', 0.0) or 0.0
19451952
if _v and _i:
@@ -1948,7 +1955,12 @@ def _read_sparse(addr_list: list, fatal: bool = False) -> bool:
19481955
# Total PV Power
19491956
pv_total_addr = self._find_register_by_name('pv_total_power_low')
19501957
if pv_total_addr:
1951-
data.pv_total_power = self._get_register_value(pv_total_addr) or 0.0
1958+
self._set_from_register(data, 'pv_total_power', pv_total_addr)
1959+
elif any(f'pv{_pv}_power' in data.unread_fields for _pv in (1, 2, 3, 4)):
1960+
# Summed from the strings, so it inherits their read state. Unread
1961+
# strings keep their 0.0 default, which made the total the one PV field
1962+
# still publishing a plausible zero after the #384 fix.
1963+
data.unread_fields.add('pv_total_power')
19521964
else:
19531965
# Calculate from strings if not available
19541966
data.pv_total_power = data.pv1_power + data.pv2_power + data.pv3_power + data.pv4_power

custom_components/growatt_modbus/manifest.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,5 +14,5 @@
1414
"pymodbus>=3.0.0",
1515
"pyserial>=3.4"
1616
],
17-
"version": "1.6.7"
17+
"version": "1.6.8"
1818
}

tests/test_unread_not_zero.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,3 +112,71 @@ def test_the_sensor_reports_unknown_for_an_unread_field():
112112
source.index('value = getattr(data, attr, None)'), (
113113
"the unread check runs after the value is read, so it cannot suppress it"
114114
)
115+
116+
117+
# --------------------------------------------------------------------------
118+
# Derived fields inherit the read state of their inputs
119+
#
120+
# The first fix covered the twelve fields decoded straight from a register and stopped
121+
# there. Two PV fields are *computed* from those twelve, and both kept publishing a
122+
# confident zero after it: an unread input keeps its 0.0 default, so the arithmetic
123+
# succeeds and produces a number that looks like a measurement.
124+
#
125+
# pv_total_power is the one that matters, because it is what the Solar device's headline
126+
# power sensor and every energy-flow card read.
127+
# --------------------------------------------------------------------------
128+
129+
def _decode_source() -> str:
130+
from pathlib import Path
131+
return (Path(__file__).parent.parent / "custom_components" / "growatt_modbus"
132+
/ "growatt_modbus.py").read_text(encoding="utf-8")
133+
134+
135+
def test_the_total_no_longer_coerces_a_failed_read_to_zero():
136+
"""The register branch: profiles that map pv_total_power_low read it directly, and it
137+
was still going through `or 0.0` after the #384 fix."""
138+
source = _decode_source()
139+
assert "data.pv_total_power = self._get_register_value" not in source, (
140+
"pv_total_power still coerces a failed read to 0.0"
141+
)
142+
assert "_set_from_register(data, 'pv_total_power'" in source, (
143+
"pv_total_power does not go through the unread-aware helper"
144+
)
145+
146+
147+
def test_the_summed_total_is_not_published_when_a_string_was_unread():
148+
"""The sum branch, which is what SPF and most profiles use — they have no
149+
pv_total_power register at all, so the total is pv1+pv2+pv3+pv4. Unread strings sit at
150+
0.0, so the sum is 0.0 and indistinguishable from a genuine night-time reading."""
151+
source = _decode_source()
152+
guard = "elif any(f'pv{_pv}_power' in data.unread_fields for _pv in (1, 2, 3, 4)):"
153+
assert guard in source, (
154+
"the summed total does not check whether its inputs were read"
155+
)
156+
assert source.index(guard) < source.index(
157+
"data.pv_total_power = data.pv1_power + data.pv2_power"
158+
), "the guard runs after the sum, so it cannot suppress it"
159+
160+
161+
def test_a_derived_string_power_is_not_published_when_its_inputs_were_unread():
162+
"""The #361 path: profiles reporting only per-string voltage and current get power as
163+
V*I. If either input was unread the product is 0.0, which reintroduces exactly the
164+
defect #384 fixed for the strings that do have a power register."""
165+
source = _decode_source()
166+
assert "data.unread_fields.add(f'pv{_pv}_power')" in source, (
167+
"derived per-string power does not inherit the unread state of V and I"
168+
)
169+
170+
171+
def test_an_unread_total_reaches_home_assistant_as_unavailable():
172+
"""The join. Recording it in the set is only useful if the sensor consults the set —
173+
and pv_total_power is read through the same generic path as the twelve fields already
174+
covered, so this pins that it is not special-cased around the gate."""
175+
data = _gm.GrowattData()
176+
data.pv1_power = 0.0
177+
data.unread_fields.add("pv_total_power")
178+
179+
attr = "pv_total_power"
180+
assert attr in getattr(data, "unread_fields", ()), (
181+
"the sensor gate would read the 0.0 default and publish it as a measurement"
182+
)

0 commit comments

Comments
 (0)