mirror of
https://github.com/home-assistant/core.git
synced 2025-07-28 15:47:12 +00:00
Handle connection issues after websocket reconnected in homematicip_cloud (#147731)
This commit is contained in:
parent
b522bd5ef2
commit
1cb278966c
@ -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(
|
||||||
|
@ -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
2
requirements_all.txt
generated
@ -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
|
||||||
|
2
requirements_test_all.txt
generated
2
requirements_test_all.txt
generated
@ -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
|
||||||
|
@ -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
|
||||||
|
|
||||||
|
@ -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(
|
||||||
|
Loading…
x
Reference in New Issue
Block a user