mirror of
https://github.com/home-assistant/core.git
synced 2026-08-28 02:24:46 -05:00
Add history events for Alexa Devices (#170905)
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user