Skip to content

Commit 52208e3

Browse files
committed
attempts
1 parent fe055b9 commit 52208e3

10 files changed

Lines changed: 63 additions & 36 deletions

File tree

‎docs/timeouts.md‎

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,8 @@ The **`TIMEOUT`** option (default **3** seconds) is the deadline for:
1717
- opening the link (connect), and
1818
- each individual register read or write attempt.
1919

20-
Each FC03 group is always attempted **`RETRY_ATTEMPTS` (3)** times, so a missing reply can take up
21-
to **9** seconds (`TIMEOUT` × 3) before that group fails.
20+
Each FC03 group is attempted **`READ_ATTEMPTS`** times (default **3**), so a missing reply can take
21+
up to **`TIMEOUT × READ_ATTEMPTS`** seconds before that group fails.
2222

2323
It is **not** a cap on an entire sensor batch or poll cycle. `Sunsynk.read_sensors()` may issue many
2424
FC03 groups in one call; each group gets its own deadline.
@@ -50,10 +50,10 @@ all `for_unit()` handles on that port). **0** disables the gap.
5050

5151
Library callers that omit `message_spacing` still get `DEFAULT_MESSAGE_SPACING` (0.05). On
5252
**timeout**, `Sunsynk.read_holding_registers()` logs, increments **`timeouts`**, and retries up to
53-
**`RETRY_ATTEMPTS` (3)** before raising **`ExceptionGroup`**. **Serial** links also call
54-
`ModbusConnection.disconnect()` (not `close()`) so the next FC03 reconnects with an empty receive
55-
buffer. TCP, UDP, and Solarman do not flush on timeout. Other read errors still flush. tmodbus
56-
already disconnects on desync (bad function code / header mismatch).
53+
**`read_attempts`** (add-on **`READ_ATTEMPTS`**, default **3**) before raising **`ExceptionGroup`**.
54+
**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).
5757

5858
---
5959

@@ -125,7 +125,7 @@ between commands on slow devices) and is the main pacing mitigation for #672 on
125125
```text
126126
AInverter.read_sensors()
127127
→ Sunsynk.read_sensors() # groups sensors, one FC03 per group
128-
→ Sunsynk.read_holding_registers() # RETRY_ATTEMPTS, then ExceptionGroup
128+
→ Sunsynk.read_holding_registers() # read_attempts, then ExceptionGroup
129129
→ unit.read_holding_registers()
130130
→ modbus-connection TmodbusUnit # connect + Pacer (50ms) + tmodbus RTU
131131
```
@@ -199,10 +199,10 @@ Solarman does **not** use modbus-connection or tmodbus. It uses
199199

200200
- **`TIMEOUT`** maps to **`socket_timeout`** on the Solarman client (connect and reads).
201201
- FC03 retries live on **`Sunsynk.read_holding_registers`** (shared with serial/TCP): up to
202-
**`RETRY_ATTEMPTS` (5)**, then **`ExceptionGroup`**. No sleep between tries.
202+
**`read_attempts`** (default **3**), then **`ExceptionGroup`**. No sleep between tries.
203203
- **`TimeoutError`** / **`GatewayTargetError`**: log, increment **`timeouts`**, disconnect/flush,
204204
retry.
205-
- Other read errors: same loop; log includes `(retry n/5)`.
205+
- Other read errors: same loop; log includes `[attempt n/READ_ATTEMPTS]`.
206206
- **`SolarmanUnit`** is a single attempt and **disconnects** on error so the next retry reconnects.
207207

208208
There is **no** `message_spacing` knob on Solarman; pacing is whatever the dongle and
@@ -232,7 +232,7 @@ No shared `ModbusConnection`; one Solarman client per inverter port entry.
232232
| Library | modbus-connection + tmodbus | PySolarmanV5Async |
233233
| Add-on `TIMEOUT` | Per connect / FC03 / FC16 attempt | `socket_timeout` |
234234
| Default gap after each reply | **`READ_MESSAGE_SPACING`** (default **0.05s**) | — |
235-
| Driver retry on `TimeoutError` | Yes (`RETRY_ATTEMPTS`, then `ExceptionGroup`) | Yes (same, on `Sunsynk`) |
235+
| Driver retry on `TimeoutError` | Yes (`read_attempts`, then `ExceptionGroup`) | Yes (same, on `Sunsynk`) |
236236
| Add-on batch retry | Yes (`read_sensors_retry`) | Yes (same) |
237237
| Multi-inverter same PORT | One connection, `for_unit(MODBUS_ID)` | Not supported (one dongle per entry) |
238238

