"""The tests for Netatmo component.""" from collections.abc import Callable, Coroutine, Iterator from datetime import timedelta from functools import partial from itertools import pairwise from time import time from typing import Any from unittest.mock import AsyncMock, patch import aiohttp from freezegun.api import FrozenDateTimeFactory import pyatmo from pyatmo.const import ALL_SCOPES import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.components import cloud, webhook from homeassistant.components.netatmo import DOMAIN, coordinator from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ( CONF_WEBHOOK_ID, EVENT_STATE_CHANGED, STATE_ON, STATE_UNAVAILABLE, STATE_UNKNOWN, Platform, ) from homeassistant.core import CoreState, HomeAssistant from homeassistant.exceptions import ( OAuth2TokenRequestReauthError, ServiceValidationError, ) from homeassistant.helpers import ( device_registry as dr, entity_registry as er, issue_registry as ir, ) from homeassistant.helpers.config_entry_oauth2_flow import ( ImplementationUnavailableError, ) from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util from .common import ( FAKE_WEBHOOK_ACTIVATION, HOME_ID, fake_post_request, selected_platforms, simulate_webhook, ) from tests.common import MockConfigEntry, async_capture_events, async_fire_time_changed from tests.components.cloud import mock_cloud from tests.typing import WebSocketGenerator # Fake webhook thermostat mode change to "Max" FAKE_WEBHOOK = { "room_id": "2746182631", "home": { "id": HOME_ID, "name": "MYHOME", "country": "DE", "rooms": [ { "id": "2746182631", "name": "Livingroom", "type": "livingroom", "therm_setpoint_mode": "max", "therm_setpoint_end_time": 1612749189, } ], "modules": [ {"id": "12:34:56:00:01:ae", "name": "Livingroom", "type": "NATherm1"} ], }, "mode": "max", "event_type": "set_point", "push_type": "display_change", } SWITCH_ENTITY_ID = "switch.prise" # The switch's home is polled every 150s with the cloud credentials the test # config entry uses HOME_POLL_INTERVAL = 150 # Scheduled updates to drive, enough for the longest failure script to run # through the escalating retry backoff SCHEDULED_UPDATES = 80 async def test_setup_component( hass: HomeAssistant, config_entry: MockConfigEntry ) -> None: """Test setup and teardown of the netatmo component.""" with ( patch( "homeassistant.components.netatmo.api.AsyncConfigEntryNetatmoAuth", ) as mock_auth, patch( "homeassistant.components.netatmo.async_get_config_entry_implementation", ) as mock_impl, patch( "homeassistant.components.netatmo.webhook.webhook_generate_url" ) as mock_webhook, ): mock_auth.return_value.async_post_api_request.side_effect = partial( fake_post_request, hass ) mock_auth.return_value.async_addwebhook.side_effect = AsyncMock() mock_auth.return_value.async_dropwebhook.side_effect = AsyncMock() assert await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() mock_auth.assert_called_once() mock_impl.assert_called_once() mock_webhook.assert_called_once() assert config_entry.state is ConfigEntryState.LOADED assert hass.config_entries.async_entries(DOMAIN) assert len(hass.states.async_all()) > 0 for entry in hass.config_entries.async_entries("netatmo"): await hass.config_entries.async_remove(entry.entry_id) await hass.async_block_till_done() assert len(hass.states.async_all()) == 0 assert not hass.config_entries.async_entries(DOMAIN) async def test_setup_component_with_config( hass: HomeAssistant, config_entry: MockConfigEntry ) -> None: """Test setup of the netatmo component with dev account.""" fake_post_hits = 0 async def fake_post(*args: Any, **kwargs: Any): """Fake error during requesting backend data.""" nonlocal fake_post_hits fake_post_hits += 1 return await fake_post_request(hass, *args, **kwargs) with ( patch( "homeassistant.components.netatmo.async_get_config_entry_implementation", ) as mock_impl, patch( "homeassistant.components.netatmo.webhook.webhook_generate_url" ) as mock_webhook, patch( "homeassistant.components.netatmo.api.AsyncConfigEntryNetatmoAuth", ) as mock_auth, patch("homeassistant.components.netatmo.coordinator.PLATFORMS", ["sensor"]), ): mock_auth.return_value.async_post_api_request.side_effect = fake_post mock_auth.return_value.async_addwebhook.side_effect = AsyncMock() mock_auth.return_value.async_dropwebhook.side_effect = AsyncMock() assert await async_setup_component( hass, DOMAIN, {"netatmo": {"client_id": "123", "client_secret": "abc"}} ) await hass.async_block_till_done() assert fake_post_hits >= 8 mock_impl.assert_called_once() mock_webhook.assert_called_once() assert hass.config_entries.async_entries(DOMAIN) assert len(hass.states.async_all()) > 0 async def test_setup_component_with_webhook( hass: HomeAssistant, config_entry, netatmo_auth ) -> None: """Test setup and teardown of the netatmo component with webhook registration.""" with selected_platforms( [Platform.CAMERA, Platform.CLIMATE, Platform.LIGHT, Platform.SENSOR] ): assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() webhook_id = config_entry.data[CONF_WEBHOOK_ID] await simulate_webhook(hass, webhook_id, FAKE_WEBHOOK_ACTIVATION) assert len(hass.states.async_all()) > 0 webhook_id = config_entry.data[CONF_WEBHOOK_ID] await simulate_webhook(hass, webhook_id, FAKE_WEBHOOK_ACTIVATION) # Assert webhook is established successfully climate_entity_livingroom = "climate.livingroom_livingroom" assert hass.states.get(climate_entity_livingroom).state == "auto" await simulate_webhook(hass, webhook_id, FAKE_WEBHOOK) assert hass.states.get(climate_entity_livingroom).state == "heat" for entry in hass.config_entries.async_entries("netatmo"): await hass.config_entries.async_remove(entry.entry_id) await hass.async_block_till_done() assert len(hass.states.async_all()) == 0 assert len(hass.config_entries.async_entries(DOMAIN)) == 0 async def test_no_deprecation_issue_on_setup( hass: HomeAssistant, config_entry: MockConfigEntry, netatmo_auth: AsyncMock, issue_registry: ir.IssueRegistry, ) -> None: """Test the automatic webhook lifecycle does not raise the deprecation issue.""" with selected_platforms([Platform.CLIMATE]): assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() assert not issue_registry.async_get_issue( DOMAIN, "deprecated_service_register_webhook" ) assert not issue_registry.async_get_issue( DOMAIN, "deprecated_service_unregister_webhook" ) @pytest.mark.parametrize( ("service", "expected_registered"), [ pytest.param("register_webhook", True, id="register"), pytest.param("unregister_webhook", False, id="unregister"), ], ) async def test_deprecated_webhook_service( hass: HomeAssistant, config_entry: MockConfigEntry, netatmo_auth: AsyncMock, issue_registry: ir.IssueRegistry, service: str, expected_registered: bool, ) -> None: """Test the deprecated webhook actions still work and raise a repair issue.""" with selected_platforms([Platform.CLIMATE]): assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() webhook_id = config_entry.data[CONF_WEBHOOK_ID] assert webhook_id in hass.data[webhook.DOMAIN] # register_webhook re-registers the already-active webhook without # raising; unregister_webhook tears it down. await hass.services.async_call(DOMAIN, service, blocking=True) assert (webhook_id in hass.data[webhook.DOMAIN]) is expected_registered assert issue_registry.async_get_issue(DOMAIN, f"deprecated_service_{service}") @pytest.mark.parametrize("service", ["register_webhook", "unregister_webhook"]) async def test_deprecated_webhook_service_not_loaded( hass: HomeAssistant, service: str, ) -> None: """Test calling a webhook action without a loaded entry raises.""" await async_setup_component(hass, DOMAIN, {}) with pytest.raises(ServiceValidationError): await hass.services.async_call(DOMAIN, service, blocking=True) async def test_setup_without_https( hass: HomeAssistant, config_entry: MockConfigEntry, caplog: pytest.LogCaptureFixture ) -> None: """Test if set up with cloud link and without https.""" hass.config.components.add("cloud") with ( patch( "homeassistant.helpers.network.get_url", return_value="http://example.nabu.casa", ), patch( "homeassistant.components.netatmo.api.AsyncConfigEntryNetatmoAuth" ) as mock_auth, patch( "homeassistant.components.netatmo.async_get_config_entry_implementation", ), patch( "homeassistant.components.netatmo.webhook.webhook_generate_url" ) as mock_async_generate_url, ): mock_auth.return_value.async_post_api_request.side_effect = partial( fake_post_request, hass ) mock_async_generate_url.return_value = "http://example.com" assert await async_setup_component( hass, DOMAIN, {"netatmo": {"client_id": "123", "client_secret": "abc"}} ) await hass.async_block_till_done() mock_auth.assert_called_once() mock_async_generate_url.assert_called_once() assert "https and port 443 is required to register the webhook" in caplog.text async def test_setup_with_cloud( hass: HomeAssistant, config_entry: MockConfigEntry ) -> None: """Test if set up with active cloud subscription.""" await mock_cloud(hass) await hass.async_block_till_done() with ( patch("homeassistant.components.cloud.async_is_logged_in", return_value=True), patch.object(cloud, "async_is_connected", return_value=True), patch.object(cloud, "async_active_subscription", return_value=True), patch( "homeassistant.components.cloud.async_create_cloudhook", return_value="https://hooks.nabu.casa/ABCD", ) as fake_create_cloudhook, patch( "homeassistant.components.cloud.async_delete_cloudhook" ) as fake_delete_cloudhook, patch( "homeassistant.components.netatmo.api.AsyncConfigEntryNetatmoAuth" ) as mock_auth, patch("homeassistant.components.netatmo.coordinator.PLATFORMS", []), patch( "homeassistant.components.netatmo.async_get_config_entry_implementation", ), patch( "homeassistant.components.netatmo.webhook.webhook_generate_url", ), ): mock_auth.return_value.async_post_api_request.side_effect = partial( fake_post_request, hass ) assert await async_setup_component( hass, DOMAIN, {"netatmo": {"client_id": "123", "client_secret": "abc"}} ) assert cloud.async_active_subscription(hass) is True assert cloud.async_is_connected(hass) is True fake_create_cloudhook.assert_called_once() assert ( hass.config_entries.async_entries("netatmo")[0].data["cloudhook_url"] == "https://hooks.nabu.casa/ABCD" ) await hass.async_block_till_done() assert hass.config_entries.async_entries(DOMAIN) for entry in hass.config_entries.async_entries("netatmo"): await hass.config_entries.async_remove(entry.entry_id) fake_delete_cloudhook.assert_called_once() await hass.async_block_till_done() assert not hass.config_entries.async_entries(DOMAIN) async def test_setup_with_cloudhook(hass: HomeAssistant) -> None: """Test if set up with active cloud subscription and cloud hook.""" config_entry = MockConfigEntry( domain="netatmo", data={ "auth_implementation": "cloud", "cloudhook_url": "https://hooks.nabu.casa/ABCD", "token": { "refresh_token": "mock-refresh-token", "access_token": "mock-access-token", "type": "Bearer", "expires_in": 60, "expires_at": time() + 1000, "scope": ALL_SCOPES, }, }, ) config_entry.add_to_hass(hass) await mock_cloud(hass) await hass.async_block_till_done() 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_create_cloudhook", return_value="https://hooks.nabu.casa/ABCD", ) as fake_create_cloudhook, patch( "homeassistant.components.cloud.async_delete_cloudhook" ) as fake_delete_cloudhook, patch( "homeassistant.components.netatmo.api.AsyncConfigEntryNetatmoAuth" ) as mock_auth, patch("homeassistant.components.netatmo.coordinator.PLATFORMS", []), patch( "homeassistant.components.netatmo.async_get_config_entry_implementation", ), patch( "homeassistant.components.netatmo.webhook.webhook_generate_url", ), ): mock_auth.return_value.async_post_api_request.side_effect = partial( fake_post_request, hass ) mock_auth.return_value.async_addwebhook.side_effect = AsyncMock() mock_auth.return_value.async_dropwebhook.side_effect = AsyncMock() assert await async_setup_component(hass, DOMAIN, {}) assert cloud.async_active_subscription(hass) is True assert ( hass.config_entries.async_entries("netatmo")[0].data["cloudhook_url"] == "https://hooks.nabu.casa/ABCD" ) await hass.async_block_till_done() assert hass.config_entries.async_entries(DOMAIN) fake_create_cloudhook.assert_not_called() for config_entry in hass.config_entries.async_entries("netatmo"): await hass.config_entries.async_remove(config_entry.entry_id) fake_delete_cloudhook.assert_called_once() await hass.async_block_till_done() assert not hass.config_entries.async_entries(DOMAIN) async def test_setup_component_with_delay( hass: HomeAssistant, config_entry: MockConfigEntry ) -> None: """Test setup of the netatmo component with delayed startup.""" hass.set_state(CoreState.not_running) with ( patch( "pyatmo.AbstractAsyncAuth.async_addwebhook", side_effect=AsyncMock() ) as mock_addwebhook, patch( "pyatmo.AbstractAsyncAuth.async_dropwebhook", side_effect=AsyncMock() ) as mock_dropwebhook, patch( "homeassistant.components.netatmo.async_get_config_entry_implementation", ) as mock_impl, patch( "homeassistant.components.netatmo.webhook.webhook_generate_url" ) as mock_webhook, patch( "pyatmo.AbstractAsyncAuth.async_post_api_request", side_effect=partial(fake_post_request, hass), ) as mock_post_api_request, patch("homeassistant.components.netatmo.coordinator.PLATFORMS", ["light"]), ): assert await async_setup_component( hass, DOMAIN, {"netatmo": {"client_id": "123", "client_secret": "abc"}} ) await hass.async_block_till_done() assert mock_post_api_request.call_count == 7 mock_impl.assert_called_once() mock_webhook.assert_not_called() await hass.async_start() await hass.async_block_till_done() mock_webhook.assert_called_once() # Fake webhook activation await simulate_webhook( hass, config_entry.data[CONF_WEBHOOK_ID], FAKE_WEBHOOK_ACTIVATION ) await hass.async_block_till_done() mock_addwebhook.assert_called_once() mock_dropwebhook.assert_not_awaited() async_fire_time_changed( hass, dt_util.utcnow() + timedelta(seconds=60), ) await hass.async_block_till_done() assert hass.config_entries.async_entries(DOMAIN) assert len(hass.states.async_all()) > 0 await hass.async_stop() mock_dropwebhook.assert_called_once() async def test_setup_component_invalid_token_scope(hass: HomeAssistant) -> None: """Test handling of invalid token scope.""" config_entry = MockConfigEntry( domain="netatmo", data={ "auth_implementation": "cloud", "token": { "refresh_token": "mock-refresh-token", "access_token": "mock-access-token", "type": "Bearer", "expires_in": 60, "expires_at": time() + 1000, "scope": "read_smokedetector read_thermostat write_thermostat", }, }, options={}, ) config_entry.add_to_hass(hass) with ( patch( "homeassistant.components.netatmo.api.AsyncConfigEntryNetatmoAuth", ) as mock_auth, patch( "homeassistant.components.netatmo.async_get_config_entry_implementation", ) as mock_impl, patch( "homeassistant.components.netatmo.webhook.webhook_generate_url" ) as mock_webhook, ): mock_auth.return_value.async_post_api_request.side_effect = partial( fake_post_request, hass ) mock_auth.return_value.async_addwebhook.side_effect = AsyncMock() mock_auth.return_value.async_dropwebhook.side_effect = AsyncMock() assert await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() mock_auth.assert_not_called() mock_impl.assert_called_once() mock_webhook.assert_not_called() assert config_entry.state is ConfigEntryState.SETUP_ERROR assert hass.config_entries.async_entries(DOMAIN) # Test a reauth flow is initiated assert len(list(config_entry.async_get_active_flows(hass, {"reauth"}))) == 1 for config_entry in hass.config_entries.async_entries("netatmo"): await hass.config_entries.async_remove(config_entry.entry_id) async def test_setup_component_invalid_token( hass: HomeAssistant, config_entry: MockConfigEntry ) -> None: """Test handling of invalid token.""" async def fake_ensure_valid_token(*args, **kwargs): raise OAuth2TokenRequestReauthError( request_info=aiohttp.client.RequestInfo( url="http://example.com", method="GET", headers={}, real_url="http://example.com", ), status=400, history=(), domain="netatmo", ) with ( patch( "homeassistant.components.netatmo.api.AsyncConfigEntryNetatmoAuth", ) as mock_auth, patch( "homeassistant.components.netatmo.async_get_config_entry_implementation", ) as mock_impl, patch( "homeassistant.components.netatmo.webhook.webhook_generate_url" ) as mock_webhook, patch("homeassistant.components.netatmo.OAuth2Session") as mock_session, ): mock_auth.return_value.async_post_api_request.side_effect = partial( fake_post_request, hass ) mock_auth.return_value.async_addwebhook.side_effect = AsyncMock() mock_auth.return_value.async_dropwebhook.side_effect = AsyncMock() mock_session.return_value.async_ensure_token_valid.side_effect = ( fake_ensure_valid_token ) assert await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() mock_auth.assert_not_called() mock_impl.assert_called_once() mock_webhook.assert_not_called() assert config_entry.state is ConfigEntryState.SETUP_ERROR assert hass.config_entries.async_entries(DOMAIN) # Test a reauth flow is initiated assert len(list(config_entry.async_get_active_flows(hass, {"reauth"}))) == 1 for entry in hass.config_entries.async_entries("netatmo"): await hass.config_entries.async_remove(entry.entry_id) async def test_devices( hass: HomeAssistant, device_registry: dr.DeviceRegistry, config_entry: MockConfigEntry, snapshot: SnapshotAssertion, netatmo_auth: AsyncMock, ) -> None: """Test devices are registered.""" with selected_platforms( [ Platform.CAMERA, Platform.CLIMATE, Platform.COVER, Platform.LIGHT, Platform.SELECT, Platform.SENSOR, Platform.SWITCH, ] ): assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() device_entries = dr.async_entries_for_config_entry( device_registry, config_entry.entry_id ) assert device_entries for device_entry in device_entries: identifier = list(device_entry.identifiers)[0] assert device_entry == snapshot(name=f"{identifier[0]}-{identifier[1]}") async def test_device_remove_devices( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, config_entry: MockConfigEntry, netatmo_auth: AsyncMock, ) -> None: """Test we can only remove a device that no longer exists.""" assert await async_setup_component(hass, "config", {}) with selected_platforms([Platform.CLIMATE]): assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() climate_entity_livingroom = "climate.livingroom_livingroom" entity = entity_registry.async_get(climate_entity_livingroom) device_entry = device_registry.async_get(entity.device_id) client = await hass_ws_client(hass) response = await client.remove_device(device_entry.id, config_entry.entry_id) assert not response["success"] dead_device_entry = device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, identifiers={(DOMAIN, "remove-device-id")}, ) response = await client.remove_device(dead_device_entry.id, config_entry.entry_id) assert response["success"] async def test_oauth_implementation_not_available( hass: HomeAssistant, config_entry: MockConfigEntry ) -> None: """Test that unavailable OAuth implementation raises ConfigEntryNotReady.""" config_entry.add_to_hass(hass) with patch( "homeassistant.components.netatmo.async_get_config_entry_implementation", side_effect=ImplementationUnavailableError, ): await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.SETUP_RETRY @pytest.mark.usefixtures("entity_registry_enabled_by_default") @pytest.mark.parametrize( ("platform", "entity_id", "module_id", "initial_state"), [ pytest.param( "switch", "switch.prise", "12:34:56:80:00:12:ac:f2", "on", id="switch" ), pytest.param( "cover", "cover.entrance_blinds", "0009999992", "closed", id="cover" ), pytest.param( "fan", "fan.centralized_ventilation_controler", "12:34:56:00:01:01:01:b1", "on", id="fan", ), pytest.param( "light", "light.unknown_00_11_22_33_00_11_45_fe", "00:11:22:33:00:11:45:fe", "off", id="light", ), pytest.param( "button", "button.entrance_blinds_preferred_position", "0009999992", STATE_UNKNOWN, id="button", ), ], ) async def test_entity_unavailable_when_device_unreachable( hass: HomeAssistant, config_entry: MockConfigEntry, freezer: FrozenDateTimeFactory, platform: str, entity_id: str, module_id: str, initial_state: str, ) -> None: """Test that entities become unavailable when their device is unreachable.""" reachable = True def set_reachable(payload: dict) -> None: home = payload.get("body", {}).get("home") if not isinstance(home, dict): return for module in home.get("modules", []): if module.get("id") == module_id: module["reachable"] = reachable async def fake_post(*args: Any, **kwargs: Any): return await fake_post_request( hass, *args, msg_callback=set_reachable, **kwargs ) with ( patch( "homeassistant.components.netatmo.api.AsyncConfigEntryNetatmoAuth" ) as mock_auth, patch("homeassistant.components.netatmo.coordinator.PLATFORMS", [platform]), patch( "homeassistant.components.netatmo.async_get_config_entry_implementation", return_value=AsyncMock(), ), patch("homeassistant.components.netatmo.webhook.webhook_generate_url"), ): mock_auth.return_value.async_post_api_request.side_effect = fake_post mock_auth.return_value.async_addwebhook.side_effect = AsyncMock() mock_auth.return_value.async_dropwebhook.side_effect = AsyncMock() assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() assert hass.states.get(entity_id).state == initial_state reachable = False for _ in range(11): freezer.tick(timedelta(seconds=30)) async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) assert hass.states.get(entity_id).state == STATE_UNAVAILABLE async def _setup_switch_platform( hass: HomeAssistant, config_entry: MockConfigEntry, fake_post: Callable[..., Coroutine[Any, Any, Any]], ) -> None: """Set up the switch platform with a custom API request side effect.""" with ( patch( "homeassistant.components.netatmo.api.AsyncConfigEntryNetatmoAuth" ) as mock_auth, patch( "homeassistant.components.netatmo.coordinator.PLATFORMS", [Platform.SWITCH] ), patch( "homeassistant.components.netatmo.async_get_config_entry_implementation", return_value=AsyncMock(), ), patch("homeassistant.components.netatmo.webhook.webhook_generate_url"), ): mock_auth.return_value.async_post_api_request.side_effect = fake_post mock_auth.return_value.async_addwebhook.side_effect = AsyncMock() mock_auth.return_value.async_dropwebhook.side_effect = AsyncMock() assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @pytest.mark.parametrize( "failure_script", [ pytest.param((True,), id="single_error"), pytest.param((True, True), id="errors_within_tolerance"), pytest.param( (True, True, False, True, True), id="error_count_reset_by_success" ), ], ) async def test_entity_stays_available_through_tolerated_errors( hass: HomeAssistant, config_entry: MockConfigEntry, freezer: FrozenDateTimeFactory, failure_script: tuple[bool, ...], ) -> None: """Test that entities do not flicker when up to two updates in a row fail.""" # Scripted per home status request of the switch's home, so that the number # of consecutive errors does not depend on when the updates happen to run script: Iterator[bool] = iter(()) failures = 0 async def fake_post(*args: Any, **kwargs: Any): nonlocal failures if ( kwargs.get("endpoint", "").endswith("homestatus") and kwargs.get("params", {}).get("home_id") == HOME_ID and next(script, False) ): failures += 1 raise TimeoutError return await fake_post_request(hass, *args, **kwargs) await _setup_switch_platform(hass, config_entry, fake_post) assert hass.states.get(SWITCH_ENTITY_ID).state == STATE_ON # Collect every state the entity takes on from here, so that a tolerated # error cannot go unnoticed by recovering before the final assertion state_changes = async_capture_events(hass, EVENT_STATE_CHANGED) script = iter(failure_script) for _ in range(SCHEDULED_UPDATES): freezer.tick(timedelta(seconds=30)) async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) assert failures == sum(failure_script) assert not [ event for event in state_changes if event.data["entity_id"] == SWITCH_ENTITY_ID ] @pytest.mark.parametrize( "error", [TimeoutError, pyatmo.ApiError], ids=["timeout", "api_error"], ) async def test_entity_unavailable_after_three_failed_updates( hass: HomeAssistant, config_entry: MockConfigEntry, freezer: FrozenDateTimeFactory, error: type[Exception], ) -> None: """Test that entities go unavailable once three updates in a row fail.""" failing = False failures = 0 async def fake_post(*args: Any, **kwargs: Any): nonlocal failures if ( failing and kwargs.get("endpoint", "").endswith("homestatus") and kwargs.get("params", {}).get("home_id") == HOME_ID ): failures += 1 raise error return await fake_post_request(hass, *args, **kwargs) await _setup_switch_platform(hass, config_entry, fake_post) assert hass.states.get(SWITCH_ENTITY_ID).state == STATE_ON failing = True for _ in range(SCHEDULED_UPDATES): freezer.tick(timedelta(seconds=30)) async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) assert failures >= 3 assert hass.states.get(SWITCH_ENTITY_ID).state == STATE_UNAVAILABLE async def test_failed_updates_are_retried_with_escalating_backoff( hass: HomeAssistant, config_entry: MockConfigEntry, freezer: FrozenDateTimeFactory, ) -> None: """Test that a failing home is retried promptly at first, then less often.""" failing = False request_times: list[float] = [] async def fake_post(*args: Any, **kwargs: Any): if ( failing and kwargs.get("endpoint", "").endswith("homestatus") and kwargs.get("params", {}).get("home_id") == HOME_ID ): request_times.append(time()) raise TimeoutError return await fake_post_request(hass, *args, **kwargs) with patch.object(coordinator, "MAX_ERROR_BACKOFF", 4 * HOME_POLL_INTERVAL): await _setup_switch_platform(hass, config_entry, fake_post) failing = True for _ in range(SCHEDULED_UPDATES): freezer.tick(timedelta(seconds=30)) async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) gaps = [round(later - earlier) for earlier, later in pairwise(request_times)] # The first retry comes at the regular poll interval (rounded up to the next # scheduled update), the delay then doubles per consecutive error until the # patched cap of 600s is reached assert gaps == [180, 300, 600, 600]