diff --git a/homeassistant/components/alexa_devices/__init__.py b/homeassistant/components/alexa_devices/__init__.py index af0a3d7818cc34..4e51047696937d 100644 --- a/homeassistant/components/alexa_devices/__init__.py +++ b/homeassistant/components/alexa_devices/__init__.py @@ -11,6 +11,7 @@ PLATFORMS = [ Platform.BINARY_SENSOR, + Platform.BUTTON, Platform.NOTIFY, Platform.SENSOR, Platform.SWITCH, diff --git a/homeassistant/components/alexa_devices/button.py b/homeassistant/components/alexa_devices/button.py new file mode 100644 index 00000000000000..9a735f550fc1e1 --- /dev/null +++ b/homeassistant/components/alexa_devices/button.py @@ -0,0 +1,55 @@ +"""Support for buttons.""" + +from homeassistant.components.button import ButtonEntity +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import EntityDescription +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.util import slugify + +from .coordinator import AmazonConfigEntry, AmazonDevicesCoordinator +from .entity import AmazonServiceEntity + +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + + +async def async_setup_entry( + hass: HomeAssistant, + entry: AmazonConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up button entities for Alexa Devices.""" + coordinator = entry.runtime_data + + known_routines: set[str] = set() + + def _check_routines() -> None: + current_routines = set(coordinator.api.routines) + new_routines = current_routines - known_routines + if new_routines: + known_routines.update(new_routines) + async_add_entities( + AmazonRoutineButton(coordinator, routine) for routine in new_routines + ) + + _check_routines() + entry.async_on_unload(coordinator.async_add_listener(_check_routines)) + + +class AmazonRoutineButton(AmazonServiceEntity, ButtonEntity): + """Button entity for Alexa routine.""" + + _attr_has_entity_name = True + + def __init__(self, coordinator: AmazonDevicesCoordinator, routine: str) -> None: + """Initialize the routine button entity.""" + self._coordinator = coordinator + self._routine = routine + super().__init__( + coordinator, + EntityDescription(key=slugify(routine), name=routine), + ) + + async def async_press(self) -> None: + """Handle button press action.""" + await self._coordinator.api.call_routine(self._routine) diff --git a/homeassistant/components/alexa_devices/coordinator.py b/homeassistant/components/alexa_devices/coordinator.py index 8988d3e13cf785..a5414722baa806 100644 --- a/homeassistant/components/alexa_devices/coordinator.py +++ b/homeassistant/components/alexa_devices/coordinator.py @@ -12,12 +12,13 @@ from aiohttp import ClientSession from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed -from homeassistant.helpers import device_registry as dr +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from homeassistant.util import slugify from .const import _LOGGER, CONF_LOGIN_DATA, DOMAIN @@ -64,6 +65,13 @@ def __init__( for identifier_domain, identifier in device.identifiers if identifier_domain == DOMAIN } + self.previous_routines: set[str] = { + routine.unique_id + for routine in er.async_entries_for_config_entry( + er.async_get(hass), entry.entry_id + ) + if routine.domain == Platform.BUTTON + } async def _async_update_data(self) -> dict[str, AmazonDevice]: """Update device data.""" @@ -92,8 +100,13 @@ async def _async_update_data(self) -> dict[str, AmazonDevice]: current_devices = set(data.keys()) if stale_devices := self.previous_devices - current_devices: await self._async_remove_device_stale(stale_devices) - self.previous_devices = current_devices + + current_routines = {slugify(routine) for routine in self.api.routines} + if stale_routines := self.previous_routines - current_routines: + await self._async_remove_routine_stale(stale_routines) + self.previous_routines = current_routines + return data async def _async_remove_device_stale( @@ -116,3 +129,23 @@ async def _async_remove_device_stale( device_id=device.id, remove_config_entry_id=self.config_entry.entry_id, ) + + async def _async_remove_routine_stale( + self, + stale_routines: set[str], + ) -> None: + """Remove stale routine.""" + entity_registry = er.async_get(self.hass) + + for routine in stale_routines: + _LOGGER.debug( + "Detected change in routines: routine %s removed", + routine, + ) + entity_id = entity_registry.async_get_entity_id( + Platform.BUTTON, + DOMAIN, + f"{slugify(self.config_entry.unique_id)}-{slugify(routine)}", + ) + if entity_id: + entity_registry.async_remove(entity_id) diff --git a/homeassistant/components/alexa_devices/entity.py b/homeassistant/components/alexa_devices/entity.py index 21b01e26f6ccb8..57a67d9d31f876 100644 --- a/homeassistant/components/alexa_devices/entity.py +++ b/homeassistant/components/alexa_devices/entity.py @@ -2,9 +2,10 @@ from aioamazondevices.structures import AmazonDevice -from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity +from homeassistant.util import slugify from .const import DOMAIN from .coordinator import AmazonDevicesCoordinator @@ -50,3 +51,32 @@ def available(self) -> bool: and self._serial_num in self.coordinator.data and self.device.online ) + + +class AmazonServiceEntity(CoordinatorEntity[AmazonDevicesCoordinator]): + """Defines Alexa Devices entity for service device.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: AmazonDevicesCoordinator, + description: EntityDescription, + ) -> None: + """Initialize the service entity.""" + + super().__init__(coordinator) + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, service_device_id(coordinator))}, + manufacturer="Amazon", + entry_type=DeviceEntryType.SERVICE, + ) + self.entity_description = description + self._attr_unique_id = ( + f"{slugify(coordinator.config_entry.unique_id)}-{description.key}" + ) + + +def service_device_id(coordinator: AmazonDevicesCoordinator) -> str: + """Return service device id.""" + return slugify(f"{coordinator.config_entry.unique_id}_service_device") diff --git a/tests/components/alexa_devices/conftest.py b/tests/components/alexa_devices/conftest.py index d40b562a56e1f5..b2cdeddacab1ea 100644 --- a/tests/components/alexa_devices/conftest.py +++ b/tests/components/alexa_devices/conftest.py @@ -13,7 +13,13 @@ ) from homeassistant.const import CONF_PASSWORD, CONF_USERNAME -from .const import TEST_DEVICE_1, TEST_DEVICE_1_SN, TEST_PASSWORD, TEST_USERNAME +from .const import ( + TEST_DEVICE_1, + TEST_DEVICE_1_SN, + TEST_PASSWORD, + TEST_USER_ID, + TEST_USERNAME, +) from tests.common import MockConfigEntry @@ -44,12 +50,13 @@ def mock_amazon_devices_client() -> Generator[AsyncMock]: client = mock_client.return_value client.login = AsyncMock() client.login.login_mode_interactive.return_value = { - "customer_info": {"user_id": TEST_USERNAME}, + "customer_info": {"user_id": TEST_USER_ID}, CONF_SITE: "https://www.amazon.com", } client.get_devices_data.return_value = { TEST_DEVICE_1_SN: deepcopy(TEST_DEVICE_1) } + client.routines = ["Test Routine"] client.send_sound_notification = AsyncMock() yield client @@ -59,7 +66,7 @@ def mock_config_entry() -> MockConfigEntry: """Mock a config entry.""" return MockConfigEntry( domain=DOMAIN, - title="Amazon Test Account", + title=TEST_USERNAME, data={ CONF_USERNAME: TEST_USERNAME, CONF_PASSWORD: TEST_PASSWORD, @@ -68,7 +75,7 @@ def mock_config_entry() -> MockConfigEntry: CONF_SITE: "https://www.amazon.com", }, }, - unique_id=TEST_USERNAME, + unique_id=TEST_USER_ID, version=1, minor_version=3, ) diff --git a/tests/components/alexa_devices/const.py b/tests/components/alexa_devices/const.py index 77eee5133b1cee..e01647a71dcfa7 100644 --- a/tests/components/alexa_devices/const.py +++ b/tests/components/alexa_devices/const.py @@ -12,6 +12,7 @@ TEST_CODE = "023123" TEST_PASSWORD = "fake_password" TEST_USERNAME = "fake_email@gmail.com" +TEST_USER_ID = "amzn1.account.fake_user_id" TEST_DEVICE_1_SN = "echo_test_serial_number" TEST_DEVICE_1_ID = "echo_test_device_id" diff --git a/tests/components/alexa_devices/snapshots/test_button.ambr b/tests/components/alexa_devices/snapshots/test_button.ambr new file mode 100644 index 00000000000000..ce237adb111924 --- /dev/null +++ b/tests/components/alexa_devices/snapshots/test_button.ambr @@ -0,0 +1,51 @@ +# serializer version: 1 +# name: test_all_entities[button.fake_email_gmail_com_test_routine-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.fake_email_gmail_com_test_routine', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Test Routine', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Test Routine', + 'platform': 'alexa_devices', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'amzn1_account_fake_user_id-test_routine', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[button.fake_email_gmail_com_test_routine-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'fake_email@gmail.com Test Routine', + }), + 'context': , + 'entity_id': 'button.fake_email_gmail_com_test_routine', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/alexa_devices/snapshots/test_diagnostics.ambr b/tests/components/alexa_devices/snapshots/test_diagnostics.ambr index 7388d97d158016..8c30470005f643 100644 --- a/tests/components/alexa_devices/snapshots/test_diagnostics.ambr +++ b/tests/components/alexa_devices/snapshots/test_diagnostics.ambr @@ -97,7 +97,7 @@ 'subentries': list([ ]), 'title': '**REDACTED**', - 'unique_id': 'fake_email@gmail.com', + 'unique_id': 'amzn1.account.fake_user_id', 'version': 1, }), }) diff --git a/tests/components/alexa_devices/test_button.py b/tests/components/alexa_devices/test_button.py new file mode 100644 index 00000000000000..a1bba9b7e1a94d --- /dev/null +++ b/tests/components/alexa_devices/test_button.py @@ -0,0 +1,96 @@ +"""Test Alexa Devices button entities.""" + +from unittest.mock import AsyncMock, patch + +from freezegun.api import FrozenDateTimeFactory +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.alexa_devices.coordinator import SCAN_INTERVAL +from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er +from homeassistant.util import slugify + +from . import setup_integration +from .const import TEST_USERNAME + +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform + + +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_amazon_devices_client: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + + with patch("homeassistant.components.alexa_devices.PLATFORMS", [Platform.BUTTON]): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_pressing_routine_button( + hass: HomeAssistant, + mock_amazon_devices_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test routine run button.""" + + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: f"button.{slugify(TEST_USERNAME)}_test_routine"}, + blocking=True, + ) + mock_amazon_devices_client.call_routine.assert_called_once() + + +@pytest.mark.parametrize( + ("initial_routine", "updated_routines"), + [ + (["Test Routine"], ["Test Routine", "New Routine"]), # Add a routine + (["Test Routine", "New Routine"], ["Test Routine"]), # Remove a routine + (["Test Routine"], []), # Remove all routines + ], +) +async def test_dynamic_entities( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_amazon_devices_client: AsyncMock, + mock_config_entry: MockConfigEntry, + initial_routine: list[str], + updated_routines: list[str], +) -> None: + """Test entities are dynamically created and deleted.""" + + mock_amazon_devices_client.routines = initial_routine + + await setup_integration(hass, mock_config_entry) + + # Check initial routine(s) exist + for routine in initial_routine: + entity_id = f"button.{slugify(TEST_USERNAME)}_{slugify(routine)}" + assert hass.states.get(entity_id) is not None + + mock_amazon_devices_client.routines = updated_routines + + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + # After update, check which routines should exist + for routine in updated_routines: + entity_id = f"button.{slugify(TEST_USERNAME)}_{slugify(routine)}" + assert hass.states.get(entity_id) is not None + + # Check routines that were removed no longer exist + for routine in set(initial_routine) - set(updated_routines): + entity_id = f"button.{slugify(TEST_USERNAME)}_{slugify(routine)}" + assert hass.states.get(entity_id) is None diff --git a/tests/components/alexa_devices/test_config_flow.py b/tests/components/alexa_devices/test_config_flow.py index 1368b357610467..7b19e28c080eac 100644 --- a/tests/components/alexa_devices/test_config_flow.py +++ b/tests/components/alexa_devices/test_config_flow.py @@ -19,7 +19,7 @@ from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from .const import TEST_CODE, TEST_PASSWORD, TEST_USERNAME +from .const import TEST_CODE, TEST_PASSWORD, TEST_USER_ID, TEST_USERNAME from tests.common import MockConfigEntry @@ -51,11 +51,11 @@ async def test_full_flow( CONF_USERNAME: TEST_USERNAME, CONF_PASSWORD: TEST_PASSWORD, CONF_LOGIN_DATA: { - "customer_info": {"user_id": TEST_USERNAME}, + "customer_info": {"user_id": TEST_USER_ID}, CONF_SITE: "https://www.amazon.com", }, } - assert result["result"].unique_id == TEST_USERNAME + assert result["result"].unique_id == TEST_USER_ID mock_amazon_devices_client.login.login_mode_interactive.assert_called_once_with( "023123" ) @@ -170,7 +170,7 @@ async def test_reauth_successful( CONF_USERNAME: TEST_USERNAME, CONF_PASSWORD: "other_fake_password", CONF_LOGIN_DATA: { - "customer_info": {"user_id": TEST_USERNAME}, + "customer_info": {"user_id": TEST_USER_ID}, CONF_SITE: "https://www.amazon.com", }, } @@ -228,7 +228,7 @@ async def test_reauth_not_successful( CONF_USERNAME: TEST_USERNAME, CONF_PASSWORD: "fake_password", CONF_LOGIN_DATA: { - "customer_info": {"user_id": TEST_USERNAME}, + "customer_info": {"user_id": TEST_USER_ID}, CONF_SITE: "https://www.amazon.com", }, } @@ -268,7 +268,7 @@ async def test_reconfigure_successful( CONF_USERNAME: TEST_USERNAME, CONF_PASSWORD: new_password, CONF_LOGIN_DATA: { - "customer_info": {"user_id": TEST_USERNAME}, + "customer_info": {"user_id": TEST_USER_ID}, CONF_SITE: "https://www.amazon.com", }, } @@ -327,7 +327,7 @@ async def test_reconfigure_fails( CONF_USERNAME: TEST_USERNAME, CONF_PASSWORD: TEST_PASSWORD, CONF_LOGIN_DATA: { - "customer_info": {"user_id": TEST_USERNAME}, + "customer_info": {"user_id": TEST_USER_ID}, CONF_SITE: "https://www.amazon.com", }, } diff --git a/tests/components/alexa_devices/test_init.py b/tests/components/alexa_devices/test_init.py index 0b20b1fe239e7d..623c1a7315df54 100644 --- a/tests/components/alexa_devices/test_init.py +++ b/tests/components/alexa_devices/test_init.py @@ -16,7 +16,7 @@ from homeassistant.helpers import device_registry as dr from . import setup_integration -from .const import TEST_DEVICE_1_SN, TEST_PASSWORD, TEST_USERNAME +from .const import TEST_DEVICE_1_SN, TEST_PASSWORD, TEST_USER_ID, TEST_USERNAME from tests.common import MockConfigEntry @@ -109,7 +109,7 @@ async def test_migrate_entry( CONF_PASSWORD: TEST_PASSWORD, **(extra_data), }, - unique_id=TEST_USERNAME, + unique_id=TEST_USER_ID, version=1, minor_version=minor_version, )