Skip to content

Commit 9f7d3d9

Browse files
0xAHAclaude
andcommitted
v1.3.7 (pre-release): validate response length on the shared hub (#367)
Register blocks are written into the cache positionally across 11 call sites -- regs[0] is assumed to be the block start. A short response, or a stale frame belonging to a different request, still got written sequentially from the start address, putting words on registers they never belonged to. The symptom was a plausible-looking wrong number rather than a missing one. tdalejandro decoded their corrupt readings and found 0x33325354 = "32ST", four characters of the inverter serial number, published as 85,893,614.8 W of AC power, and the firmware version string published as PV2 power. The non-shared path has checked response length since v1.3.5. The shared hub did not, and a hub is created for every TCP entry rather than only genuinely shared ones -- so that guard only ever protected serial/RTU users. Same guard-on-one-path-only shape as the block-size bug in v1.3.6. Changes: - _validate_registers() on SharedModbusConnection, used by both read methods so there is one implementation rather than two to keep in sync. - Length compared with != rather than <. An over-long response is an equally strong sign of a misaligned frame and is free to catch. - A detected mismatch drains the receive buffer. A misaligned stream stays misaligned, which is why the corrupt values repeated byte-for-byte instead of varying. - Restored read-failure tracking on the shared path. It returned before reaching the counters, so _consecutive_read_failures never moved for any TCP entry and the adaptive backoff could not engage. Diagnosed by tdalejandro, including the proposed fix. 15 new tests, verified by disabling the guard and confirming 9 of them fail. Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 8cff54d commit 9f7d3d9

4 files changed

Lines changed: 283 additions & 9 deletions

File tree

RELEASENOTES.md

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

55
---
66

7+
## v1.3.7 (pre-release)
8+
9+
Issues: #367
10+
11+
> **Pre-release.** This changes what happens when a Modbus read comes back malformed —
12+
> from "use it anyway" to "discard it". On a marginal RS485 gateway that will convert
13+
> polls that were *silently producing wrong values* into polls that visibly fail. That is
14+
> the correct trade, but the failure count in your log may go **up**. That is the fix
15+
> working, not a new fault.
16+
17+
- **Fix: malformed responses were written into the register cache on all TCP setups.**
18+
Register blocks are stored positionally — the first returned word is assumed to be the
19+
block's start address. When a response came back short, or was a stale frame belonging
20+
to a different request, its words were still written sequentially from the start
21+
address, landing on registers they never belonged to.
22+
23+
The result was not a missing sensor but a *plausible-looking wrong number*. @tdalejandro
24+
decoded their own corrupt readings and found `0x33325354` — the ASCII `"32ST"`, four
25+
characters of the inverter's serial number — published as **85,893,614.8 W** of AC
26+
power, and the firmware version string published as PV2 power. Because
27+
`total_increasing` energy sensors are affected too, those values entered long-term
28+
statistics.
29+
30+
The non-shared read path has validated response length since v1.3.5. The shared hub did
31+
not — and since a hub is created for **every** TCP entry, not only ones genuinely
32+
sharing a gateway, that guard in practice only ever protected serial/RTU users. The
33+
exposed group was everyone on TCP.
34+
35+
Diagnosed by @tdalejandro, including the proposed fix, which is what shipped.
36+
37+
- **A response *longer* than requested is now rejected too.**
38+
The existing guard tested `< count`. An over-long response is an equally strong sign of
39+
a misaligned or stale frame and costs nothing to catch, so the check is `!= count`.
40+
41+
- **Fix: the adaptive backoff never engaged on TCP connections.**
42+
The shared path returned before reaching the read-failure counters, so
43+
`_consecutive_read_failures` never moved for any TCP entry and the slow-poll backoff
44+
after repeated failures could not trigger. Found while fixing the above — the same
45+
guard-on-one-path-only pattern.
46+
47+
- **A detected misalignment now drains the receive buffer.**
48+
A misaligned stream stays misaligned, which is why the corrupt values repeated
49+
byte-for-byte instead of varying. Draining on detection gives the next read a clean
50+
start rather than inheriting the same offset.
51+
52+
- **Testing:** 15 new tests covering the guard, including the reporter's exact
53+
serial-number frame. Verified by disabling the guard and confirming they fail.
54+
55+
---
56+
757
## v1.3.6
858

959
Issues: #367

custom_components/growatt_modbus/growatt_modbus.py

Lines changed: 58 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -527,6 +527,46 @@ def _flush_receive_buffer(self) -> None:
527527
# Register access (slave_id passed per call, not stored on hub)
528528
# ------------------------------------------------------------------
529529

530+
def _validate_registers(self, resp, start: int, count: int) -> Optional[list]:
531+
"""Return the response's registers, or None if the frame cannot be trusted.
532+
533+
Callers write results into the register cache *positionally* — regs[0] is assumed
534+
to be `start`, regs[1] to be `start + 1`, and so on across 11 call sites. So a
535+
response whose length doesn't match the request is not a partial success to
536+
salvage: every word in it lands on an address it does not belong to. That is how
537+
string registers (a serial number, a firmware version string) ended up decoded and
538+
published as instantaneous power — 0x33325354 is "32ST", four characters of the
539+
reporter's serial number, shown as 85,893,614.8 W (#367).
540+
541+
The non-shared path has guarded this since v1.3.5, but every TCP entry goes through
542+
the shared hub (a hub is created even for a single entry), so in practice the guard
543+
only ever protected serial/RTU users — the ones least exposed to gateway framing
544+
problems in the first place.
545+
546+
Length is compared with != rather than <: a response *longer* than requested is an
547+
equally strong sign of a misaligned or stale frame, and costs nothing to catch.
548+
"""
549+
if hasattr(resp, 'isError') and callable(resp.isError) and resp.isError():
550+
return None
551+
552+
registers = resp.registers if hasattr(resp, 'registers') else None
553+
if registers is None:
554+
return None
555+
556+
if len(registers) != count:
557+
logger.warning(
558+
"[SharedConn %s:%s] Short/misaligned read at %d: got %d of %d registers — "
559+
"discarding frame and flushing buffer",
560+
self.host, self.port, start, len(registers), count,
561+
)
562+
# A misaligned stream stays misaligned — which is why the corrupt values
563+
# repeat byte-for-byte rather than varying. Draining the buffer here gives the
564+
# next read a clean start instead of inheriting the same offset.
565+
self._flush_receive_buffer()
566+
return None
567+
568+
return registers
569+
530570
def read_input_registers(self, start: int, count: int, slave_id: int) -> Optional[list]:
531571
if self._client is None:
532572
return None
@@ -565,9 +605,7 @@ def read_input_registers(self, start: int, count: int, slave_id: int) -> Optiona
565605
self.host, self.port, start, count, slave_id, exc)
566606
return None
567607

