Skip to content

Commit 748a1aa

Browse files
0xAHAclaude
andcommitted
fix: serial entries on one adapter now share a connection (#384)
The shared connection hub exists to serialize transactions and 'prevent RS485 cross-talk', and was created only for TCP entries. That had it backwards: an RS485 bus is precisely where two uncoordinated masters collide. Each serial entry opened its own client on the same adapter and paced itself with a per-instance min_read_interval, which says nothing about what the other entry is doing. Two inverters on one USB-RS485 adapter - the normal way to wire a parallel SPF stack - interleaved frames on one physical bus with nothing serializing them, giving random single-sample read failures on both units. The hub is now transport-agnostic, keyed on device path for serial and host:port for TCP. Serial gets its own buffer drain via pyserial's reset_input_buffer(), since the TCP path's socket recv() loop raises on a Serial object and would silently skip the flush. Parity/stopbits/bytesize default to N/1/8, matching what the non-shared path has always hardcoded, so moving to a shared connection cannot change framing. Note the runtime name for the serial class is ModbusClient - ModbusSerialClient is imported only under TYPE_CHECKING. The first cut of this used the latter and would have raised NameError on the first connection; the whole existing suite passed because nothing ever asked a serial hub to connect. The new tests exercise that path. Verified red without the fix. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 05419eb commit 748a1aa

6 files changed

Lines changed: 337 additions & 42 deletions

File tree

RELEASENOTES.md

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

55
---
66

7+
## v1.7.0 (pre-release)
8+
9+
> **Pre-release for testing.** v1.6.2 remains the stable release.
10+
11+
- **Multiple inverters on one USB-RS485 adapter now share a single connection.** Until now
12+
each entry opened its own serial client on the same adapter and paced only itself, so two
13+
pollers interleaved their frames on one bus with nothing coordinating them — which shows up
14+
as random, unexplained read failures on both inverters. Serial entries on the same device
15+
path are now serialised behind one lock, the same way TCP entries on the same host:port
16+
already were. **Only affects setups with two or more entries on one adapter**; single-entry
17+
setups are unchanged, and TCP is untouched.
18+
19+
---
20+
721
## v1.6.9 (pre-release)
822

923
Issues: #384

custom_components/growatt_modbus/__init__.py

Lines changed: 31 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,9 @@
1717
CONF_INVERTER_SERIES,
1818
CONF_REGISTER_MAP,
1919
CONF_CONNECTION_TYPE,
20+
CONF_DEVICE_PATH,
21+
CONF_BAUDRATE,
22+
DEFAULT_BAUDRATE,
2023
CURRENT_DEVICE_STRUCTURE_VERSION,
2124
REGISTER_MAPS,
2225
WRITABLE_REGISTERS,
@@ -242,27 +245,48 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
242245
_LOGGER.info("Removing stale number entity %s (WIT export_limit_w reg 203 not writable)", _stale_export_eid)
243246
entity_registry.async_remove(_stale_export_eid)
244247

245-
# Shared connection hub: all TCP entries on the same host:port share one ModbusTcpClient
246-
# and a threading.Lock to serialize reads/writes and prevent RS485 cross-talk on the gateway.
248+
# Shared connection hub: every entry on the same transport — the same host:port for TCP,
249+
# the same device path for serial — shares one client and a threading.Lock that
250+
# serializes reads and writes, preventing RS485 cross-talk.
251+
#
252+
# Serial was excluded from this until v1.7.0, which had it backwards: an RS485 bus is
253+
# precisely where two uncoordinated masters collide. Each entry opened its own
254+
# ModbusSerialClient on the same adapter and paced itself with a per-instance
255+
# min_read_interval, which says nothing about what the other entry is doing. Two
256+
# inverters on one USB-RS485 adapter is the normal way to wire a parallel SPF stack.
257+
#
247258
# This is transparent for single-entry setups (hub refcount=1, no actual sharing).
248259
hub: SharedModbusConnection | None = None
249260
connection_type = entry.data.get(CONF_CONNECTION_TYPE, "tcp")
261+
timeout = entry.options.get("timeout", 10)
262+
connections = hass.data[DOMAIN].setdefault("_connections", {})
263+
250264
if connection_type == "tcp":
251265
from homeassistant.const import CONF_HOST, CONF_PORT
252266
host = entry.data.get(CONF_HOST, "")
253267
port = entry.data.get(CONF_PORT, 502)
254-
timeout = entry.options.get("timeout", 10)
255-
hub_key = f"{host}:{port}"
256-
connections = hass.data[DOMAIN].setdefault("_connections", {})
268+
hub_key = f"{host}:{port}" if host else ""
269+
hub_factory = lambda: SharedModbusConnection(host=host, port=port, timeout=timeout)
270+
else:
271+
device = entry.data.get(CONF_DEVICE_PATH, "")
272+
baudrate = entry.data.get(CONF_BAUDRATE, DEFAULT_BAUDRATE)
273+
# Keyed on the device path alone: baud rate is a property of the bus, so two entries
274+
# disagreeing about it are misconfigured rather than entitled to separate clients.
275+
hub_key = f"serial:{device}" if device else ""
276+
hub_factory = lambda: SharedModbusConnection(
277+
device=device, baudrate=baudrate, timeout=timeout
278+
)
279+
280+
if hub_key:
257281
if hub_key not in connections:
258-
connections[hub_key] = SharedModbusConnection(host=host, port=port, timeout=timeout)
282+
connections[hub_key] = hub_factory()
259283
_LOGGER.debug("Created shared Modbus connection hub for %s", hub_key)
260284
hub = connections[hub_key]
261285
hub.acquire_ref()
262286
if hub._refcount > 1:
263287
_LOGGER.info(
264288
"Shared Modbus connection mode: entry %s joined hub for %s (refcount=%d) — "
265-
"RS485 gateway cross-talk prevention active",
289+
"RS485 cross-talk prevention active",
266290
entry.entry_id, hub_key, hub._refcount,
267291
)
268292

custom_components/growatt_modbus/growatt_modbus.py

Lines changed: 107 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -432,18 +432,43 @@ class GrowattData:
432432
serial_number: str = ""
433433

434434
class SharedModbusConnection:
435-
"""Single ModbusTcpClient shared across multiple GrowattModbus instances on the same host:port.
435+
"""One Modbus client shared across every GrowattModbus instance on the same transport.
436+
437+
For TCP that means the same host:port; for serial, the same device path.
436438
437439
Serializes all Modbus transactions with a threading.Lock (because _fetch_data runs in
438-
executor threads, not on the asyncio event loop). Reference-counted so the TCP socket
440+
executor threads, not on the asyncio event loop). Reference-counted so the connection
439441
stays open as long as at least one coordinator needs it.
442+
443+
**Serial was excluded from this until v1.7.0**, which was backwards. The hub's stated
444+
purpose is preventing RS485 cross-talk, and an RS485 bus is exactly where two
445+
uncoordinated masters collide: each config entry opened its own ModbusSerialClient on
446+
the same adapter and paced itself with a per-instance `min_read_interval`, which says
447+
nothing about what the other entry is doing. Two inverters on one USB-RS485 adapter —
448+
the normal way to wire a parallel SPF stack — interleaved their frames on one physical
449+
bus with nothing serializing them.
440450
"""
441451

442-
def __init__(self, host: str, port: int, timeout: int = 10) -> None:
452+
def __init__(
453+
self,
454+
host: str = "",
455+
port: int = 502,
456+
timeout: int = 10,
457+
device: str = "",
458+
baudrate: int = 9600,
459+
parity: str = "N",
460+
stopbits: int = 1,
461+
bytesize: int = 8,
462+
) -> None:
443463
self.host = host
444464
self.port = port
465+
self.device = device
466+
self.baudrate = baudrate
467+
self.parity = parity
468+
self.stopbits = stopbits
469+
self.bytesize = bytesize
445470
self._timeout = timeout
446-
self._client: Optional['ModbusTcpClient'] = None
471+
self._client: Optional[Union['ModbusTcpClient', 'ModbusSerialClient']] = None
447472
# Reentrant so a caller can hold the bus across a sequence of writes while the
448473
# individual write methods still take it themselves (#331). A plain Lock would
449474
# deadlock the moment write_batch() wrapped anything. Reentrancy is per-thread,
@@ -467,6 +492,15 @@ def __init__(self, host: str, port: int, timeout: int = 10) -> None:
467492
self.good_reads = 0
468493
self.malformed_reads = 0
469494

495+
@property
496+
def is_serial(self) -> bool:
497+
return bool(self.device)
498+
499+
@property
500+
def connection_id(self) -> str:
501+
"""Identifier used in log lines — the device path, or host:port."""
502+
return self.device if self.is_serial else f"{self.host}:{self.port}"
503+
470504
# ------------------------------------------------------------------
471505
# Reference counting
472506
# ------------------------------------------------------------------
@@ -498,12 +532,30 @@ def release_ref(self) -> None:
498532
def ensure_connected(self) -> bool:
499533
"""Connect if not already open; flush stale bytes on a new connection."""
500534
if self._client is None:
501-
try:
502-
self._client = ModbusTcpClient(host=self.host, port=self.port, timeout=self._timeout)
503-
except TypeError:
504-
self._client = ModbusTcpClient(self.host, self.port)
505-
if hasattr(self._client, 'timeout'):
506-
self._client.timeout = self._timeout
535+
if self.is_serial:
536+
if not SERIAL_AVAILABLE:
537+
logger.error(
538+
"[SharedConn %s] pyserial/pymodbus serial support is not installed",
539+
self.connection_id,
540+
)
541+
return False
542+
# NB: the runtime name is ModbusClient — ModbusSerialClient is imported only
543+
# under TYPE_CHECKING and does not exist when this executes.
544+
self._client = ModbusClient(
545+
port=self.device,
546+
baudrate=self.baudrate,
547+
parity=self.parity,
548+
stopbits=self.stopbits,
549+
bytesize=self.bytesize,
550+
timeout=self._timeout,
551+
)
552+
else:
553+
try:
554+
self._client = ModbusTcpClient(host=self.host, port=self.port, timeout=self._timeout)
555+
except TypeError:
556+
self._client = ModbusTcpClient(self.host, self.port)
557+
if hasattr(self._client, 'timeout'):
558+
self._client.timeout = self._timeout
507559

508560
try:
509561
if hasattr(self._client, 'is_socket_open') and self._client.is_socket_open():
@@ -535,13 +587,34 @@ def reset(self, reason: str = "") -> None:
535587
a wedged connection.
536588
"""
537589
logger.warning(
538-
"[SharedConn %s:%s] Resetting connection%s",
539-
self.host, self.port, f": {reason}" if reason else "",
590+
"[SharedConn %s] Resetting connection%s",
591+
self.connection_id, f": {reason}" if reason else "",
540592
)
541593
self.disconnect()
542594

