diff --git a/homeassistant/components/switchbot_cloud/__init__.py b/homeassistant/components/switchbot_cloud/__init__.py index 1a697a2ed9a6..53cc147d7879 100644 --- a/homeassistant/components/switchbot_cloud/__init__.py +++ b/homeassistant/components/switchbot_cloud/__init__.py @@ -1,6 +1,6 @@ """SwitchBot via API integration.""" -from asyncio import gather +from asyncio import Lock, gather from collections.abc import Awaitable, Callable import contextlib from dataclasses import dataclass, field @@ -310,9 +310,13 @@ async def async_setup_entry( ) entry.runtime_data = SwitchbotCloudData(api=api, devices=switchbot_devices) - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + # One at a time, so the cloud connecting cannot register a webhook next to + # the one being registered here + webhook_lock = Lock() - await _initialize_webhook(hass, entry, api, coordinators_by_id) + async def _async_initialize_webhook() -> None: + async with webhook_lock: + await _initialize_webhook(hass, entry, api, coordinators_by_id) async def _handle_cloud_connection_change( state: cloud.CloudConnectionState, @@ -324,12 +328,20 @@ async def async_setup_entry( and re-register it with SwitchBot's cloud so push devices work. """ if state is cloud.CloudConnectionState.CLOUD_CONNECTED: - await _initialize_webhook(hass, entry, api, coordinators_by_id) + await _async_initialize_webhook() + # Listening before the first attempt, so a cloud that connects while the + # entry is still setting up is not missed entry.async_on_unload( cloud.async_listen_connection_change(hass, _handle_cloud_connection_change) ) + await _async_initialize_webhook() + + # Forwarded last, so a failure above cannot leave the platforms set up for a + # retry to set up a second time + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True @@ -445,12 +457,16 @@ async def _async_get_webhook_url( return str(entry.data[CONF_CLOUDHOOK_URL]) if cloud.async_is_connected(hass): webhook_id = entry.data[CONF_WEBHOOK_ID] - cloudhook_url = await cloud.async_get_or_create_cloudhook(hass, webhook_id) - hass.config_entries.async_update_entry( - entry, data={**entry.data, CONF_CLOUDHOOK_URL: cloudhook_url} - ) - _LOGGER.debug("Created SwitchBot Cloud cloudhook: %s", cloudhook_url) - return cloudhook_url + # The cloud can go away between being asked and being used + with contextlib.suppress(cloud.CloudNotAvailable): + cloudhook_url = await cloud.async_get_or_create_cloudhook( + hass, webhook_id + ) + hass.config_entries.async_update_entry( + entry, data={**entry.data, CONF_CLOUDHOOK_URL: cloudhook_url} + ) + _LOGGER.debug("Created SwitchBot Cloud cloudhook: %s", cloudhook_url) + return cloudhook_url return webhook.async_generate_url( hass, diff --git a/tests/components/switchbot_cloud/test_init.py b/tests/components/switchbot_cloud/test_init.py index 8667bc51a61d..a20492c33656 100644 --- a/tests/components/switchbot_cloud/test_init.py +++ b/tests/components/switchbot_cloud/test_init.py @@ -425,6 +425,54 @@ async def test_setup_creates_cloudhook_when_cloud_active( mock_setup_webhook.assert_called_once_with(CLOUDHOOK_URL) +async def test_setup_survives_the_cloud_going_away( + hass: HomeAssistant, + mock_list_devices: AsyncMock, + mock_get_status: AsyncMock, + mock_get_webook_configuration: AsyncMock, + mock_delete_webhook: AsyncMock, + mock_setup_webhook: AsyncMock, +) -> None: + """Test the entry still loads when the cloud goes away mid-setup. + + The connection is checked before the cloudhook is created, so it can be + gone by the time it is used. The local URL carries it until the + connection change listener creates the cloudhook. + """ + await async_process_ha_core_config( + hass, + {"external_url": "https://example.com"}, + ) + await mock_cloud(hass) + await hass.async_block_till_done() + + mock_get_webook_configuration.return_value = {"urls": []} + mock_list_devices.return_value = [_water_detector()] + mock_get_status.return_value = {"battery": 100} + mock_delete_webhook.return_value = {} + mock_setup_webhook.return_value = {} + + with ( + patch("homeassistant.components.cloud.async_is_logged_in", return_value=True), + patch("homeassistant.components.cloud.async_is_connected", return_value=True), + patch.object(cloud, "async_active_subscription", return_value=True), + patch( + "homeassistant.components.cloud.async_get_or_create_cloudhook", + side_effect=CloudNotAvailable, + ), + patch("homeassistant.components.cloud.async_delete_cloudhook"), + ): + entry = await configure_integration(hass) + + assert entry.state is ConfigEntryState.LOADED + assert CONF_CLOUDHOOK_URL not in entry.data + # SwitchBot was given the local URL to push to in the meantime + mock_setup_webhook.assert_called_once() + assert mock_setup_webhook.call_args[0][0].startswith( + "https://example.com/api/webhook/" + ) + + async def test_setup_reuses_persisted_cloudhook( hass: HomeAssistant, mock_list_devices,