Skip to content

Commit 48e0323

Browse files
committed
gatewaytimeout
1 parent 35c82ec commit 48e0323

13 files changed

Lines changed: 130 additions & 27 deletions

File tree

‎docs/modbus-connection-plan.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,8 +64,8 @@ classDiagram
6464

6565
## `for_unit` is the server id
6666

67-
Yes. `connection.for_unit(id)` is the Modbus **unit id** (also called slave id, device id, or server
68-
id). It is the same value as:
67+
Yes. `connection.for_unit(id)` is the Modbus **unit id** (server id; device id). It is the same
68+
value as:
6969

7070
- `Sunsynk.server_id` today
7171
- add-on `MODBUS_ID` / `InverterOptions.modbus_id`

‎docs/modbus-connection.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,8 @@ The public API stays `Sensor` + `Sunsynk.read_sensors(sensors)`. Internally, hol
2222
goes to a unit. The add-on stores one `ModbusConnection` per port and passes
2323
`connection.for_unit(modbus_id)` into each inverter.
2424

25-
`for_unit(id)` is the Modbus **server id** (slave / device id): today’s `Sunsynk.server_id` and
26-
add-on `MODBUS_ID`. It is not a register address.
25+
`for_unit(id)` is the Modbus **server id** (device id): today’s `Sunsynk.server_id` and add-on
26+
`MODBUS_ID`. It is not a register address.
2727

2828
## What hurts today
2929

‎docs/timeouts.md‎

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -47,10 +47,11 @@ all `for_unit()` handles on that port). **0** disables the gap.
4747

4848
Library callers that omit `message_spacing` still get `DEFAULT_MESSAGE_SPACING` (0.05).
4949

50-
On **timeout**, `Sunsynk` calls `ModbusConnection.disconnect()` (not `close()`) so the next FC03
51-
reconnects with an empty receive buffer. tmodbus already disconnects on desync (bad function code /
52-
header mismatch); it did not disconnect on timeout, which left garbage bytes for the next poll
53-
(`Received data with no pending requests`, `0x79`).
50+
On **timeout** and **gateway target failed to respond** (Modbus exception **0x0B** /
51+
`GatewayTargetError`), `Sunsynk` calls `ModbusConnection.disconnect()` (not `close()`) so the next
52+
FC03 reconnects with an empty receive buffer. Gateways often return 0x0B when the inverter misses
53+
the reply window; a late frame then shows up as `Received unexpected response with Transaction ID`.
54+
tmodbus already disconnects on desync (bad function code / header mismatch).
5455

5556
---
5657