543595
def _flush_receive_buffer(self) -> None:
544-
"""Drain stale Modbus responses left in the adapter's TCP buffer after reconnect."""
596+
"""Drain stale Modbus responses left in the adapter's buffer after reconnect."""
597+
if self.is_serial:
598+
# pyserial exposes its own drain; the socket recv() path below does not apply
599+
# and would raise on a Serial object.
600+
serial_port = getattr(self._client, 'socket', None)
601+
if serial_port is None:
602+
return
603+
try:
604+
waiting = getattr(serial_port, 'in_waiting', 0)
605+
serial_port.reset_input_buffer()
606+
if waiting:
607+
logger.debug(
608+
"[SharedConn %s] Flushed %d stale bytes from serial input buffer",
609+
self.connection_id, waiting,
610+
)
611+
except Exception as exc:
612+
logger.debug(
613+
"[SharedConn %s] Serial buffer flush failed (non-critical): %s",
614+
self.connection_id, exc,
615+
)
616+
return
617+
545618
sock = getattr(self._client, 'socket', None)
546619
if sock is None:
547620
transport = getattr(self._client, 'transport', None)
@@ -565,11 +638,11 @@ def _flush_receive_buffer(self) -> None:
565638
sock.settimeout(original_timeout)
566639
if discarded:
567640
logger.debug(
568-
"[SharedConn %s:%s] Flushed %d stale bytes from receive buffer after reconnect",
569-
self.host, self.port, discarded,
641+
"[SharedConn %s] Flushed %d stale bytes from receive buffer after reconnect",
642+
self.connection_id, discarded,
570643
)
571644
except Exception as exc:
572-
logger.debug("[SharedConn %s:%s] Buffer flush failed (non-critical): %s", self.host, self.port, exc)
645+
logger.debug("[SharedConn %s] Buffer flush failed (non-critical): %s", self.connection_id, exc)
573646