568-
if hasattr(resp, 'isError') and callable(resp.isError) and resp.isError():
569-
return None
570-
return resp.registers if hasattr(resp, 'registers') else None
608+
return self._validate_registers(resp, start, count)
571609
return None
572610

573611
def read_holding_registers(self, start: int, count: int, slave_id: int) -> Optional[list]:
@@ -597,9 +635,7 @@ def read_holding_registers(self, start: int, count: int, slave_id: int) -> Optio
597635
self.host, self.port, start, count, slave_id, exc)
598636
return None
599637

600-
if hasattr(resp, 'isError') and callable(resp.isError) and resp.isError():
601-
return None
602-
return resp.registers if hasattr(resp, 'registers') else None
638+
return self._validate_registers(resp, start, count)
603639
return None
604640

605641
def write_register(self, register: int, value: int, slave_id: int) -> bool:
@@ -962,7 +998,15 @@ def read_input_registers(self, start_address: int, count: int, log_errors: bool
962998
self._enforce_read_interval()
963999

9641000
if self._shared_conn is not None:
965-
return self._shared_conn.read_input_registers(start_address, count, self.slave_id)
1001+
registers = self._shared_conn.read_input_registers(start_address, count, self.slave_id)
1002+
# This used to return directly, bypassing the failure counters below — so
1003+
# _consecutive_read_failures never moved for any TCP entry and the adaptive
1004+
# backoff could not engage for them at all (#367).
1005+
if registers is None:
1006+
self._track_read_failure()
1007+
else:
1008+
self._track_read_success()
1009+
return registers
9661010

9671011
try:
9681012
# Try keyword arguments with different parameter names for pymodbus versions
@@ -1045,7 +1089,13 @@ def read_holding_registers(self, start_address: int, count: int) -> Optional[lis
10451089
self._enforce_read_interval()
10461090

10471091
if self._shared_conn is not None:
1048-
return self._shared_conn.read_holding_registers(start_address, count, self.slave_id)
1092+
registers = self._shared_conn.read_holding_registers(start_address, count, self.slave_id)
1093+
# Same bypass as read_input_registers above (#367).
1094+
if registers is None:
1095+
self._track_read_failure()
1096+
else:
1097+
self._track_read_success()
1098+
return registers
10491099

10501100
try:
10511101
try:

custom_components/growatt_modbus/manifest.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,5 +12,5 @@
1212
"pymodbus>=3.0.0",
1313
"pyserial>=3.4"
1414
],
15-
"version": "1.3.6"
15+
"version": "1.3.7"
1616
}