@@ -74,8 +75,8 @@ v1.0.0 **tmodbus** is stricter:
7475
before a new frame. tmodbus enforces that gap from the **send** time (`_last_frame_ended_at` is
7576
updated when the request is written). By the time the inverter replies, that interval has already
7677
elapsed, so the **next poll can go out immediately** after the response is parsed.
77-
2. **Slow slaves** — Deye inverters and USB-FTDI adapters often need tens of milliseconds after a
78-
reply before they accept another master request. Polling back-to-back produces intermittent
78+
2. **Slow servers** — Deye inverters and USB-FTDI adapters often need tens of milliseconds after a
79+
reply before they accept another client request. Polling back-to-back produces intermittent
7980
timeouts (~every 10s with default `TIMEOUT`, matching field reports in #672).
8081
3. **FTDI latency** — FT232R and similar chips buffer serial data (default latency timer ~16ms).
8182
tmodbus documents that true inter-frame silence detection is unreliable on USB serial; it sizes
@@ -150,7 +151,7 @@ Requests are serialized on **one `ModbusConnection` per PORT** string. Multiple
150151
same gateway share that connection; modbus-connection’s lock and pacer ensure only one transaction
151152
is in flight at a time.
152153

153-
Timeouts here are usually **gateway or multi-master** issues rather than RTU turnaround:
154+
Timeouts here are usually **gateway or multi-client** issues rather than RTU turnaround:
154155

155156
- **`Received unexpected response with Transaction ID`** — another client is talking to the same
156157
gateway, or the gateway returned a late MBAP frame for an earlier request (#672, multi-inverter
@@ -252,6 +253,7 @@ addon timeout.
252253
- [modbus-connection#213](https://github.com/home-assistant-libs/modbus-connection/pull/213) —
253254
serial `timeout` passed through to tmodbus (fixes `TIMEOUT` being ignored on RTU; does not add
254255
spacing).
255-
- **50ms `message_spacing`** in `open_connection()` — addresses back-to-back polling on serial /
256+
- **`READ_MESSAGE_SPACING`** (default **0.05s**) plus **disconnect** on timeout and gateway **0x0B**
257+
(`GatewayTargetError`) — addresses back-to-back polling and late gateway replies on serial /
256258
RTU-backed links ([#672](https://github.com/kellerza/sunsynk/issues/672)).
257259
- Removal of nested **`asyncio.timeout`** wrappers — avoids double cancellation; see edge CHANGELOG.

‎hass-addon-mbusd/README.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# Modbus TCP to Modbus RTU Gateway Add-on
22

3-
This Add-on uses the [mbusd](https://github.com/3cky/mbusd) gateway. It provides a TCP-Slave (or
4-
server) which acts as a RTU-master to get data from Modbus RTU-slave devices.
3+
This Add-on uses the [mbusd](https://github.com/3cky/mbusd) gateway. It provides a TCP **server**
4+
which acts as an RTU **client** to get data from Modbus RTU **servers**.
55

66
I use this mainly for testing, but mbusd can also be deployed on a low-spec device (i.e. Raspberry
77
Pi 1b) to convert an RS-485 connection to TCP.

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

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,12 @@
66

77
All items below are changes since add-on **1.0.0** (stable release on GitHub `main`).
88

9+
- **Breaking:** three-phase **Parallel Mode** select options are **Client** (was Master) and
10+
**Server** (was Slave). The inverter LCD may still say Master/Slave. Update automations that match
11+
the old strings.
12+
- Docs use Modbus **client** / **server**: the add-on is the client (initiates requests); each
13+
inverter is a server (replies). `MODBUS_ID` is the server id. See
14+
[Modbus](https://kellerza.github.io/sunsynk/guide/overview#modbus).
915
- **Breaking:** leftover `DRIVER` is no longer ignored — startup fails until it is removed. `tcp://`
1016
plus a non-zero `DONGLE_SERIAL_NUMBER` still remaps to `solarman://`; a dongle serial with a
1117
serial device path fails instead of warning.
@@ -20,8 +26,9 @@ All items below are changes since add-on **1.0.0** (stable release on GitHub `ma
2026
- Modbus requests wait **`READ_MESSAGE_SPACING`** seconds after each reply (default **0.05**).
2127
tmodbus's RTU 3.5-character gap is measured from *send*, so v1.0.0 issued the next poll as soon as
2228
the previous reply was parsed — too fast for Deye + USB-FTDI / RS485 gateways. Raising `TIMEOUT`
23-
does not add that gap. On timeout, the add-on **disconnects** the Modbus link so the next request
24-
reconnects with a clean buffer ([#672](https://github.com/kellerza/sunsynk/issues/672)).
29+
does not add that gap. On timeout or gateway **0x0B** (`GatewayTargetError`), the add-on
30+
**disconnects** the Modbus link so the next request reconnects with a clean buffer
31+
([#672](https://github.com/kellerza/sunsynk/issues/672)).
2532
- `TIMEOUT` is the client/socket deadline for connect and each register I/O. Nested
2633
`asyncio.timeout` wrappers (including the **`2 × TIMEOUT`** batch cap) are removed so a hang is
2734
not cancelled twice. Solarman uses the same `TIMEOUT` for `socket_timeout` and does not retry

‎src/sunsynk/definitions/three_phase_common.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -498,7 +498,7 @@
498498
SelectRWSensor(
499499
336,
500500
"Parallel Mode",
501-
options={0: "Slave", 1 << 1: "Master"},
501+
options={0: "Server", 1 << 1: "Client"}, # inverter LCD: Slave / Master
502502
bitmask=1 << 1,
503503
),
504504
SelectRWSensor(

‎src/sunsynk/sunsynk.py‎

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
from dataclasses import dataclass, field
1010
from typing import Protocol, cast, runtime_checkable
1111

12-
from modbus_connection import ModbusTimeoutError
12+
from modbus_connection import GatewayTargetError, ModbusTimeoutError
1313
from modbus_connection.tmodbus import ModbusConnection
1414

1515
from sunsynk.connection import open_connection
@@ -92,11 +92,11 @@ async def connect(self) -> None:
9292
if connect is not None:
9393
await connect()
9494

95-
async def _flush_modbus_connection(self) -> None:
95+
async def _flush_modbus_connection(self, *, reason: str) -> None:
9696
"""Drop the tmodbus link so the next request reconnects with an empty buffer."""
9797
if self.connection is None or not self.connection.connected:
9898
return
99-
_LOG.debug("Flushing Modbus connection after timeout (%s)", self.port)
99+
_LOG.debug("Flushing Modbus connection after %s (%s)", reason, self.port)
100100
await self.connection.disconnect()
101101

102102
async def write_register(self, *, address: int, value: int) -> bool:
@@ -107,7 +107,13 @@ async def write_register(self, *, address: int, value: int) -> bool:
107107
except TimeoutError:
108108
_LOG.error("timeout writing register %s=%s", address, value)
109109
self.timeouts += 1
110-
await self._flush_modbus_connection()
110+
await self._flush_modbus_connection(reason="timeout")
111+
except GatewayTargetError as err:
112+
_LOG.error(
113+
"gateway target failed writing register %s=%s: %s", address, value, err
114+
)
115+
self.timeouts += 1
116+
await self._flush_modbus_connection(reason="gateway target error")
111117
except Exception as err:
112118
_LOG.error("failed to write register %s=%s: %s", address, value, err)
113119
return False
@@ -148,8 +154,12 @@ async def read_holding_registers(self, start: int, length: int) -> Sequence[int]
148154
return await self.unit.read_holding_registers(start, length)
149155
except TimeoutError:
150156
self.timeouts += 1
151-
await self._flush_modbus_connection()
157+
await self._flush_modbus_connection(reason="timeout")
152158
raise OSError(f"timeout reading register {start}") from None
159+
except GatewayTargetError:
160+
self.timeouts += 1
161+
await self._flush_modbus_connection(reason="gateway target error")
162+
raise
153163

154164
async def read_sensors(self, sensors: Iterable[Sensor]) -> None:
155165
"""Read a list of sensors - Sunsynk supports function code 0x03."""

‎src/tests/sunsynk/test_connection.py‎

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,12 @@
33
from unittest.mock import AsyncMock, MagicMock, patch
44

55
import pytest
6-
from modbus_connection import ModbusSerialParams, ModbusTcpParams, ModbusUdpParams
6+
from modbus_connection import (
7+
GatewayTargetError,
8+
ModbusSerialParams,
9+
ModbusTcpParams,
10+
ModbusUdpParams,
11+
)
712
from modbus_connection.mock import MockModbusConnection
813

914
from sunsynk.connection import DEFAULT_MESSAGE_SPACING, open_connection, url_to_params
@@ -110,3 +115,19 @@ async def test_read_holding_registers_timeout_without_connection(
110115
with pytest.raises(OSError, match="timeout reading"):
111116
await ss.read_holding_registers(1, 1)
112117
assert ss.timeouts == 1
118+
119+
120+
async def test_read_holding_registers_gateway_target_flushes(
121+
state: InverterState,
122+
) -> None:
123+
"""Gateway 0x0B (target failed to respond) flushes the link like a timeout."""
124+
unit = MagicMock()
125+
unit.read_holding_registers = AsyncMock(side_effect=GatewayTargetError())
126+
conn = MagicMock()
127+
conn.connected = True
128+
conn.disconnect = AsyncMock()
129+
ss = Sunsynk(unit=unit, state=state, connection=conn)
130+
with pytest.raises(GatewayTargetError):
131+
await ss.read_holding_registers(176, 2)
132+
assert ss.timeouts == 1
133+
conn.disconnect.assert_awaited_once()

‎www/docs/guide/fault-finding.md‎

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,10 @@ Modbus read, a timer is armed: if reads are still failing once that many seconds
4545
the last success, the add-on pauses normal polling for that inverter for the configured skip period,
4646
then probes the serial register once before resuming or extending stale quiet.
4747

48+
A parallel AC setup (inverter **Parallel Mode** Client / Server) does **not** mean both inverters
49+
share the RS485 Modbus bus. If the second inverter never appears, see
50+
[Parallel inverters](#parallel-inverters) below.
51+
4852
If you fail to get a reply from the inverter, typically if step #2 fails, please check the
4953
following:
5054

@@ -149,3 +153,40 @@ RS485 devices are typically multi-drop with a termination resistor on the first
149153
However, the RS485 BMS port may only be intended to connect to a single device.
150154

151155
![RS485](../images/rs485-term.jpg =70%x)
156+
157+
## (D) Parallel inverters — second inverter not responding {#parallel-inverters}
158+
159+
The add-on can poll several inverters on **one** adaptor when they share a physical RS485 bus: same
160+
`PORT`, unique `MODBUS_ID` (and `SERIAL_NR` / `HA_PREFIX`) per unit. See
161+
[Shared RS485 bus](../reference/multi-options#port).
162+
163+
Parallel Mode does **not** automatically daisy-chain Modbus. If only one inverter answers, or the
164+
other goes **stale** / startup shows no response, test **one inverter at a time** with the **same**
165+
USB adaptor. Change only which RJ45 you plug into and the single `INVERTERS` entry. The inverter LCD
166+
may still say Master / Slave for Parallel Mode; this add-on uses **Client** / **Server**. See
167+
[Modbus](./overview.md#modbus).
168+
169+
1. **Baseline — inverter 1 only.** Plug the cable into inverter 1. Configure one `INVERTERS` entry
170+
for that unit (`MODBUS_ID` matching its **Modbus SN**). If this fails, it is a normal connection
171+
problem — follow (A)–(C).
172+
173+
2. **Inverter 2 on its own socket.** Move the **same** cable to inverter 2. Change the config to
174+
inverter 2 only (`HA_PREFIX`, `MODBUS_ID`, `SERIAL_NR`). If this fails while plugged into
175+
inverter 2, inverter 2 is not answering Modbus: check its Modbus SN, cabling, and whether that
176+
model exposes RS485 on the port you are using when Parallel Mode is Server.
177+
178+
3. **Is the bus shared?** Plug the cable back into inverter 1. Keep the **inverter 1** config. Then
179+
move the cable to inverter 2 **without changing the config**.
180+
181+
- If inverter 1 still answers with the cable on inverter 2, the RS485 bus **is** shared — use the
182+
**same** `PORT` for both `INVERTERS` entries, with unique `MODBUS_ID`s.
183+
- If it goes silent, the bus is **not** shared. Use **two adaptors** (two `PORT`s), one per
184+
inverter.
185+
186+
4. **Optional cross-check.** Cable on inverter 1, config for inverter 2 only. A reply means inverter
187+
2 is reachable through inverter 1's socket (shared bus). No reply means the bus is not shared, or
188+
inverter 2's `MODBUS_ID` is wrong.
189+
190+
When using two adaptors, `MODBUS_ID` must match whatever unit actually answers on **that** adaptor.
191+
Some units in Server mode still answer as `1` on their own RS485 port even when the display shows a
192+
different Modbus SN.

‎www/docs/guide/mbusd.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@
33
mbusd is an open-source Modbus TCP to Modbus RTU (RS-232/485) gateway. It presents a network of RTU
44
servers as single TCP server.
55

6-
That is a TCP-Server, which acts as a RTU-Client to get data from Modbus RTU-Server devices.
6+
That is a TCP server, which acts as an RTU client to get data from Modbus RTU servers. See
7+
[Modbus](./overview.md#modbus) for client / server.
78

89
When you add the Sunsynk HASS repository, it includes a mbusd addon called "Modbus TCP to Modbus RTU
910
Gateway Add-on".

0 commit comments

Comments
 (0)