Add history events for Alexa Devices (#170905)

This commit is contained in:
Simone Chemelli
2026-05-26 22:05:20 +02:00
committed by GitHub
parent 31f87b3a8a
commit e8d7df7770
12 changed files with 413 additions and 8 deletions
@@ -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)
@@ -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
@@ -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()
@@ -1,5 +1,10 @@
{
"entity": {
"event": {
"voice_event": {
"default": "mdi:chat-processing"
}
},
"sensor": {
"voc_index": {
"default": "mdi:molecule"
@@ -58,6 +58,18 @@
}
},
"entity": {
"event": {
"voice_event": {
"name": "Voice event",
"state_attributes": {
"event_type": {
"state": {
"triggered": "Triggered"
}
}
}
}
},
"notify": {
"announce": {
"name": "Announce"
+10 -1
View File
@@ -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
+23 -3
View File
@@ -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",
)
@@ -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': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'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': <ANY>,
'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': <ANY>,
'entity_id': 'event.echo_test_voice_event',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'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',
})
# ---
@@ -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
@@ -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
@@ -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"
@@ -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