@@ -243,7 +243,8 @@ User-facing keys from [multi-options](../www/docs/reference/multi-options.md) (a
243243

244244
| Option | Role |
245245
| ------------------------------- | --------------------------------------------------------------------------------------------- |
246-
| `TIMEOUT` | Per-attempt deadline in **seconds** (default **3**); each FC03 read is tried **3** times |
246+
| `TIMEOUT` | Per-attempt deadline in **seconds** (default **3**) |
247+
| `READ_ATTEMPTS` | Tries per FC03/FC16 group (default **3**, max **5**); worst case `TIMEOUT × READ_ATTEMPTS` |
247248
| `READ_MESSAGE_SPACING` | Seconds to wait after each successful Modbus reply (default **0.05**; **0** disables) |
248249
| `READ_SENSORS_BATCH_SIZE` | Max registers per FC03 group (smaller → more requests, less time per request) |
249250
| `READ_ALLOW_GAP` | Extra registers allowed between addresses so groups merge (fewer requests) |

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

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,11 @@
44

55
## Unreleased
66

7-
- **`TIMEOUT`** is now **3** seconds per connect and each register **attempt**. Each
8-
holding-register read (FC03) and write (FC16) is always tried **3** times (**3s × 3**), so a
9-
missing reply can take up to **9** seconds before that operation fails. On timeout, **serial**
10-
links disconnect so the next attempt reconnects with an empty buffer; TCP and Solarman do not.
11-
Other I/O errors still flush.
7+
- **`READ_ATTEMPTS`** (default **3**, max **5**) – tries per FC03 read and FC16 write. Worst-case
8+
wait per group is **`TIMEOUT × READ_ATTEMPTS`**.
9+
- **`TIMEOUT`** is now **3** seconds per connect and each register **attempt**. On timeout,
10+
**serial** links disconnect so the next attempt reconnects with an empty buffer; TCP and Solarman
11+
do not. Other I/O errors still flush.
1212
- Add a 50ms `connect_delay` for serial
1313
- Remove Gen L1/L2/L3 current sensors & check for duplicates. #676
1414

‎hass-addon-sunsynk-edge/config.yaml‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ options:
3636
READ_ALLOW_GAP: 2
3737
READ_SENSORS_BATCH_SIZE: 20
3838
READ_MESSAGE_SPACING: 0.05
39+
READ_ATTEMPTS: 3
3940
TIMEOUT: 3
4041
STALE_INVERTER_AFTER_SECONDS: 120
4142
STALE_INVERTER_SKIP_SECONDS: 600
@@ -66,6 +67,7 @@ schema:
6667
READ_ALLOW_GAP: int(1,50)
6768
READ_SENSORS_BATCH_SIZE: int(1,100)
6869
READ_MESSAGE_SPACING: float(0,2)?
70+
READ_ATTEMPTS: int(1,5)?
6971
TIMEOUT: int(1,15)?
7072
STALE_INVERTER_AFTER_SECONDS: int(1,1000)?
7173
STALE_INVERTER_SKIP_SECONDS: int(60,86400)?

‎hass-addon-sunsynk-edge/translations/en.yaml‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,12 @@ configuration:
4444
4545
Default **0.05**. Increase on flaky RS485 / USB-FTDI links. **0** disables the gap.
4646
Raising TIMEOUT only waits longer for a missing reply; it does not add this pause.
47+
READ_ATTEMPTS:
48+
name: Read attempts
49+
description: |
50+
How many times to try each holding-register read or write.
51+
52+
Default **3**. Worst-case wait per group is TIMEOUT × READ_ATTEMPTS.
4753
STALE_INVERTER_AFTER_SECONDS:
4854
name: Stale inverter after (seconds)
4955
description: |
@@ -72,8 +78,8 @@ configuration:
7278
description: |
7379
Seconds to wait for each connect and register read/write attempt.
7480
75-
Default **3**. Each holding-register read is always tried 3 times, so a missing
76-
reply can take up to 9 seconds before that group fails. Max **15**.
81+
Default **3**. Each holding-register read is tried READ_ATTEMPTS times (default 3),
82+
so a missing reply can take up to TIMEOUT × READ_ATTEMPTS seconds. Max **15**.
7783
MQTT_CUSTOM:
7884
name: Custom MQTT
7985
description: |

‎src/ha_addon_sunsynk_multi/driver.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ def create_sunsynk(opt: Options, iopt: InverterOptions) -> Sunsynk:
8282
unit=unit,
8383
port=port,
8484
timeout=opt.timeout,
85+
read_attempts=opt.read_attempts,
8586
read_sensors_batch_size=opt.read_sensors_batch_size,
8687
allow_gap=opt.read_allow_gap,
8788
)
@@ -93,6 +94,7 @@ def create_sunsynk(opt: Options, iopt: InverterOptions) -> Sunsynk:
9394
connection=conn,
9495
port=port,
9596
timeout=opt.timeout,
97+
read_attempts=opt.read_attempts,
9698
read_sensors_batch_size=opt.read_sensors_batch_size,
9799
allow_gap=opt.read_allow_gap,
98100
)