574647
# ------------------------------------------------------------------
575648
# Register access (slave_id passed per call, not stored on hub)
@@ -603,9 +676,9 @@ def _validate_registers(self, resp, start: int, count: int) -> Optional[list]:
603676

604677
if len(registers) != count:
605678
logger.warning(
606-
"[SharedConn %s:%s] Short/misaligned read at %d: got %d of %d registers — "
679+
"[SharedConn %s] Short/misaligned read at %d: got %d of %d registers — "
607680
"discarding frame and flushing buffer",
608-
self.host, self.port, start, len(registers), count,
681+
self.connection_id, start, len(registers), count,
609682
)
610683
# A misaligned stream stays misaligned — which is why the corrupt values
611684
# repeat byte-for-byte rather than varying. Draining the buffer here gives the
@@ -647,15 +720,15 @@ def read_input_registers(self, start: int, count: int, slave_id: int) -> Optiona
647720
except Exception as exc:
648721
if attempt == 0 and self._begin_recovery():
649722
logger.debug(
650-
"[SharedConn %s:%s] read_input_registers(%d, %d) transport error "
723+
"[SharedConn %s] read_input_registers(%d, %d) transport error "
651724
"(%s) — resetting and retrying once",
652-
self.host, self.port, start, count, exc,
725+
self.connection_id, start, count, exc,
653726
)
654727
self.reset("transport error during block read")
655728
if self.ensure_connected():
656729
continue
657-
logger.debug("[SharedConn %s:%s] read_input_registers(%d, %d, slave=%d) error: %s",
658-
self.host, self.port, start, count, slave_id, exc)
730+
logger.debug("[SharedConn %s] read_input_registers(%d, %d, slave=%d) error: %s",
731+
self.connection_id, start, count, slave_id, exc)
659732
return None
660733