tests/test_short_read_guard.py

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
"""Response-length validation on the shared connection hub (Issue #367).
2+
3+
The defect this covers produced the worst class of bug in this integration: not a
4+
crash, not a missing sensor, but a *plausible-looking wrong number* written into Home
5+
Assistant's long-term statistics.
6+
7+
Registers are written into the cache positionally — `regs[0]` is assumed to be the
8+
block's start address, across 11 call sites. If a response is short, or is a stale
9+
frame from a different request, the words still get written sequentially from
10+
`start`, landing on addresses they never belonged to. The reporter decoded their own
11+
corrupt values and found 0x33325354 = "32ST" — four characters of the inverter's
12+
serial number — published as 85,893,614.8 W of AC power.
13+
14+
The non-shared path has checked response length since v1.3.5. The shared hub did not,
15+
and because a hub is created for *every* TCP entry (not only genuinely shared ones),
16+
the guard in practice only ever covered serial/RTU users.
17+
18+
Length is compared with != rather than <: a response longer than requested is an
19+
equally strong sign of a misaligned frame.
20+
"""
21+
from __future__ import annotations
22+
23+
import importlib
24+
25+
import pytest
26+
27+
_gm = importlib.import_module("growatt_under_test.growatt_modbus")
28+
SharedModbusConnection = _gm.SharedModbusConnection
29+
30+
31+
class _Response:
32+
def __init__(self, registers=None, error=False):
33+
self.registers = [] if registers is None else registers
34+
self._error = error
35+
36+
def isError(self): # noqa: N802 - pymodbus spelling
37+
return self._error
38+
39+
40+
class _FakeClient:
41+
def __init__(self, response):
42+
self.response = response
43+
44+
def close(self):
45+
pass
46+
47+
def connect(self):
48+
return True
49+
50+
def is_socket_open(self):
51+
return True
52+
53+
def read_input_registers(self, *args, **kwargs):
54+
return self.response
55+
56+
def read_holding_registers(self, *args, **kwargs):
57+
return self.response
58+
59+
60+
def _hub(response) -> SharedModbusConnection:
61+
hub = SharedModbusConnection(host="10.0.0.1", port=502)
62+
hub._client = _FakeClient(response)
63+
hub.begin_poll()
64+
return hub
65+
66+
67+
def _count_flushes(hub, monkeypatch) -> list:
68+
calls = []
69+
monkeypatch.setattr(hub, "_flush_receive_buffer", lambda: calls.append(1))
70+
return calls
71+
72+
73+
# --------------------------------------------------------------------------
74+
# The exact-length case must still work
75+
# --------------------------------------------------------------------------
76+
77+
@pytest.mark.parametrize("reader", ["read_input_registers", "read_holding_registers"])
78+
def test_exact_length_response_is_returned(reader):
79+
hub = _hub(_Response([10, 20, 30, 40]))
80+
assert getattr(hub, reader)(100, 4, 1) == [10, 20, 30, 40]
81+
82+
83+
# --------------------------------------------------------------------------
84+
# Short reads — the truncation case
85+
# --------------------------------------------------------------------------
86+
87+
@pytest.mark.parametrize("reader", ["read_input_registers", "read_holding_registers"])
88+
def test_short_response_is_rejected(reader):
89+
"""Two of four registers arrived. Salvaging them would map regs[0..1] onto the
90+
right addresses but leave the rest stale — and the caller cannot tell."""
91+
hub = _hub(_Response([10, 20]))
92+
assert getattr(hub, reader)(100, 4, 1) is None
93+
94+
95+
@pytest.mark.parametrize("reader", ["read_input_registers", "read_holding_registers"])
96+
def test_empty_response_is_rejected(reader):
97+
hub = _hub(_Response([]))
98+
assert getattr(hub, reader)(100, 4, 1) is None
99+
100+
101+
# --------------------------------------------------------------------------
102+
# Long reads — the stale/misaligned frame case
103+
# --------------------------------------------------------------------------
104+
105+
@pytest.mark.parametrize("reader", ["read_input_registers", "read_holding_registers"])
106+
def test_overlong_response_is_rejected(reader):
107+
"""A response longer than requested cannot be a valid answer to this request.
108+
109+
This is the case `< count` would have let through, and it is exactly the shape a
110+
stale frame from a *different* (larger) request takes.
111+
"""
112+
hub = _hub(_Response([10, 20, 30, 40, 50, 60]))
113+
assert getattr(hub, reader)(100, 4, 1) is None
114+
115+
116+
# --------------------------------------------------------------------------
117+
# A misaligned stream must be drained, not inherited
118+
# --------------------------------------------------------------------------
119+
120+
@pytest.mark.parametrize("reader", ["read_input_registers", "read_holding_registers"])
121+
def test_length_mismatch_flushes_the_buffer(reader, monkeypatch):
122+
"""Why the corrupt values repeated byte-for-byte rather than varying: the same
123+
stale bytes sat at the same offset on every poll."""
124+
hub = _hub(_Response([10, 20]))
125+
flushes = _count_flushes(hub, monkeypatch)
126+
127+
getattr(hub, reader)(100, 4, 1)
128+
129+
assert len(flushes) == 1
130+
131+
132+
@pytest.mark.parametrize("reader", ["read_input_registers", "read_holding_registers"])
133+
def test_good_read_does_not_flush(reader, monkeypatch):
134+
"""The flush is a recovery action, not a per-read tax."""
135+
hub = _hub(_Response([10, 20, 30, 40]))
136+
flushes = _count_flushes(hub, monkeypatch)
137+
138+
getattr(hub, reader)(100, 4, 1)
139+
140+
assert flushes == []
141+
142+
143+
# --------------------------------------------------------------------------
144+
# Protocol refusals keep their existing behaviour (#360, #361)
145+
# --------------------------------------------------------------------------
146+
147+
@pytest.mark.parametrize("reader", ["read_input_registers", "read_holding_registers"])
148+
def test_error_response_still_returns_none_without_flushing(reader, monkeypatch):
149+
"""An Illegal Address reply means the device answered and declined. Several
150+
profiles probe ranges their hardware rejects on every poll, so this path must
151+
stay cheap — no flush, no reset."""
152+
hub = _hub(_Response([], error=True))
153+
flushes = _count_flushes(hub, monkeypatch)
154+
155+
assert getattr(hub, reader)(100, 4, 1) is None
156+
assert flushes == []
157+
158+
159+
# --------------------------------------------------------------------------
160+
# The regression, stated in the reporter's own terms
161+
# --------------------------------------------------------------------------
162+
163+
def test_serial_number_frame_cannot_reach_the_register_cache():
164+
"""0x33325354 = "32ST" — characters 9-12 of the reporter's serial number, which
165+
were published as 85,893,614.8 W of AC power.
166+
167+
A stale frame carrying string-register content is rejected on length before any
168+
positional write can occur, so those words never reach the addresses that decode
169+
as power.
170+
"""
171+
hub = _hub(_Response([0x3332, 0x5354])) # "32" "ST"
172+
173+
# A four-register power block was requested; two words came back.
174+
assert hub.read_input_registers(3004, 4, 1) is None

0 commit comments

Comments
 (0)