Handle connection issues after websocket reconnected in homematicip_cloud (#147731)

This commit is contained in:
hahn-th 2025-07-15 12:15:19 +02:00 committed by GitHub
parent b522bd5ef2
commit 1cb278966c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 107 additions and 34 deletions

View File

@ -113,9 +113,7 @@ class HomematicipHAP:
self._ws_close_requested = False self._ws_close_requested = False
self._ws_connection_closed = asyncio.Event() self._ws_connection_closed = asyncio.Event()
self._retry_task: asyncio.Task | None = None self._get_state_task: asyncio.Task | None = None
self._tries = 0
self._accesspoint_connected = True
self.hmip_device_by_entity_id: dict[str, Any] = {} self.hmip_device_by_entity_id: dict[str, Any] = {}
self.reset_connection_listener: Callable | None = None self.reset_connection_listener: Callable | None = None
@ -161,17 +159,8 @@ class HomematicipHAP:
""" """
if not self.home.connected: if not self.home.connected:
_LOGGER.error("HMIP access point has lost connection with the cloud") _LOGGER.error("HMIP access point has lost connection with the cloud")
self._accesspoint_connected = False self._ws_connection_closed.set()
self.set_all_to_unavailable() self.set_all_to_unavailable()
elif not self._accesspoint_connected:
# Now the HOME_CHANGED event has fired indicating the access
# point has reconnected to the cloud again.
# Explicitly getting an update as entity states might have
# changed during access point disconnect."""
job = self.hass.async_create_task(self.get_state())
job.add_done_callback(self.get_state_finished)
self._accesspoint_connected = True
@callback @callback
def async_create_entity(self, *args, **kwargs) -> None: def async_create_entity(self, *args, **kwargs) -> None:
@ -185,20 +174,43 @@ class HomematicipHAP:
await asyncio.sleep(30) await asyncio.sleep(30)
await self.hass.config_entries.async_reload(self.config_entry.entry_id) await self.hass.config_entries.async_reload(self.config_entry.entry_id)
async def _try_get_state(self) -> None:
"""Call get_state in a loop until no error occurs, using exponential backoff on error."""
# Wait until WebSocket connection is established.
while not self.home.websocket_is_connected():
await asyncio.sleep(2)
delay = 8
max_delay = 1500
while True:
try:
await self.get_state()
break
except HmipConnectionError as err:
_LOGGER.warning(
"Get_state failed, retrying in %s seconds: %s", delay, err
)
await asyncio.sleep(delay)
delay = min(delay * 2, max_delay)
async def get_state(self) -> None: async def get_state(self) -> None:
"""Update HMIP state and tell Home Assistant.""" """Update HMIP state and tell Home Assistant."""
await self.home.get_current_state_async() await self.home.get_current_state_async()
self.update_all() self.update_all()
def get_state_finished(self, future) -> None: def get_state_finished(self, future) -> None:
"""Execute when get_state coroutine has finished.""" """Execute when try_get_state coroutine has finished."""
try: try:
future.result() future.result()
except HmipConnectionError: except Exception as err: # noqa: BLE001
# Somehow connection could not recover. Will disconnect and _LOGGER.error(
# so reconnect loop is taking over. "Error updating state after HMIP access point reconnect: %s", err
_LOGGER.error("Updating state after HMIP access point reconnect failed") )
self.hass.async_create_task(self.home.disable_events()) else:
_LOGGER.info(
"Updating state after HMIP access point reconnect finished successfully",
)
def set_all_to_unavailable(self) -> None: def set_all_to_unavailable(self) -> None:
"""Set all devices to unavailable and tell Home Assistant.""" """Set all devices to unavailable and tell Home Assistant."""
@ -222,8 +234,8 @@ class HomematicipHAP:
async def async_reset(self) -> bool: async def async_reset(self) -> bool:
"""Close the websocket connection.""" """Close the websocket connection."""
self._ws_close_requested = True self._ws_close_requested = True
if self._retry_task is not None: if self._get_state_task is not None:
self._retry_task.cancel() self._get_state_task.cancel()
await self.home.disable_events_async() await self.home.disable_events_async()
_LOGGER.debug("Closed connection to HomematicIP cloud server") _LOGGER.debug("Closed connection to HomematicIP cloud server")
await self.hass.config_entries.async_unload_platforms( await self.hass.config_entries.async_unload_platforms(
@ -247,7 +259,9 @@ class HomematicipHAP:
"""Handle websocket connected.""" """Handle websocket connected."""
_LOGGER.info("Websocket connection to HomematicIP Cloud established") _LOGGER.info("Websocket connection to HomematicIP Cloud established")
if self._ws_connection_closed.is_set(): if self._ws_connection_closed.is_set():
await self.get_state() self._get_state_task = self.hass.async_create_task(self._try_get_state())
self._get_state_task.add_done_callback(self.get_state_finished)
self._ws_connection_closed.clear() self._ws_connection_closed.clear()
async def ws_disconnected_handler(self) -> None: async def ws_disconnected_handler(self) -> None:
@ -256,11 +270,12 @@ class HomematicipHAP:
self._ws_connection_closed.set() self._ws_connection_closed.set()
async def ws_reconnected_handler(self, reason: str) -> None: async def ws_reconnected_handler(self, reason: str) -> None:
"""Handle websocket reconnection.""" """Handle websocket reconnection. Is called when Websocket tries to reconnect."""
_LOGGER.info( _LOGGER.info(
"Websocket connection to HomematicIP Cloud re-established due to reason: %s", "Websocket connection to HomematicIP Cloud trying to reconnect due to reason: %s",
reason, reason,
) )
self._ws_connection_closed.set() self._ws_connection_closed.set()
async def get_hap( async def get_hap(

View File

@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/homematicip_cloud", "documentation": "https://www.home-assistant.io/integrations/homematicip_cloud",
"iot_class": "cloud_push", "iot_class": "cloud_push",
"loggers": ["homematicip"], "loggers": ["homematicip"],
"requirements": ["homematicip==2.0.6"] "requirements": ["homematicip==2.0.7"]
} }

2
requirements_all.txt generated
View File

@ -1174,7 +1174,7 @@ home-assistant-frontend==20250702.2
home-assistant-intents==2025.6.23 home-assistant-intents==2025.6.23
# homeassistant.components.homematicip_cloud # homeassistant.components.homematicip_cloud
homematicip==2.0.6 homematicip==2.0.7
# homeassistant.components.horizon # homeassistant.components.horizon
horimote==0.4.1 horimote==0.4.1

View File

@ -1023,7 +1023,7 @@ home-assistant-frontend==20250702.2
home-assistant-intents==2025.6.23 home-assistant-intents==2025.6.23
# homeassistant.components.homematicip_cloud # homeassistant.components.homematicip_cloud
homematicip==2.0.6 homematicip==2.0.7
# homeassistant.components.remember_the_milk # homeassistant.components.remember_the_milk
httplib2==0.20.4 httplib2==0.20.4

View File

@ -195,9 +195,14 @@ async def test_hap_reconnected(
ha_state = hass.states.get(entity_id) ha_state = hass.states.get(entity_id)
assert ha_state.state == STATE_UNAVAILABLE assert ha_state.state == STATE_UNAVAILABLE
mock_hap._accesspoint_connected = False with patch(
await async_manipulate_test_data(hass, mock_hap.home, "connected", True) "homeassistant.components.homematicip_cloud.hap.AsyncHome.websocket_is_connected",
await hass.async_block_till_done() return_value=True,
):
await async_manipulate_test_data(hass, mock_hap.home, "connected", True)
await mock_hap.ws_connected_handler()
await hass.async_block_till_done()
ha_state = hass.states.get(entity_id) ha_state = hass.states.get(entity_id)
assert ha_state.state == STATE_ON assert ha_state.state == STATE_ON

View File

@ -1,6 +1,6 @@
"""Test HomematicIP Cloud accesspoint.""" """Test HomematicIP Cloud accesspoint."""
from unittest.mock import AsyncMock, Mock, patch from unittest.mock import AsyncMock, MagicMock, Mock, patch
from homematicip.auth import Auth from homematicip.auth import Auth
from homematicip.connection.connection_context import ConnectionContext from homematicip.connection.connection_context import ConnectionContext
@ -242,7 +242,14 @@ async def test_get_state_after_disconnect(
hap = HomematicipHAP(hass, hmip_config_entry) hap = HomematicipHAP(hass, hmip_config_entry)
assert hap assert hap
with patch.object(hap, "get_state") as mock_get_state: simple_mock_home = AsyncMock(spec=AsyncHome, autospec=True)
hap.home = simple_mock_home
hap.home.websocket_is_connected = Mock(side_effect=[False, True])
with (
patch("asyncio.sleep", new=AsyncMock()) as mock_sleep,
patch.object(hap, "get_state") as mock_get_state,
):
assert not hap._ws_connection_closed.is_set() assert not hap._ws_connection_closed.is_set()
await hap.ws_connected_handler() await hap.ws_connected_handler()
@ -250,8 +257,54 @@ async def test_get_state_after_disconnect(
await hap.ws_disconnected_handler() await hap.ws_disconnected_handler()
assert hap._ws_connection_closed.is_set() assert hap._ws_connection_closed.is_set()
await hap.ws_connected_handler() with patch(
mock_get_state.assert_called_once() "homeassistant.components.homematicip_cloud.hap.AsyncHome.websocket_is_connected",
return_value=True,
):
await hap.ws_connected_handler()
mock_get_state.assert_called_once()
assert not hap._ws_connection_closed.is_set()
hap.home.websocket_is_connected.assert_called()
mock_sleep.assert_awaited_with(2)
async def test_try_get_state_exponential_backoff() -> None:
"""Test _try_get_state waits for websocket connection."""
# Arrange: Create instance and mock home
hap = HomematicipHAP(MagicMock(), MagicMock())
hap.home = MagicMock()
hap.home.websocket_is_connected = Mock(return_value=True)
hap.get_state = AsyncMock(
side_effect=[HmipConnectionError, HmipConnectionError, True]
)
with patch("asyncio.sleep", new=AsyncMock()) as mock_sleep:
await hap._try_get_state()
assert mock_sleep.mock_calls[0].args[0] == 8
assert mock_sleep.mock_calls[1].args[0] == 16
assert hap.get_state.call_count == 3
async def test_try_get_state_handle_exception() -> None:
"""Test _try_get_state handles exceptions."""
# Arrange: Create instance and mock home
hap = HomematicipHAP(MagicMock(), MagicMock())
hap.home = MagicMock()
expected_exception = Exception("Connection error")
future = AsyncMock()
future.result = Mock(side_effect=expected_exception)
with patch("homeassistant.components.homematicip_cloud.hap._LOGGER") as mock_logger:
hap.get_state_finished(future)
mock_logger.error.assert_called_once_with(
"Error updating state after HMIP access point reconnect: %s", expected_exception
)
async def test_async_connect( async def test_async_connect(