‎src/ha_addon_sunsynk_multi/options.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,8 @@ class Options(MQTTOptions):
8080
"""Seconds to wait after each Modbus reply before the next request (0 disables)."""
8181
schedules: list[Schedule] = field(default_factory=list)
8282
timeout: int = 3
83+
read_attempts: int = 3
84+
"""FC03/FC16 tries per register group (worst-case wait is TIMEOUT * READ_ATTEMPTS)."""
8385

8486
stale_inverter_after_seconds: int = 60
8587
"""Grace window (seconds) after each successful read: if failures continue past this deadline, enter stale quiet."""

‎src/sunsynk/sunsynk.py‎

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,6 @@
2020

2121
_LOG = logging.getLogger(__name__)
2222

23-
RETRY_ATTEMPTS = 3
24-
2523

2624
def _exc_label(err: BaseException) -> str:
2725
"""Type name, plus message when the exception string is empty (TimeoutError)."""
@@ -60,6 +58,7 @@ class Sunsynk:
6058
port: str = ""
6159
baudrate: int = 9600
6260
timeout: int = 3
61+
read_attempts: int = 3
6362
read_sensors_batch_size: int = 20
6463
allow_gap: int = 2
6564
timeouts: int = 0
@@ -118,7 +117,7 @@ async def _flush_modbus_connection(self, *, reason: str) -> None:
118117

119118
async def write_register(self, *, address: int, value: int) -> bool:
120119
"""Write to a register - Sunsynk support function code 0x10."""
121-
for _ in range(RETRY_ATTEMPTS):
120+
for _ in range(self.read_attempts):
122121
try:
123122
await self.unit.write_registers(address, [value])
124123
return True
@@ -163,7 +162,7 @@ async def write_sensor(
163162
async def read_holding_registers(self, start: int, length: int) -> Sequence[int]:
164163
"""Read holding registers (FC03)."""
165164
errs: list[Exception] = []
166-
for attempt in range(RETRY_ATTEMPTS):
165+
for attempt in range(self.read_attempts):
167166
try:
168167
return await self.unit.read_holding_registers(start, length)
169168
except TimeoutError as err:
@@ -175,7 +174,7 @@ async def read_holding_registers(self, start: int, length: int) -> Sequence[int]
175174
length,
176175
_exc_label(err),
177176
attempt + 1,
178-
RETRY_ATTEMPTS,
177+
self.read_attempts,
179178
)
180179
if self.connection is not None and isinstance(
181180
self.connection._params, ModbusSerialParams
@@ -189,7 +188,7 @@ async def read_holding_registers(self, start: int, length: int) -> Sequence[int]
189188
length,
190189
_exc_label(err),
191190
attempt + 1,
192-
RETRY_ATTEMPTS,
191+
self.read_attempts,
193192
)
194193
await self._flush_modbus_connection(reason=type(err).__name__)
195194

