88
99from homeassistant .config_entries import ConfigEntry
1010from homeassistant .core import HomeAssistant
11+ from homeassistant .exceptions import HomeAssistantError
1112from homeassistant .helpers .update_coordinator import DataUpdateCoordinator , UpdateFailed
1213
1314from givenergy_modbus .client .client import Client
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
2846class 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 ()
0 commit comments