Skip to content

Commit c6d0aad

Browse files
hahn-thfrenck
authored andcommitted
Handle connection issues after websocket reconnected in homematicip_cloud (home-assistant#147731)
1 parent 87af9fc commit c6d0aad

File tree

6 files changed

+107
-34
lines changed

6 files changed

+107
-34
lines changed

homeassistant/components/homematicip_cloud/hap.py

Lines changed: 39 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -113,9 +113,7 @@ def __init__(
113113

114114
self._ws_close_requested = False
115115
self._ws_connection_closed = asyncio.Event()
116-
self._retry_task: asyncio.Task | None = None
117-
self._tries = 0
118-
self._accesspoint_connected = True
116+
self._get_state_task: asyncio.Task | None = None
119117
self.hmip_device_by_entity_id: dict[str, Any] = {}
120118
self.reset_connection_listener: Callable | None = None
121119

@@ -161,17 +159,8 @@ def async_update(self, *args, **kwargs) -> None:
161159
"""
162160
if not self.home.connected:
163161
_LOGGER.error("HMIP access point has lost connection with the cloud")
164-
self._accesspoint_connected = False
162+
self._ws_connection_closed.set()
165163
self.set_all_to_unavailable()
166-
elif not self._accesspoint_connected:
167-
# Now the HOME_CHANGED event has fired indicating the access
168-
# point has reconnected to the cloud again.
169-
# Explicitly getting an update as entity states might have
170-
# changed during access point disconnect."""
171-
172-
job = self.hass.async_create_task(self.get_state())
173-
job.add_done_callback(self.get_state_finished)
174-
self._accesspoint_connected = True
175164

176165
@callback
177166
def async_create_entity(self, *args, **kwargs) -> None:
@@ -185,20 +174,43 @@ async def async_create_entity_lazy(self, is_device=True) -> None:
185174
await asyncio.sleep(30)
186175
await self.hass.config_entries.async_reload(self.config_entry.entry_id)
187176

177+
async def _try_get_state(self) -> None:
178+
"""Call get_state in a loop until no error occurs, using exponential backoff on error."""
179+
180+
# Wait until WebSocket connection is established.
181+
while not self.home.websocket_is_connected():
182+
await asyncio.sleep(2)
183+
184+
delay = 8
185+
max_delay = 1500
186+
while True:
187+
try:
188+
await self.get_state()
189+
break
190+
except HmipConnectionError as err:
191+
_LOGGER.warning(
192+
"Get_state failed, retrying in %s seconds: %s", delay, err
193+
)
194+
await asyncio.sleep(delay)
195+
delay = min(delay * 2, max_delay)
196+
188197
async def get_state(self) -> None:
189198
"""Update HMIP state and tell Home Assistant."""
190199
await self.home.get_current_state_async()
191200
self.update_all()
192201

193202
def get_state_finished(self, future) -> None:
194-
"""Execute when get_state coroutine has finished."""
203+
"""Execute when try_get_state coroutine has finished."""
195204
try:
196205
future.result()
197-
except HmipConnectionError:
198-
# Somehow connection could not recover. Will disconnect and
199-
# so reconnect loop is taking over.
200-
_LOGGER.error("Updating state after HMIP access point reconnect failed")
201-
self.hass.async_create_task(self.home.disable_events())
206+
except Exception as err: # noqa: BLE001
207+
_LOGGER.error(
208+
"Error updating state after HMIP access point reconnect: %s", err
209+
)
210+
else:
211+
_LOGGER.info(
212+
"Updating state after HMIP access point reconnect finished successfully",
213+
)
202214

203215
def set_all_to_unavailable(self) -> None:
204216
"""Set all devices to unavailable and tell Home Assistant."""
@@ -222,8 +234,8 @@ async def async_connect(self, home: AsyncHome) -> None:
222234
async def async_reset(self) -> bool:
223235
"""Close the websocket connection."""
224236
self._ws_close_requested = True
225-
if self._retry_task is not None:
226-
self._retry_task.cancel()
237+
if self._get_state_task is not None:
238+
self._get_state_task.cancel()
227239
await self.home.disable_events_async()
228240
_LOGGER.debug("Closed connection to HomematicIP cloud server")
229241
await self.hass.config_entries.async_unload_platforms(
@@ -247,7 +259,9 @@ async def ws_connected_handler(self) -> None:
247259
"""Handle websocket connected."""
248260
_LOGGER.info("Websocket connection to HomematicIP Cloud established")
249261
if self._ws_connection_closed.is_set():
250-
await self.get_state()
262+
self._get_state_task = self.hass.async_create_task(self._try_get_state())
263+
self._get_state_task.add_done_callback(self.get_state_finished)
264+
251265
self._ws_connection_closed.clear()
252266

253267
async def ws_disconnected_handler(self) -> None:
@@ -256,11 +270,12 @@ async def ws_disconnected_handler(self) -> None:
256270
self._ws_connection_closed.set()
257271

258272
async def ws_reconnected_handler(self, reason: str) -> None:
259-
"""Handle websocket reconnection."""
273+
"""Handle websocket reconnection. Is called when Websocket tries to reconnect."""
260274
_LOGGER.info(
261-
"Websocket connection to HomematicIP Cloud re-established due to reason: %s",
275+
"Websocket connection to HomematicIP Cloud trying to reconnect due to reason: %s",
262276
reason,
263277
)
278+
264279
self._ws_connection_closed.set()
265280

266281
async def get_hap(

homeassistant/components/homematicip_cloud/manifest.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,5 +6,5 @@
66
"documentation": "https://www.home-assistant.io/integrations/homematicip_cloud",
77
"iot_class": "cloud_push",
88
"loggers": ["homematicip"],
9-
"requirements": ["homematicip==2.0.6"]
9+
"requirements": ["homematicip==2.0.7"]
1010
}

requirements_all.txt

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

requirements_test_all.txt

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

tests/components/homematicip_cloud/test_device.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -195,9 +195,14 @@ async def test_hap_reconnected(
195195
ha_state = hass.states.get(entity_id)
196196
assert ha_state.state == STATE_UNAVAILABLE
197197

198-
mock_hap._accesspoint_connected = False
199-
await async_manipulate_test_data(hass, mock_hap.home, "connected", True)
200-
await hass.async_block_till_done()
198+
with patch(
199+
"homeassistant.components.homematicip_cloud.hap.AsyncHome.websocket_is_connected",
200+
return_value=True,
201+
):
202+
await async_manipulate_test_data(hass, mock_hap.home, "connected", True)
203+
await mock_hap.ws_connected_handler()
204+
await hass.async_block_till_done()
205+
201206
ha_state = hass.states.get(entity_id)
202207
assert ha_state.state == STATE_ON
203208

tests/components/homematicip_cloud/test_hap.py

Lines changed: 57 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"""Test HomematicIP Cloud accesspoint."""
22

3-
from unittest.mock import AsyncMock, Mock, patch
3+
from unittest.mock import AsyncMock, MagicMock, Mock, patch
44

55
from homematicip.auth import Auth
66
from homematicip.connection.connection_context import ConnectionContext
@@ -242,16 +242,69 @@ async def test_get_state_after_disconnect(
242242
hap = HomematicipHAP(hass, hmip_config_entry)
243243
assert hap
244244

245-
with patch.object(hap, "get_state") as mock_get_state:
245+
simple_mock_home = AsyncMock(spec=AsyncHome, autospec=True)
246+
hap.home = simple_mock_home
247+
hap.home.websocket_is_connected = Mock(side_effect=[False, True])
248+
249+
with (
250+
patch("asyncio.sleep", new=AsyncMock()) as mock_sleep,
251+
patch.object(hap, "get_state") as mock_get_state,
252+
):
246253
assert not hap._ws_connection_closed.is_set()
247254

248255
await hap.ws_connected_handler()
249256
mock_get_state.assert_not_called()
250257

251258
await hap.ws_disconnected_handler()
252259
assert hap._ws_connection_closed.is_set()
253-
await hap.ws_connected_handler()
254-
mock_get_state.assert_called_once()
260+
with patch(
261+
"homeassistant.components.homematicip_cloud.hap.AsyncHome.websocket_is_connected",
262+
return_value=True,
263+
):
264+
await hap.ws_connected_handler()
265+
mock_get_state.assert_called_once()
266+
267+
assert not hap._ws_connection_closed.is_set()
268+
hap.home.websocket_is_connected.assert_called()
269+
mock_sleep.assert_awaited_with(2)
270+
271+
272+
async def test_try_get_state_exponential_backoff() -> None:
273+
"""Test _try_get_state waits for websocket connection."""
274+
275+
# Arrange: Create instance and mock home
276+
hap = HomematicipHAP(MagicMock(), MagicMock())
277+
hap.home = MagicMock()
278+
hap.home.websocket_is_connected = Mock(return_value=True)
279+
280+
hap.get_state = AsyncMock(
281+
side_effect=[HmipConnectionError, HmipConnectionError, True]
282+
)
283+
284+
with patch("asyncio.sleep", new=AsyncMock()) as mock_sleep:
285+
await hap._try_get_state()
286+
287+
assert mock_sleep.mock_calls[0].args[0] == 8
288+
assert mock_sleep.mock_calls[1].args[0] == 16
289+
assert hap.get_state.call_count == 3
290+
291+
292+
async def test_try_get_state_handle_exception() -> None:
293+
"""Test _try_get_state handles exceptions."""
294+
# Arrange: Create instance and mock home
295+
hap = HomematicipHAP(MagicMock(), MagicMock())
296+
hap.home = MagicMock()
297+
298+
expected_exception = Exception("Connection error")
299+
future = AsyncMock()
300+
future.result = Mock(side_effect=expected_exception)
301+
302+
with patch("homeassistant.components.homematicip_cloud.hap._LOGGER") as mock_logger:
303+
hap.get_state_finished(future)
304+
305+
mock_logger.error.assert_called_once_with(
306+
"Error updating state after HMIP access point reconnect: %s", expected_exception
307+
)
255308

256309

257310
async def test_async_connect(

0 commit comments

Comments
 (0)