‎src/tests/ha_addon_sunsynk_multi/test_driver.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ def test_init() -> None:
2828
ist = STATE[0].inv
2929
assert isinstance(ist, Sunsynk)
3030
assert ist.port == inv_port
31+
assert ist.read_attempts == 3
3132
mock_conn.for_unit.assert_called_once_with(1)
3233
open_conn.assert_called_once_with(
3334
inv_port, timeout=OPT.timeout, message_spacing=0.05

‎src/tests/sunsynk/test_connection.py‎

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414

1515
from sunsynk.connection import DEFAULT_MESSAGE_SPACING, open_connection, url_to_params
1616
from sunsynk.state import InverterState
17-
from sunsynk.sunsynk import RETRY_ATTEMPTS, Sunsynk
17+
from sunsynk.sunsynk import Sunsynk
1818

1919

2020
def test_url_to_params() -> None:
@@ -88,8 +88,8 @@ async def test_write_register_timeout(state: InverterState) -> None:
8888
conn.disconnect = AsyncMock()
8989
ss = Sunsynk(unit=unit, state=state, connection=conn)
9090
assert await ss.write_register(address=1, value=1) is False
91-
assert ss.timeouts == RETRY_ATTEMPTS
92-
assert unit.write_registers.await_count == RETRY_ATTEMPTS
91+
assert ss.timeouts == ss.read_attempts
92+
assert unit.write_registers.await_count == ss.read_attempts
9393
conn.disconnect.assert_not_awaited()
9494

9595

@@ -104,9 +104,9 @@ async def test_read_holding_registers_timeout(state: InverterState) -> None:
104104
ss = Sunsynk(unit=unit, state=state, connection=conn)
105105
with pytest.raises(ExceptionGroup, match="Failed to read 1 registers at 1"):
106106
await ss.read_holding_registers(1, 1)
107-
assert ss.timeouts == RETRY_ATTEMPTS
108-
assert unit.read_holding_registers.await_count == RETRY_ATTEMPTS
109-
assert conn.disconnect.await_count == RETRY_ATTEMPTS
107+
assert ss.timeouts == ss.read_attempts
108+
assert unit.read_holding_registers.await_count == ss.read_attempts
109+
assert conn.disconnect.await_count == ss.read_attempts
110110

111111

112112
async def test_read_holding_registers_timeout_tcp_does_not_flush(
@@ -122,7 +122,7 @@ async def test_read_holding_registers_timeout_tcp_does_not_flush(
122122
ss = Sunsynk(unit=unit, state=state, connection=conn)
123123
with pytest.raises(ExceptionGroup, match="Failed to read"):
124124
await ss.read_holding_registers(1, 1)
125-
assert ss.timeouts == RETRY_ATTEMPTS
125+
assert ss.timeouts == ss.read_attempts
126126
conn.disconnect.assert_not_awaited()
127127

128128

@@ -135,7 +135,7 @@ async def test_read_holding_registers_timeout_without_connection(
135135
ss = Sunsynk(unit=unit, state=state)
136136
with pytest.raises(ExceptionGroup, match="Failed to read"):
137137
await ss.read_holding_registers(1, 1)
138-
assert ss.timeouts == RETRY_ATTEMPTS
138+
assert ss.timeouts == ss.read_attempts
139139

140140

141141
async def test_read_holding_registers_timeout_then_success(
@@ -154,6 +154,17 @@ async def test_read_holding_registers_timeout_then_success(
154154
conn.disconnect.assert_awaited_once()
155155

156156

157+
async def test_read_holding_registers_one_attempt(state: InverterState) -> None:
158+
"""read_attempts=1 raises after a single timeout."""
159+
unit = MagicMock()
160+
unit.read_holding_registers = AsyncMock(side_effect=TimeoutError)
161+
ss = Sunsynk(unit=unit, state=state, read_attempts=1)
162+
with pytest.raises(ExceptionGroup, match="Failed to read"):
163+
await ss.read_holding_registers(1, 1)
164+
assert ss.timeouts == 1
165+
assert unit.read_holding_registers.await_count == 1
166+
167+
157168
async def test_read_holding_registers_logs_empty_timeout(
158169
state: InverterState,
159170
caplog: pytest.LogCaptureFixture,
@@ -182,4 +193,4 @@ async def test_read_holding_registers_gateway_target_flushes(
182193
with pytest.raises(ExceptionGroup, match="Failed to read 2 registers at 176"):
183194
await ss.read_holding_registers(176, 2)
184195
assert ss.timeouts == 0
185-
assert conn.disconnect.await_count == RETRY_ATTEMPTS
196+
assert conn.disconnect.await_count == ss.read_attempts

‎www/docs/reference/multi-options.md‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -158,10 +158,13 @@ Global. Change these when a gateway or RS485 link is unreliable.
158158
disables the gap. Increase on flaky RS485 / USB-FTDI links. Not used for `solarman://`. Raising
159159
`TIMEOUT` does not add this pause.
160160

161+
- <i-mdi-dev-to class="vp-edge-option-icon" /> `READ_ATTEMPTS` – Tries per holding-register read
162+
(FC03) and write (FC16). Default **3**, max **5**. Worst-case wait per group is
163+
`TIMEOUT × READ_ATTEMPTS`.
164+
161165
- `TIMEOUT` – Seconds for connect and each register read/write attempt (default **3**, max **15**).
162-
Each holding-register read is always tried **3** times (up to **9** seconds for a missing reply).
163-
Increase on slow links. If timeouts persist, lower `READ_SENSORS_BATCH_SIZE` or increase
164-
`READ_MESSAGE_SPACING`.
166+
Each group is tried `READ_ATTEMPTS` times. Increase on slow links. If timeouts persist, lower
167+
`READ_SENSORS_BATCH_SIZE` or increase `READ_MESSAGE_SPACING`.
165168

166169
## Schedules
167170

0 commit comments

Comments
 (0)