Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions homeassistant/components/alexa_devices/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

PLATFORMS = [
Platform.BINARY_SENSOR,
Platform.BUTTON,
Platform.NOTIFY,
Platform.SENSOR,
Platform.SWITCH,
Expand Down
55 changes: 55 additions & 0 deletions homeassistant/components/alexa_devices/button.py
Original file line number Diff line number Diff line change
@@ -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)
39 changes: 36 additions & 3 deletions homeassistant/components/alexa_devices/coordinator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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."""
Expand Down Expand Up @@ -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(
Expand All @@ -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)
32 changes: 31 additions & 1 deletion homeassistant/components/alexa_devices/entity.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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")
15 changes: 11 additions & 4 deletions tests/components/alexa_devices/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand All @@ -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,
Expand All @@ -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,
)
1 change: 1 addition & 0 deletions tests/components/alexa_devices/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
51 changes: 51 additions & 0 deletions tests/components/alexa_devices/snapshots/test_button.ambr
Original file line number Diff line number Diff line change
@@ -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': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'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': <ANY>,
'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': <ANY>,
'entity_id': 'button.fake_email_gmail_com_test_routine',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@
'subentries': list([
]),
'title': '**REDACTED**',
'unique_id': 'fake_email@gmail.com',
'unique_id': 'amzn1.account.fake_user_id',
'version': 1,
}),
})
Expand Down
96 changes: 96 additions & 0 deletions tests/components/alexa_devices/test_button.py
Comment thread
chemelli74 marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading