Skip to content

Commit 2463176

Browse files
authored
Improved connection timeout handling (#151)
1 parent 60d76de commit 2463176

4 files changed

Lines changed: 135 additions & 31 deletions

File tree

custom_components/givenergy_local/__init__.py

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -41,13 +41,26 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
4141
entry, _PLATFORMS
4242
)
4343
if unload_ok:
44-
hass.data[DOMAIN].pop(entry.entry_id)
44+
coordinator: GivEnergyUpdateCoordinator = hass.data[DOMAIN].pop(entry.entry_id)
45+
# Explicit and immediate, rather than relying solely on the shutdown
46+
# callback HA registers via config_entry.async_on_unload - that only
47+
# fires after this function returns, so without this the connection
48+
# (and its socket) would otherwise outlive the platforms it backs.
49+
await coordinator.async_shutdown()
4550
async_unload_services(hass)
4651

4752
return unload_ok
4853

4954

5055
async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
51-
"""Reload config entry."""
52-
await async_unload_entry(hass, entry)
53-
await async_setup_entry(hass, entry)
56+
"""Reload config entry.
57+
58+
Goes through hass.config_entries.async_reload rather than calling
59+
async_unload_entry/async_setup_entry directly: HA only runs the config
60+
entry's on-unload callbacks (which is where DataUpdateCoordinator hooks
61+
its own shutdown) from within ConfigEntry.async_unload's post-unload
62+
processing. Calling async_unload_entry directly skips that path
63+
entirely, leaking the previous coordinator's client and socket on every
64+
reload.
65+
"""
66+
await hass.config_entries.async_reload(entry.entry_id)

custom_components/givenergy_local/coordinator.py

Lines changed: 115 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
from homeassistant.config_entries import ConfigEntry
1010
from homeassistant.core import HomeAssistant
11+
from homeassistant.exceptions import HomeAssistantError
1112
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
1213

1314
from givenergy_modbus.client.client import Client
@@ -23,6 +24,23 @@
2324
_REFRESH_DELAY_BETWEEN_ATTEMPTS = 2.0
2425
_COMMAND_TIMEOUT = 3.0
2526
_COMMAND_RETRIES = 3
27+
_EXECUTE_TIMEOUT = 15.0
28+
29+
# Bound on how long we will wait for the underlying socket to close. A half-dead
30+
# dongle can leave writer.wait_closed() hanging (or raising TimeoutError)
31+
# indefinitely; without a bound, tearing down a wedged connection can itself
32+
# wedge the coordinator (see issue #147).
33+
_CLOSE_TIMEOUT = 5.0
34+
35+
# Bound on connect()+detect() at the top of each poll.
36+
_CONNECT_TIMEOUT = 15.0
37+
38+
# Backoff applied between reconnect attempts while the inverter is unreachable,
39+
# so a sick dongle is not handed a fresh socket every 10s poll while its limited
40+
# connection slots are still draining (see issue #147 - stale sockets on the
41+
# WiFi bridge).
42+
_RECONNECT_BACKOFF_INITIAL = 10.0
43+
_RECONNECT_BACKOFF_MAX = 60.0
2644

2745

2846
class GivEnergyUpdateCoordinator(DataUpdateCoordinator[Plant]):
@@ -42,22 +60,94 @@ def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None:
4260
self.client = Client(self.host, 8899)
4361
self.require_full_refresh = True
4462
self.last_full_refresh = datetime.min.replace(tzinfo=UTC)
63+
self._reconnect_backoff = _RECONNECT_BACKOFF_INITIAL
64+
self._next_reconnect_attempt = datetime.min.replace(tzinfo=UTC)
4565

