Skip to content

Commit 2d44c11

Browse files
0xAHAclaude
andcommitted
feat: shared Modbus connection mode for multi-slave RS485 gateways (v1.0.8)
Fixes RS485 cross-talk on gateways like USR-DR164 when two integration entries share the same host:port with different slave IDs. Previously, two independent TCP sockets caused transaction ID mismatches and corrupted readings as the gateway delivered each slave's response to the wrong session. All TCP entries on the same host:port now share one ModbusTcpClient and a threading.Lock (SharedModbusConnection hub). Reads/writes are serialized: one slave's complete request/response cycle completes before the next begins. A 50ms inter-slave pause (configurable) lets the RS485 bus settle. Lock acquisition times out after 30s to prevent starvation. The hub reconnects and flushes stale buffer bytes on socket drop, preserving the existing issue-#317 protection. Detection is automatic — no config options needed. Closes #351 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent fa1fa87 commit 2d44c11

7 files changed

Lines changed: 388 additions & 15 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
# Growatt Modbus Integration for Home Assistant ☀️
44

55
![HACS Badge](https://img.shields.io/badge/HACS-Custom-orange.svg)
6-
![Version](https://img.shields.io/badge/Version-1.0.7-blue.svg)
6+
![Version](https://img.shields.io/badge/Version-1.0.8-blue.svg)
77
[![GitHub Issues](https://img.shields.io/github/issues/0xAHA/Growatt_ModbusTCP.svg)](https://github.com/0xAHA/Growatt_ModbusTCP/issues)
88
[![GitHub Stars](https://img.shields.io/github/stars/0xAHA/Growatt_ModbusTCP.svg?style=social)](https://github.com/0xAHA/Growatt_ModbusTCP)
99

RELEASENOTES.md

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

55
---
66

7+
## v1.0.8
8+
9+
Issues: #351
10+
11+
- **New: Shared Modbus connection mode for multi-inverter RS485-to-TCP gateways (Issue #351):**
12+
When two integration entries point at the same RS485-to-TCP gateway (identical host:port, different
13+
slave IDs), the integration now automatically shares a single `ModbusTcpClient` TCP socket between
14+
them instead of opening two independent connections.
15+
16+
**Why this matters:** Consumer gateways like the USR-DR164 accept both TCP connections but cannot
17+
correctly demultiplex RS485 responses back to the right session under simultaneous load. The result
18+
is transaction ID mismatches ("request ask for id=2 but got id=3") and corrupted readings as each
19+
slave's response is delivered to the wrong TCP session.
20+
21+
**How it works:** Detection is automatic — no configuration required. At setup, if a second entry
22+
shares the same host:port as an existing one, both coordinators use the same `SharedModbusConnection`
23+
hub. All reads and writes are serialized through a `threading.Lock`, ensuring one slave's complete
24+
request/response cycle finishes before the next begins. A 50ms inter-slave pause is added after
25+
each poll to let the RS485 bus settle (configurable via `inter_slave_delay` option).
26+
27+
Recovery: if the connection drops mid-read, the lock is always released (via `finally` block) and
28+
the hub reconnects + flushes stale buffer bytes on the next poll. Lock acquisitions time out after
29+
30s to prevent a hung coordinator from blocking others indefinitely.
30+
31+
Serial connections are not affected (each serial device opens its own file handle; same-device
32+
multi-slave serial setups are unusual and typically handled by the OS serial stack).
33+
34+
---
35+
736
## v1.0.7
837

938
- **New: Backup Box support (Growatt ARK transfer switch, TL-XH/MIN TL-XH):**

custom_components/growatt_modbus/__init__.py

Lines changed: 42 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,14 @@
1616
CONF_DEVICE_STRUCTURE_VERSION,
1717
CONF_INVERTER_SERIES,
1818
CONF_REGISTER_MAP,
19+
CONF_CONNECTION_TYPE,
1920
CURRENT_DEVICE_STRUCTURE_VERSION,
2021
WRITABLE_REGISTERS,
2122
DEVICE_TYPE_INVERTER,
2223
)
2324
from .coordinator import GrowattModbusCoordinator
2425
from .diagnostic import async_setup_services
26+
from .growatt_modbus import SharedModbusConnection
2527

2628
_LOGGER = logging.getLogger(__name__)
2729

@@ -33,10 +35,11 @@
3335
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
3436
"""Set up the Growatt Modbus integration."""
3537
hass.data.setdefault(DOMAIN, {})
36-
38+
hass.data[DOMAIN].setdefault("_connections", {})
39+
3740
# Set up diagnostic service
3841
await async_setup_services(hass)
39-
42+
4043
return True
4144

4245

@@ -218,7 +221,31 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
218221
_LOGGER.info("Removing stale number entity %s (WIT export_limit_w reg 203 not writable)", _stale_export_eid)
219222
entity_registry.async_remove(_stale_export_eid)
220223

221-
coordinator = GrowattModbusCoordinator(hass, entry)
224+
# Shared connection hub: all TCP entries on the same host:port share one ModbusTcpClient
225+
# and a threading.Lock to serialize reads/writes and prevent RS485 cross-talk on the gateway.
226+
# This is transparent for single-entry setups (hub refcount=1, no actual sharing).
227+
hub: SharedModbusConnection | None = None
228+
connection_type = entry.data.get(CONF_CONNECTION_TYPE, "tcp")
229+
if connection_type == "tcp":
230+
from homeassistant.const import CONF_HOST, CONF_PORT
231+
host = entry.data.get(CONF_HOST, "")
232+
port = entry.data.get(CONF_PORT, 502)
233+
timeout = entry.options.get("timeout", 10)
234+
hub_key = f"{host}:{port}"
235+
connections = hass.data[DOMAIN].setdefault("_connections", {})
236+
if hub_key not in connections:
237+
connections[hub_key] = SharedModbusConnection(host=host, port=port, timeout=timeout)
238+
_LOGGER.debug("Created shared Modbus connection hub for %s", hub_key)
239+
hub = connections[hub_key]
240+
hub.acquire_ref()
241+
if hub._refcount > 1:
242+
_LOGGER.info(
243+
"Shared Modbus connection mode: entry %s joined hub for %s (refcount=%d) — "
244+
"RS485 gateway cross-talk prevention active",
245+
entry.entry_id, hub_key, hub._refcount,
246+
)
247+
248+
coordinator = GrowattModbusCoordinator(hass, entry, hub=hub)
222249

223250
await coordinator.async_config_entry_first_refresh()
224251

@@ -278,7 +305,18 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
278305
"""Unload a config entry."""
279306
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
280307
coordinator = hass.data[DOMAIN].pop(entry.entry_id)
281-
await hass.async_add_executor_job(coordinator.modbus_client.disconnect)
308+
hub = getattr(coordinator, '_hub', None)
309+
if hub is not None:
310+
# Release the hub reference; hub disconnects when refcount reaches 0
311+
connections = hass.data[DOMAIN].get("_connections", {})
312+
hub.release_ref()
313+
if hub._refcount <= 0:
314+
# Remove from registry — hub already disconnected in release_ref()
315+
hub_key = f"{hub.host}:{hub.port}"
316+
connections.pop(hub_key, None)
317+
_LOGGER.debug("Shared Modbus connection hub for %s removed (no more users)", hub_key)
318+
else:
319+
await hass.async_add_executor_job(coordinator.modbus_client.disconnect)
282320

283321
return unload_ok
284322

custom_components/growatt_modbus/const.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,14 @@
6060
# Controls are within their respective devices (inverter or battery)
6161
CURRENT_DEVICE_STRUCTURE_VERSION = 2
6262

63+
# ============================================================================
64+
# SHARED CONNECTION MODE
65+
# When two TCP entries share the same host:port, a single ModbusTcpClient is
66+
# reused with a threading.Lock to serialize reads and prevent RS485 cross-talk.
67+
# ============================================================================
68+
SHARED_LOCK_TIMEOUT = 30 # seconds to wait for shared bus lock before giving up
69+
DEFAULT_INTER_SLAVE_DELAY_MS = 50 # ms pause after each slave poll to let RS485 bus settle
70+
6371
# ============================================================================
6472
# SENSOR TYPE CLASSIFICATIONS FOR OFFLINE BEHAVIOR
6573
# ============================================================================

custom_components/growatt_modbus/coordinator.py

Lines changed: 68 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -30,11 +30,13 @@
3030
DEVICE_TYPE_LOAD,
3131
DEVICE_TYPE_BATTERY,
3232
DEVICE_TYPE_BACKUPBOX,
33+
SHARED_LOCK_TIMEOUT,
34+
DEFAULT_INTER_SLAVE_DELAY_MS,
3335
)
3436

3537
from .const import REGISTER_MAPS
3638

37-
from .growatt_modbus import GrowattModbus, GrowattData
39+
from .growatt_modbus import GrowattModbus, GrowattData, SharedModbusConnection
3840

3941
_LOGGER = logging.getLogger(__name__)
4042

@@ -97,11 +99,13 @@ def test_connection(config: dict) -> dict:
9799
class GrowattModbusCoordinator(DataUpdateCoordinator[GrowattData]):
98100
"""Growatt Modbus data update coordinator."""
99101

100-
def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
102+
def __init__(self, hass: HomeAssistant, entry: ConfigEntry,
103+
hub: 'SharedModbusConnection | None' = None) -> None:
101104
"""Initialize the coordinator."""
102105
self.entry = entry
103106
self.config = entry.data
104107
self.hass = hass
108+
self._hub = hub # Shared connection hub (TCP multi-entry same host:port)
105109

106110
self._slave_id = entry.data[CONF_SLAVE_ID]
107111

@@ -351,10 +355,17 @@ def _initialize_client(self):
351355
slave_id=self.config[CONF_SLAVE_ID],
352356
register_map=register_map,
353357
timeout=timeout,
354-
invert_battery_power=invert_battery_power
358+
invert_battery_power=invert_battery_power,
359+
shared_conn=self._hub,
355360
)
356-
_LOGGER.debug("Initialized TCP Growatt client at %s:%s (invert_battery_power=%s)",
357-
self.config[CONF_HOST], self.config[CONF_PORT], invert_battery_power)
361+
if self._hub:
362+
_LOGGER.debug(
363+
"Initialized TCP Growatt client at %s:%s (shared connection mode, invert_battery_power=%s)",
364+
self.config[CONF_HOST], self.config[CONF_PORT], invert_battery_power,
365+
)
366+
else:
367+
_LOGGER.debug("Initialized TCP Growatt client at %s:%s (invert_battery_power=%s)",
368+
self.config[CONF_HOST], self.config[CONF_PORT], invert_battery_power)
358369
else: # serial
359370
self._client = GrowattModbus(
360371
connection_type="serial",
@@ -896,8 +907,60 @@ async def _async_update_data(self) -> GrowattData:
896907
self.data = GrowattData()
897908
return self.data
898909

910+
def _fetch_data_shared(self) -> GrowattData | None:
911+
"""Fetch data using the shared connection hub (holds hub lock for the full poll)."""
912+
hub = self._hub
913+
inter_slave_delay = self.config_entry.options.get(
914+
"inter_slave_delay", DEFAULT_INTER_SLAVE_DELAY_MS
915+
) / 1000.0
916+
917+
acquired = hub._lock.acquire(timeout=SHARED_LOCK_TIMEOUT)
918+
if not acquired:
919+
_LOGGER.warning(
920+
"Shared Modbus connection busy (lock timeout %ds) for %s:%s slave %s — skipping this poll",
921+
SHARED_LOCK_TIMEOUT,
922+
self.config.get(CONF_HOST),
923+
self.config.get(CONF_PORT),
924+
self.config.get(CONF_SLAVE_ID),
925+
)
926+
return None
927+
928+
try:
929+
if not hub.ensure_connected():
930+
_LOGGER.warning(
931+
"Shared Modbus connection could not connect to %s:%s",
932+
self.config.get(CONF_HOST), self.config.get(CONF_PORT),
933+
)
934+
return None
935+
936+
self._client._battery_voltage_range = self.config_entry.options.get(
937+
"battery_voltage_range", "Auto-detect"
938+
)
939+
delay_s = self.config_entry.options.get("modbus_delay", 250) / 1000.0
940+
self._client._default_min_read_interval = delay_s
941+
if not self._client._backed_off:
942+
self._client.min_read_interval = delay_s
943+
944+
data = self._client.read_all_data()
945+
if data is not None and not self._serial_number:
946+
self._read_device_identification()
947+
948+
time.sleep(inter_slave_delay)
949+
return data
950+
951+
except Exception as err:
952+
_LOGGER.warning("Error during shared data fetch for slave %s: %s",
953+
self.config.get(CONF_SLAVE_ID), err)
954+
return None
955+
956+
finally:
957+
hub._lock.release()
958+
899959
def _fetch_data(self) -> GrowattData | None:
900960
"""Fetch data from the inverter (runs in executor)."""
961+
if self._hub is not None:
962+
return self._fetch_data_shared()
963+
901964
max_retries = 3
902965
retry_delay = 3 # seconds - increased from 2
903966

0 commit comments

Comments
 (0)