Skip to content

Commit 8bb6ab2

Browse files
N1c084claude
andcommitted
Recycle the Modbus connection after consecutive TCP timeouts
A gateway can stop serving one TCP session while still accepting new ones: every request on the old socket then times out until the kernel gives up (tcp_retries2, ~16 min), or until the add-on is restarted. Count consecutive read timeouts on non-serial links that have a ModbusConnection, and disconnect once Sunsynk.dead_link_timeouts is reached (default 6, 0 disables); the next read opens a new connection. Any successful read resets the count. Serial behaviour is unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 4628ca7 commit 8bb6ab2

4 files changed

Lines changed: 112 additions & 3 deletions

File tree

‎docs/timeouts.md‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,8 +52,13 @@ Library callers that omit `message_spacing` still get `DEFAULT_MESSAGE_SPACING`
5252
**timeout**, `Sunsynk.read_holding_registers()` logs, increments **`timeouts`**, and retries up to
5353
**`read_attempts`** (add-on **`READ_ATTEMPTS`**, default **3**) before raising **`ExceptionGroup`**.
5454
**Serial** links also call `ModbusConnection.disconnect()` (not `close()`) so the next FC03
55-
reconnects with an empty receive buffer. TCP, UDP, and Solarman do not flush on timeout. Other read
56-
errors still flush. tmodbus already disconnects on desync (bad function code / header mismatch).
55+
reconnects with an empty receive buffer. TCP and UDP do not flush on a single timeout (the MBAP
56+
transaction ID already discards a late reply), but after **`dead_link_timeouts`** (default **6**)
57+
consecutive timeouts, across reads, the connection is recycled: some RS485 gateways stop serving one
58+
TCP session while still accepting new ones, and without this the socket stays dead until the kernel
59+
gives up (`tcp_retries2`, ~16 min). Any successful read resets the count. Solarman does not flush on
60+
timeout. Other read errors still flush. tmodbus already disconnects on desync (bad function code /
61+
header mismatch).
5762

5863
---
5964

‎hass-addon-sunsynk-edge/CHANGELOG.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,11 @@
22

33
<https://github.com/kellerza/sunsynk/commits/>
44

5+
## Unreleased
6+
7+
- TCP/UDP: recycle the Modbus connection after 6 consecutive read timeouts, so a gateway session
8+
that stopped answering no longer stalls polling until the kernel drops the socket (~16 min).
9+
510
## Release 1.3.0
611

712
All items below are changes since add-on **1.2.0**.

‎src/sunsynk/sunsynk.py‎

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,18 @@ class Sunsynk:
8282
read_sensors_batch_size: int = 20
8383
allow_gap: int = 2
8484
timeouts: int = 0
85+
dead_link_timeouts: int = 6
86+
"""Consecutive non-serial read timeouts before recycling the link (0 disables).
87+
88+
A gateway can stop serving one TCP session while still accepting new ones: every
89+
request on the old socket then times out until the kernel gives up (``tcp_retries2``,
90+
~16 min). A single late reply needs no flush on TCP (the transaction ID discards it).
91+
Not applied without a ModbusConnection (Solarman).
92+
"""
93+
# ponytail: counted per Sunsynk instance (per MODBUS_ID). Inverters on one port share
94+
# the connection, so an unresponsive unit can recycle a healthy one's link; upgrade
95+
# path: keep the count on the shared connection.
96+
_consecutive_timeouts: int = field(default=0, init=False, repr=False)
8597

8698
@property
8799
def retrying_unit(self) -> HoldingUnit:
@@ -189,9 +201,10 @@ async def read_holding_registers(self, start: int, length: int) -> Sequence[int]
189201
errs: list[Exception] = []
190202
for attempt in range(self.read_attempts):
191203
try:
192-
return await self.unit.read_holding_registers(start, length)
204+
regs = await self.unit.read_holding_registers(start, length)
193205
except ModbusTimeoutError as err:
194206
self.timeouts += 1
207+
self._consecutive_timeouts += 1
195208
errs.append(err)
196209
_LOG.error(
197210
"Read register %s x%s: %s [attempt %s/%s]",
@@ -205,6 +218,18 @@ async def read_holding_registers(self, start: int, length: int) -> Sequence[int]
205218
self.connection._params, ModbusSerialParams
206219
):
207220
await self._flush_modbus_connection(reason=type(err).__name__)
221+
elif (
222+
self.connection is not None
223+
and self.dead_link_timeouts
224+
and self._consecutive_timeouts >= self.dead_link_timeouts
225+
):
226+
_LOG.warning(
227+
"%s consecutive timeouts: recycling the Modbus connection (%s)",
228+
self._consecutive_timeouts,
229+
self.port,
230+
)
231+
self._consecutive_timeouts = 0
232+
await self._flush_modbus_connection(reason="dead link")
208233
except ModbusProtocolError as err:
209234
errs.append(err)
210235
_LOG.error(
@@ -225,6 +250,9 @@ async def read_holding_registers(self, start: int, length: int) -> Sequence[int]
225250
self.read_attempts,
226251
)
227252
await self._flush_modbus_connection(reason=type(err).__name__)
253+
else:
254+
self._consecutive_timeouts = 0
255+
return regs
228256