4666
async def async_shutdown(self) -> None:
47-
"""Terminate the modbus connection and shut down the coordinator."""
67+
"""Terminate the modbus connection and shut down the coordinator.
68+
69+
Unschedules the refresh first, unconditionally: stopping the
70+
coordinator must never be contingent on the socket closing cleanly.
71+
This method must never raise - HA runs it as a detached task from the
72+
config entry's on-unload processing, so an exception here would only
73+
ever be logged, never surfaced or retried (see issue #147).
74+
"""
4875
_LOGGER.debug("Shutting down")
49-
await self.client.close()
5076
await super().async_shutdown()
77+
await self._close_client()
78+
79+
async def _close_client(self) -> bool:
80+
"""Close the current client, bounded and never raising.
81+
82+
Returns True on a clean close. On timeout or any other failure, the
83+
client is discarded and replaced with a fresh, disconnected one: a
84+
Client whose close() has failed can never be revived, because
85+
writer.wait_closed() keeps re-raising off the same already-failed
86+
close-waiter future on every subsequent attempt (issue #147).
87+
Rebuilding is the only way out available from here, short of an
88+
upstream fix.
89+
"""
90+
client = self.client
91+
try:
92+
async with asyncio.timeout(_CLOSE_TIMEOUT):
93+
await client.close()
94+
except Exception as err: # noqa: BLE001 - deliberately broad, see docstring
95+
_LOGGER.warning(
96+
"Failed to close inverter connection cleanly, abandoning it: %s", err
97+
)
98+
for task in (
99+
getattr(client, "network_consumer_task", None),
100+
getattr(client, "network_producer_task", None),
101+
):
102+
if task is not None and not task.done():
103+
task.cancel()
104+
self.client = Client(self.host, 8899)
105+
return False
106+
return True
107+
108+
async def _reconnect(self) -> None:
109+
"""Establish (or re-establish) the connection and device topology.
110+
111+
Bounded, and converts connection failures into UpdateFailed rather
112+
than letting them escape raw. Backs off between attempts so a sick
113+
dongle is not handed a fresh socket every 10s poll while its
114+
connection slots are still draining.
115+
"""
116+
if datetime.now(UTC) < self._next_reconnect_attempt:
117+
raise UpdateFailed("Waiting before next reconnect attempt")
118+
119+
try:
120+
async with asyncio.timeout(_CONNECT_TIMEOUT):
121+
await self.client.connect()
122+
# Discover device type and topology. This populates
123+
# plant.capabilities, which the config/measurement reads
124+
# below rely on. A freshly detected plant has no register
125+
# data yet, so force a full refresh this cycle.
126+
await self.client.detect()
127+
except (CommunicationError, TimeoutError) as err:
128+
await self._close_client()
129+
self._next_reconnect_attempt = datetime.now(UTC) + timedelta(
130+
seconds=self._reconnect_backoff
131+
)
132+
_LOGGER.warning(
133+
"Failed to connect to inverter at %s, retrying in %.0fs: %s",
134+
self.host,
135+
self._reconnect_backoff,
136+
err,
137+
)
138+
self._reconnect_backoff = min(
139+
self._reconnect_backoff * 2, _RECONNECT_BACKOFF_MAX
140+
)
141+
raise UpdateFailed(f"Failed to connect to inverter: {err}") from err
142+
143+
self._reconnect_backoff = _RECONNECT_BACKOFF_INITIAL
144+
self._next_reconnect_attempt = datetime.min.replace(tzinfo=UTC)
145+
self.require_full_refresh = True
51146

52147
async def _async_update_data(self) -> Plant:
53148
"""Fetch data from the inverter."""
54149
if not self.client.connected:
55-
await self.client.connect()
56-
# Discover device type and topology. This populates plant.capabilities,
57-
# which the config/measurement reads below rely on. A freshly detected
58-
# plant has no register data yet, so force a full refresh this cycle.
59-
await self.client.detect()
60-
self.require_full_refresh = True
150+
await self._reconnect()
61151

