From e8d7df7770c421d8a0271f2ee383a7230dc94159 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Tue, 26 May 2026 22:05:20 +0200 Subject: [PATCH] Add history events for Alexa Devices (#170905) --- .../components/alexa_devices/__init__.py | 28 +++++- .../components/alexa_devices/coordinator.py | 44 +++++++++- .../components/alexa_devices/event.py | 86 +++++++++++++++++++ .../components/alexa_devices/icons.json | 5 ++ .../components/alexa_devices/strings.json | 12 +++ tests/components/alexa_devices/conftest.py | 11 ++- tests/components/alexa_devices/const.py | 26 +++++- .../alexa_devices/snapshots/test_event.ambr | 71 +++++++++++++++ .../alexa_devices/test_coordinator.py | 44 ++++++++++ tests/components/alexa_devices/test_event.py | 71 +++++++++++++++ tests/components/alexa_devices/test_init.py | 21 +++++ tests/components/alexa_devices/test_switch.py | 2 +- 12 files changed, 413 insertions(+), 8 deletions(-) create mode 100644 homeassistant/components/alexa_devices/event.py create mode 100644 tests/components/alexa_devices/snapshots/test_event.ambr create mode 100644 tests/components/alexa_devices/test_event.py diff --git a/homeassistant/components/alexa_devices/__init__.py b/homeassistant/components/alexa_devices/__init__.py index 9441cc594965..b331a6ce3f8f 100644 --- a/homeassistant/components/alexa_devices/__init__.py +++ b/homeassistant/components/alexa_devices/__init__.py @@ -1,9 +1,13 @@ """Alexa Devices integration.""" +import asyncio +import contextlib + from homeassistant.const import CONF_COUNTRY, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import aiohttp_client, config_validation as cv +from homeassistant.helpers import aiohttp_client, config_validation as cv, httpx_client from homeassistant.helpers.typing import ConfigType +from homeassistant.util.ssl import SSL_ALPN_HTTP11_HTTP2 from .const import _LOGGER, CONF_LOGIN_DATA, CONF_SITE, COUNTRY_DOMAINS, DOMAIN from .coordinator import AmazonConfigEntry, AmazonDevicesCoordinator @@ -12,6 +16,7 @@ from .services import async_setup_services PLATFORMS = [ Platform.BINARY_SENSOR, Platform.BUTTON, + Platform.EVENT, Platform.NOTIFY, Platform.SENSOR, Platform.SWITCH, @@ -34,6 +39,27 @@ async def async_setup_entry(hass: HomeAssistant, entry: AmazonConfigEntry) -> bo await coordinator.async_config_entry_first_refresh() + await coordinator.sync_history_state() + + async def _on_http2_reauth_required() -> None: + entry.async_start_reauth(hass) + + async def _cancel_http2() -> None: + http2_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await http2_task + + alexa_httpx_client = httpx_client.get_async_client( + hass, + alpn_protocols=SSL_ALPN_HTTP11_HTTP2, + ) + + http2_task = await coordinator.api.start_http2_processing( + alexa_httpx_client, on_reauth_required=_on_http2_reauth_required + ) + + entry.async_on_unload(_cancel_http2) + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/homeassistant/components/alexa_devices/coordinator.py b/homeassistant/components/alexa_devices/coordinator.py index a5414722baa8..0355c21ecf15 100644 --- a/homeassistant/components/alexa_devices/coordinator.py +++ b/homeassistant/components/alexa_devices/coordinator.py @@ -8,13 +8,13 @@ from aioamazondevices.exceptions import ( CannotConnect, CannotRetrieveData, ) -from aioamazondevices.structures import AmazonDevice +from aioamazondevices.structures import AmazonDevice, AmazonVocalRecord from aiohttp import ClientSession from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady 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 @@ -73,6 +73,11 @@ class AmazonDevicesCoordinator(DataUpdateCoordinator[dict[str, AmazonDevice]]): if routine.domain == Platform.BUTTON } + self._vocal_records: dict[str, AmazonVocalRecord] = {} + + self.api.on_history_event.append(self.history_state_event_handler) + self.api.on_history_event.freeze() + async def _async_update_data(self) -> dict[str, AmazonDevice]: """Update device data.""" try: @@ -149,3 +154,38 @@ class AmazonDevicesCoordinator(DataUpdateCoordinator[dict[str, AmazonDevice]]): ) if entity_id: entity_registry.async_remove(entity_id) + + async def sync_history_state(self) -> None: + """Sync history state.""" + try: + self._vocal_records = await self.api.sync_history_state() + except CannotAuthenticate as e: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="invalid_auth", + translation_placeholders={"error": repr(e)}, + ) from e + except CannotConnect as e: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="cannot_connect_with_error", + translation_placeholders={"error": repr(e)}, + ) from e + except BaseException as e: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="cannot_retrieve_data_with_error", + translation_placeholders={"error": repr(e)}, + ) from e + + async def history_state_event_handler( + self, vocal_records: dict[str, AmazonVocalRecord] + ) -> None: + """Handle pushed vocal record events.""" + self._vocal_records = {**self._vocal_records, **vocal_records} + self.async_update_listeners() + + @property + def vocal_records(self) -> dict[str, AmazonVocalRecord]: + """Vocal records of devices.""" + return self._vocal_records diff --git a/homeassistant/components/alexa_devices/event.py b/homeassistant/components/alexa_devices/event.py new file mode 100644 index 000000000000..ec0c39972ba3 --- /dev/null +++ b/homeassistant/components/alexa_devices/event.py @@ -0,0 +1,86 @@ +"""Support for events.""" + +from typing import Final + +from homeassistant.components.event import EventEntity, EventEntityDescription +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .const import _LOGGER +from .coordinator import AmazonConfigEntry, AmazonDevicesCoordinator +from .entity import AmazonEntity + +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + +EVENTS: Final = { + EventEntityDescription( + key="voice_event", + translation_key="voice_event", + ), +} + +EVENT_TYPE = "triggered" + + +async def async_setup_entry( + hass: HomeAssistant, + entry: AmazonConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Alexa Devices events based on a config entry.""" + coordinator = entry.runtime_data + + known_devices: set[str] = set() + + def _check_device() -> None: + current_devices = set(coordinator.data) + new_devices = current_devices - known_devices + if new_devices: + known_devices.update(new_devices) + async_add_entities( + AlexaVoiceEvent(coordinator, serial_num, event_desc) + for event_desc in EVENTS + for serial_num in new_devices + ) + + _check_device() + entry.async_on_unload(coordinator.async_add_listener(_check_device)) + + +class AlexaVoiceEvent(AmazonEntity, EventEntity): + """Representation of an Alexa voice event.""" + + _attr_event_types = [EVENT_TYPE] + coordinator: AmazonDevicesCoordinator + _last_seen_timestamp: int | None = None + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + + if not ( + vocal_record := self.coordinator.vocal_records.get( + self.device.serial_number + ) + ): + _LOGGER.debug( + "No vocal record found for device %s [%s]", + self.device.account_name, + self.device.serial_number, + ) + return + + if vocal_record.timestamp == self._last_seen_timestamp: + return + + self._last_seen_timestamp = vocal_record.timestamp + self._trigger_event( + EVENT_TYPE, + { + "intent": vocal_record.intent, + "voice_command": vocal_record.title, + "voice_reply": vocal_record.sub_title, + }, + ) + self.async_write_ha_state() diff --git a/homeassistant/components/alexa_devices/icons.json b/homeassistant/components/alexa_devices/icons.json index fecc2342d7a4..db44ee44c2c4 100644 --- a/homeassistant/components/alexa_devices/icons.json +++ b/homeassistant/components/alexa_devices/icons.json @@ -1,5 +1,10 @@ { "entity": { + "event": { + "voice_event": { + "default": "mdi:chat-processing" + } + }, "sensor": { "voc_index": { "default": "mdi:molecule" diff --git a/homeassistant/components/alexa_devices/strings.json b/homeassistant/components/alexa_devices/strings.json index 29a62e185544..0ec611a8d62c 100644 --- a/homeassistant/components/alexa_devices/strings.json +++ b/homeassistant/components/alexa_devices/strings.json @@ -58,6 +58,18 @@ } }, "entity": { + "event": { + "voice_event": { + "name": "Voice event", + "state_attributes": { + "event_type": { + "state": { + "triggered": "Triggered" + } + } + } + } + }, "notify": { "announce": { "name": "Announce" diff --git a/tests/components/alexa_devices/conftest.py b/tests/components/alexa_devices/conftest.py index b2cdeddacab1..12cfc701b30c 100644 --- a/tests/components/alexa_devices/conftest.py +++ b/tests/components/alexa_devices/conftest.py @@ -1,8 +1,9 @@ """Alexa Devices tests configuration.""" +import asyncio from collections.abc import Generator from copy import deepcopy -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -19,6 +20,7 @@ from .const import ( TEST_PASSWORD, TEST_USER_ID, TEST_USERNAME, + TEST_VOCAL_RECORD_INITIAL, ) from tests.common import MockConfigEntry @@ -57,6 +59,13 @@ def mock_amazon_devices_client() -> Generator[AsyncMock]: TEST_DEVICE_1_SN: deepcopy(TEST_DEVICE_1) } client.routines = ["Test Routine"] + client.sync_history_state = AsyncMock( + return_value={TEST_DEVICE_1_SN: TEST_VOCAL_RECORD_INITIAL} + ) + client.on_history_event = MagicMock() + http2_task = asyncio.Future() + http2_task.set_result(None) + client.start_http2_processing = AsyncMock(return_value=http2_task) client.send_sound_notification = AsyncMock() yield client diff --git a/tests/components/alexa_devices/const.py b/tests/components/alexa_devices/const.py index e01647a71dcf..8a5772f6af6b 100644 --- a/tests/components/alexa_devices/const.py +++ b/tests/components/alexa_devices/const.py @@ -7,7 +7,12 @@ from aioamazondevices.const.schedules import ( NOTIFICATION_REMINDER, NOTIFICATION_TIMER, ) -from aioamazondevices.structures import AmazonDevice, AmazonDeviceSensor, AmazonSchedule +from aioamazondevices.structures import ( + AmazonDevice, + AmazonDeviceSensor, + AmazonSchedule, + AmazonVocalRecord, +) TEST_CODE = "023123" TEST_PASSWORD = "fake_password" @@ -75,7 +80,6 @@ TEST_DEVICE_1 = AmazonDevice( ) TEST_DEVICE_2_SN = "echo_test_2_serial_number" -TEST_DEVICE_2_ID = "echo_test_2_device_id" TEST_DEVICE_2 = AmazonDevice( account_name="Echo Test 2", capabilities=["AUDIO_PLAYER", "MICROPHONE"], @@ -83,7 +87,7 @@ TEST_DEVICE_2 = AmazonDevice( device_type="echo", household_device=True, device_owner_customer_id="amazon_ower_id", - device_cluster_members={TEST_DEVICE_2_SN: TEST_DEVICE_2_ID}, + device_cluster_members={TEST_DEVICE_2_SN: "echo_test_2_device_id"}, online=True, serial_number=TEST_DEVICE_2_SN, manufacturer="Test manufacturer 2", @@ -106,3 +110,19 @@ TEST_DEVICE_2 = AmazonDevice( notifications={}, media_player_supported=False, ) + +TEST_VOCAL_RECORD_INITIAL = AmazonVocalRecord( + timestamp=1000, + utterance_type="WAKE_WORD_UTTERANCE", + intent="PlayMusicIntent", + title="Play some music", + sub_title="Echo Test", +) + +TEST_VOCAL_RECORD_EVENT = AmazonVocalRecord( + timestamp=1234567890, + utterance_type="WAKE_WORD_UTTERANCE", + intent="PlayMusicIntent", + title="Play some music", + sub_title="Echo Test", +) diff --git a/tests/components/alexa_devices/snapshots/test_event.ambr b/tests/components/alexa_devices/snapshots/test_event.ambr new file mode 100644 index 000000000000..fd31a0a04943 --- /dev/null +++ b/tests/components/alexa_devices/snapshots/test_event.ambr @@ -0,0 +1,71 @@ +# serializer version: 1 +# name: test_all_entities[event.echo_test_voice_event-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'event_types': list([ + 'triggered', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'event', + 'entity_category': None, + 'entity_id': 'event.echo_test_voice_event', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Voice event', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Voice event', + 'platform': 'alexa_devices', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'voice_event', + 'unique_id': 'echo_test_serial_number-voice_event', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[event.echo_test_voice_event-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'event_type': None, + 'event_types': list([ + 'triggered', + ]), + 'friendly_name': 'Echo Test Voice event', + }), + 'context': , + 'entity_id': 'event.echo_test_voice_event', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_history_event_is_fired + ReadOnlyDict({ + 'event_type': 'triggered', + 'event_types': list([ + 'triggered', + ]), + 'friendly_name': 'Echo Test Voice event', + 'intent': 'PlayMusicIntent', + 'voice_command': 'Play some music', + 'voice_reply': 'Echo Test', + }) +# --- diff --git a/tests/components/alexa_devices/test_coordinator.py b/tests/components/alexa_devices/test_coordinator.py index b53734f8ceee..73854f0de76d 100644 --- a/tests/components/alexa_devices/test_coordinator.py +++ b/tests/components/alexa_devices/test_coordinator.py @@ -2,10 +2,17 @@ from unittest.mock import AsyncMock +from aioamazondevices.exceptions import ( + CannotAuthenticate, + CannotConnect, + CannotRetrieveData, +) from freezegun.api import FrozenDateTimeFactory +import pytest from homeassistant.components.alexa_devices.const import DOMAIN from homeassistant.components.alexa_devices.coordinator import SCAN_INTERVAL +from homeassistant.config_entries import ConfigEntryState from homeassistant.const import STATE_ON from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr @@ -82,3 +89,40 @@ async def test_coordinator_load_previous_devices_from_registry( await setup_integration(hass, mock_config_entry) coordinator = mock_config_entry.runtime_data assert coordinator.previous_devices == {TEST_DEVICE_1_SN} + + +@pytest.mark.parametrize( + ("side_effect", "expected_state"), + [ + pytest.param( + CannotAuthenticate, + ConfigEntryState.SETUP_ERROR, + id="cannot_authenticate", + ), + pytest.param( + CannotConnect, + ConfigEntryState.SETUP_RETRY, + id="cannot_connect", + ), + pytest.param( + CannotRetrieveData, + ConfigEntryState.SETUP_RETRY, + id="cannot_retrieve_data", + ), + ], +) +async def test_sync_history_state_error( + hass: HomeAssistant, + mock_amazon_devices_client: AsyncMock, + mock_config_entry: MockConfigEntry, + side_effect: type[Exception], + expected_state: ConfigEntryState, +) -> None: + """Test sync_history_state error handling.""" + mock_amazon_devices_client.sync_history_state.side_effect = side_effect + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is expected_state diff --git a/tests/components/alexa_devices/test_event.py b/tests/components/alexa_devices/test_event.py new file mode 100644 index 000000000000..ddf90e744a54 --- /dev/null +++ b/tests/components/alexa_devices/test_event.py @@ -0,0 +1,71 @@ +"""Tests for the Alexa Devices event platform.""" + +from unittest.mock import AsyncMock, patch + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.const import STATE_UNKNOWN, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration +from .const import TEST_DEVICE_1_SN, TEST_VOCAL_RECORD_EVENT + +from tests.common import MockConfigEntry, snapshot_platform + +ENTITY_ID = "event.echo_test_voice_event" + + +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.EVENT]): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.freeze_time("2025-01-01 00:00:00+00:00") +async def test_history_event_is_fired( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_amazon_devices_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test history updates trigger voice event entity state updates.""" + with patch("homeassistant.components.alexa_devices.PLATFORMS", [Platform.EVENT]): + await setup_integration(hass, mock_config_entry) + + coordinator = mock_config_entry.runtime_data + await coordinator.history_state_event_handler( + {TEST_DEVICE_1_SN: TEST_VOCAL_RECORD_EVENT} + ) + await hass.async_block_till_done() + + assert (state := hass.states.get(ENTITY_ID)) + assert state.attributes == snapshot + + +async def test_no_vocal_record_skips_event_trigger( + hass: HomeAssistant, + mock_amazon_devices_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that a coordinator update with no vocal record skips event trigger.""" + mock_amazon_devices_client.sync_history_state.return_value = {} + with patch("homeassistant.components.alexa_devices.PLATFORMS", [Platform.EVENT]): + await setup_integration(hass, mock_config_entry) + + coordinator = mock_config_entry.runtime_data + await coordinator.history_state_event_handler({}) + await hass.async_block_till_done() + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == STATE_UNKNOWN + assert state.attributes.get("event_type") is None diff --git a/tests/components/alexa_devices/test_init.py b/tests/components/alexa_devices/test_init.py index 8352b26891a6..a57181521dca 100644 --- a/tests/components/alexa_devices/test_init.py +++ b/tests/components/alexa_devices/test_init.py @@ -139,3 +139,24 @@ async def test_migrate_future_version_returns_false( await setup_integration(hass, config_entry) assert config_entry.state is ConfigEntryState.MIGRATION_ERROR + + +async def test_http2_reauth_required( + hass: HomeAssistant, + mock_amazon_devices_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test HTTP/2 re-authentication triggers a reauth flow.""" + await setup_integration(hass, mock_config_entry) + + on_reauth_required = ( + mock_amazon_devices_client.start_http2_processing.call_args.kwargs[ + "on_reauth_required" + ] + ) + await on_reauth_required() + await hass.async_block_till_done() + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + assert flows[0]["context"]["source"] == "reauth" diff --git a/tests/components/alexa_devices/test_switch.py b/tests/components/alexa_devices/test_switch.py index 4c454b5b2a9b..4732fb7863f6 100644 --- a/tests/components/alexa_devices/test_switch.py +++ b/tests/components/alexa_devices/test_switch.py @@ -25,7 +25,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from . import setup_integration -from .conftest import TEST_DEVICE_1, TEST_DEVICE_1_SN +from .const import TEST_DEVICE_1, TEST_DEVICE_1_SN from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform