"""The tests for Netatmo component.""" from datetime import timedelta from functools import partial from time import time from typing import Any from unittest.mock import AsyncMock, patch import aiohttp from freezegun.api import FrozenDateTimeFactory 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 from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ( CONF_WEBHOOK_ID, 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, fake_post_request, selected_platforms, simulate_webhook, ) from tests.common import MockConfigEntry, 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": "91763b24c43d3e344f424e8b", "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", } 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