62152
if self.last_full_refresh < (datetime.now(UTC) - _FULL_REFRESH_INTERVAL):
63153
self.require_full_refresh = True
@@ -71,7 +161,7 @@ async def _async_update_data(self) -> Plant:
71161
attempt += 1
72162
try:
73163
async with asyncio.timeout(10):
74-
_LOGGER.info(
164+
_LOGGER.debug(
75165
"Fetching data from %s (attempt=%d/%d, full_refresh=%s)",
76166
self.host,
77167
attempt,
@@ -90,16 +180,14 @@ async def _async_update_data(self) -> Plant:
90180
_LOGGER.warning("Plant refresh failed due to bad data: %s", err)
91181
await asyncio.sleep(_REFRESH_DELAY_BETWEEN_ATTEMPTS)
92182
continue
93-
except TimeoutError:
183+
except TimeoutError as err:
94184
# For some inverters/environments, frequent timeout errors occur.
95185
# In such cases, a retry using the same connection is often unsuccessful.
96-
# To prevent 'unavailable' data in HA, we attempt a full reconnect here.
97-
_LOGGER.warning("Plant refresh timed out")
98-
await self.client.close()
186+
# To prevent 'unavailable' data in HA, we close the connection here so the
187+
# next poll's top-of-loop check reconnects (bounded, with backoff).
188+
_LOGGER.warning("Plant refresh timed out: %s", err)
189+
await self._close_client()
99190
await asyncio.sleep(_REFRESH_DELAY_BETWEEN_ATTEMPTS)
100-
await self.client.connect()
101-
await self.client.detect()
102-
self.require_full_refresh = True
103191
continue
104192
except RefreshError as err:
105193
# Some or all register reads failed this cycle. Discard any partial
@@ -110,19 +198,16 @@ async def _async_update_data(self) -> Plant:
110198
continue
111199
except CommunicationError as err:
112200
_LOGGER.debug("Closing connection due to communication error: %s", err)
113-
await self.client.close()
201+
await self._close_client()
114202
raise UpdateFailed() from err
115203
except Exception as err:
116-
_LOGGER.error("Closing connection due to expected error: %s", err)
117-
await self.client.close()
118-
raise UpdateFailed("Connection closed due to expected error") from err
204+
_LOGGER.error("Closing connection due to unexpected error: %s", err)
205+
await self._close_client()
206+
raise UpdateFailed("Connection closed due to unexpected error") from err
119207

120208
if self.require_full_refresh:
121209
self.require_full_refresh = False
122210
self.last_full_refresh = datetime.now(UTC)
123-
_LOGGER.info(
124-
f"Current time: {plant.inverter.model_dump().get('system_time')}"
125-
)
126211
return plant
127212

128213
raise UpdateFailed(
@@ -131,6 +216,12 @@ async def _async_update_data(self) -> Plant:
131216

132217
async def execute(self, requests: list[TransparentRequest]) -> None:
133218
"""Execute a set of requests and force an update to read any new values."""
134-
self.client.execute(requests, _COMMAND_TIMEOUT, _COMMAND_RETRIES)
219+
try:
220+
async with asyncio.timeout(_EXECUTE_TIMEOUT):
221+
await self.client.execute(requests, _COMMAND_TIMEOUT, _COMMAND_RETRIES)
222+
except (TimeoutError, CommunicationError) as err:
223+
raise HomeAssistantError(
224+
f"Failed to send command to inverter: {err}"
225+
) from err
135226
self.require_full_refresh = True
136227
await self.async_request_refresh()

custom_components/givenergy_local/manifest.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
"iot_class": "local_polling",
1212
"issue_tracker": "https://github.com/cdpuk/givenergy-local/issues",
1313
"requirements": [
14-
"givenergy-modbus>=2.12.1,<3"
14+
"givenergy-modbus>=2.13.0,<3"
1515
],
16-
"version": "2.5.0"
16+
"version": "2.5.2"
1717
}

requirements.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
givenergy-modbus>=2.12.1,<3
1+
givenergy-modbus>=2.13.0,<3
22

33
# Don't pin the HA version
44
homeassistant

0 commit comments

Comments
 (0)