661734
return self._validate_registers(resp, start, count)
@@ -677,15 +750,15 @@ def read_holding_registers(self, start: int, count: int, slave_id: int) -> Optio
677750
except Exception as exc:
678751
if attempt == 0 and self._begin_recovery():
679752
logger.debug(
680-
"[SharedConn %s:%s] read_holding_registers(%d, %d) transport error "
753+
"[SharedConn %s] read_holding_registers(%d, %d) transport error "
681754
"(%s) — resetting and retrying once",
682-
self.host, self.port, start, count, exc,
755+
self.connection_id, start, count, exc,
683756
)
684757
self.reset("transport error during block read")
685758
if self.ensure_connected():
686759
continue
687-
logger.debug("[SharedConn %s:%s] read_holding_registers(%d, %d, slave=%d) error: %s",
688-
self.host, self.port, start, count, slave_id, exc)
760+
logger.debug("[SharedConn %s] read_holding_registers(%d, %d, slave=%d) error: %s",
761+
self.connection_id, start, count, slave_id, exc)
689762
return None
690763

691764
return self._validate_registers(resp, start, count)
@@ -730,15 +803,15 @@ def write_register(self, register: int, value: int, slave_id: int) -> bool:
730803
except Exception as exc:
731804
if attempt == 0 and self._begin_recovery():
732805
logger.debug(
733-
"[SharedConn %s:%s] write_register(%d, %d) transport error (%s) — "
806+
"[SharedConn %s] write_register(%d, %d) transport error (%s) — "
734807
"resetting and retrying once",
735-
self.host, self.port, register, value, exc,
808+
self.connection_id, register, value, exc,
736809
)
737810
self.reset("transport error during write")
738811
if self.ensure_connected():
739812
continue
740-
logger.debug("[SharedConn %s:%s] write_register(%d, %d, slave=%d) error: %s",
741-
self.host, self.port, register, value, slave_id, exc)
813+
logger.debug("[SharedConn %s] write_register(%d, %d, slave=%d) error: %s",
814+
self.connection_id, register, value, slave_id, exc)
742815
self.disconnect()
743816
return False
744817