229257
raise ExceptionGroup(f"Failed to read {length} registers at {start}", errs)
230258

‎src/tests/sunsynk/test_connection.py‎

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,64 @@ async def test_read_holding_registers_timeout_tcp_does_not_flush(
178178
conn.disconnect.assert_not_awaited()
179179

180180

181+
async def test_read_holding_registers_tcp_dead_link_recycles(
182+
state: InverterState,
183+
) -> None:
184+
"""Consecutive TCP timeouts across reads recycle the connection once."""
185+
unit = MagicMock()
186+
unit.read_holding_registers = AsyncMock(side_effect=ModbusTimeoutError)
187+
conn = MagicMock()
188+
conn.connected = True
189+
conn.disconnect = AsyncMock()
190+
conn._params = ModbusTcpParams(host="1.2.3.4", port=502)
191+
ss = Sunsynk(
192+
unit=unit, state=state, connection=conn, read_attempts=2, dead_link_timeouts=3
193+
)
194+
for _ in range(2):
195+
with pytest.raises(ExceptionGroup, match="Failed to read"):
196+
await ss.read_holding_registers(1, 1)
197+
assert ss.timeouts == 4
198+
conn.disconnect.assert_awaited_once()
199+
200+
201+
async def test_read_holding_registers_tcp_success_resets_dead_link_count(
202+
state: InverterState,
203+
) -> None:
204+
"""Isolated TCP timeouts, each recovered by a retry, never recycle the link."""
205+
unit = MagicMock()
206+
unit.read_holding_registers = AsyncMock(
207+
side_effect=[ModbusTimeoutError, [1], ModbusTimeoutError, [2]] * 3
208+
)
209+
conn = MagicMock()
210+
conn.connected = True
211+
conn.disconnect = AsyncMock()
212+
conn._params = ModbusTcpParams(host="1.2.3.4", port=502)
213+
ss = Sunsynk(
214+
unit=unit, state=state, connection=conn, read_attempts=2, dead_link_timeouts=2
215+
)
216+
for _ in range(6):
217+
assert await ss.read_holding_registers(1, 1) in ([1], [2])
218+
assert ss.timeouts == 6
219+
conn.disconnect.assert_not_awaited()
220+
221+
222+
async def test_read_holding_registers_tcp_dead_link_disabled(
223+
state: InverterState,
224+
) -> None:
225+
"""``dead_link_timeouts=0`` keeps the pre-existing TCP behaviour."""
226+
unit = MagicMock()
227+
unit.read_holding_registers = AsyncMock(side_effect=ModbusTimeoutError)
228+
conn = MagicMock()
229+
conn.connected = True
230+
conn.disconnect = AsyncMock()
231+
conn._params = ModbusTcpParams(host="1.2.3.4", port=502)
232+
ss = Sunsynk(unit=unit, state=state, connection=conn, dead_link_timeouts=0)
233+
for _ in range(5):
234+
with pytest.raises(ExceptionGroup, match="Failed to read"):
235+
await ss.read_holding_registers(1, 1)
236+
conn.disconnect.assert_not_awaited()
237+
238+
181239
async def test_read_holding_registers_timeout_without_connection(
182240
state: InverterState,
183241
) -> None:
@@ -190,6 +248,19 @@ async def test_read_holding_registers_timeout_without_connection(
190248
assert ss.timeouts == ss.read_attempts
191249

192250

251+
async def test_read_holding_registers_dead_link_without_connection(
252+
state: InverterState, caplog: pytest.LogCaptureFixture
253+
) -> None:
254+
"""Without a ModbusConnection there is nothing to recycle: no false warning."""
255+
unit = MagicMock()
256+
unit.read_holding_registers = AsyncMock(side_effect=ModbusTimeoutError)
257+
ss = Sunsynk(unit=unit, state=state, read_attempts=2, dead_link_timeouts=2)
258+
for _ in range(3):
259+
with pytest.raises(ExceptionGroup, match="Failed to read"):
260+
await ss.read_holding_registers(1, 1)
261+
assert "recycling the Modbus connection" not in caplog.text
262+
263+
193264
async def test_read_holding_registers_timeout_then_success(
194265
state: InverterState,
195266
) -> None:

0 commit comments

Comments
 (0)