@@ -760,15 +833,15 @@ def write_registers(self, register: int, values: list, slave_id: int) -> bool:
760833
except Exception as exc:
761834
if attempt == 0 and self._begin_recovery():
762835
logger.debug(
763-
"[SharedConn %s:%s] write_registers(%d, %d values) transport error "
836+
"[SharedConn %s] write_registers(%d, %d values) transport error "
764837
"(%s) — resetting and retrying once",
765-
self.host, self.port, register, len(values), exc,
838+
self.connection_id, register, len(values), exc,
766839
)
767840
self.reset("transport error during write")
768841
if self.ensure_connected():
769842
continue
770-
logger.debug("[SharedConn %s:%s] write_registers(%d, slave=%d) error: %s",
771-
self.host, self.port, register, slave_id, exc)
843+
logger.debug("[SharedConn %s] write_registers(%d, slave=%d) error: %s",
844+
self.connection_id, register, slave_id, exc)
772845
self.disconnect()
773846
return False
774847

custom_components/growatt_modbus/manifest.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,5 +14,5 @@
1414
"pymodbus>=3.0.0",
1515
"pyserial>=3.4"
1616
],
17-
"version": "1.6.9"
17+
"version": "1.7.0"
1818
}

docs/troubleshooting/rs485-gateways.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,23 @@ The integration holds one socket per host:port across polls. It was suspected of
157157

158158
---
159159

160+
## Two or more inverters on one adapter
161+
162+
If you run several inverters as separate integration entries over **one** USB-RS485 adapter or one gateway, they share a single physical bus. Only one master can be talking at a time.
163+
164+
The integration handles this for you: every entry pointing at the same device path (or the same host:port) shares one connection and one lock, so their reads are queued rather than interleaved. Nothing to configure.
165+
166+
**Serial setups need v1.7.0 or later for this.** Before that, only TCP entries were coordinated — each serial entry opened its own client on the same adapter and paced only itself, which produced random read failures on *all* the inverters sharing that bus. If you have two entries on one adapter and see unexplained dropouts, this is the first thing to rule out.
167+
168+
Two consequences worth knowing:
169+
170+
- **Polls queue, they do not overlap.** With several inverters the effective cycle is the sum of their polls. If a single poll takes 8 s, three inverters need an interval comfortably above 24 s.
171+
- **A slow or failing inverter slows the others**, because they wait for the bus. If one entry is much worse than the rest, disable it and see whether the others recover — that isolates the problem device quickly.
172+
173+
Raising the scan interval is the main lever here, exactly as it is for a single unit.
174+
175+
---
176+
160177
## Gaps in the graph are the fix, not the fault
161178

162179
Since **v1.6.6** a register that could not be read is published as **unavailable** for that poll, not as `0`.

0 commit comments

Comments
 (0)