From 14d42e43bfe85483f672c9735b1398439b2b6cf4 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Wed, 24 Sep 2025 19:00:35 +0200 Subject: [PATCH] Add dynamic devices management for Alexa Devices (#151975) --- .../components/alexa_devices/binary_sensor.py | 24 ++++++++--- .../components/alexa_devices/notify.py | 24 +++++++---- .../alexa_devices/quality_scale.yaml | 2 +- .../components/alexa_devices/sensor.py | 22 +++++++--- .../components/alexa_devices/switch.py | 22 +++++++--- tests/components/alexa_devices/conftest.py | 4 +- tests/components/alexa_devices/const.py | 35 +++++++++++++--- .../alexa_devices/test_binary_sensor.py | 42 +++++++++++++++++-- .../alexa_devices/test_coordinator.py | 29 ++----------- .../alexa_devices/test_diagnostics.py | 6 +-- tests/components/alexa_devices/test_init.py | 4 +- tests/components/alexa_devices/test_notify.py | 6 +-- tests/components/alexa_devices/test_sensor.py | 8 ++-- .../components/alexa_devices/test_services.py | 12 +++--- tests/components/alexa_devices/test_switch.py | 10 ++--- 15 files changed, 164 insertions(+), 86 deletions(-) diff --git a/homeassistant/components/alexa_devices/binary_sensor.py b/homeassistant/components/alexa_devices/binary_sensor.py index 231f144dd894..410ea4555e24 100644 --- a/homeassistant/components/alexa_devices/binary_sensor.py +++ b/homeassistant/components/alexa_devices/binary_sensor.py @@ -94,12 +94,24 @@ async def async_setup_entry( coordinator = entry.runtime_data - async_add_entities( - AmazonBinarySensorEntity(coordinator, serial_num, sensor_desc) - for sensor_desc in BINARY_SENSORS - for serial_num in coordinator.data - if sensor_desc.is_supported(coordinator.data[serial_num], sensor_desc.key) - ) + 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( + AmazonBinarySensorEntity(coordinator, serial_num, sensor_desc) + for sensor_desc in BINARY_SENSORS + for serial_num in new_devices + if sensor_desc.is_supported( + coordinator.data[serial_num], sensor_desc.key + ) + ) + + _check_device() + entry.async_on_unload(coordinator.async_add_listener(_check_device)) class AmazonBinarySensorEntity(AmazonEntity, BinarySensorEntity): diff --git a/homeassistant/components/alexa_devices/notify.py b/homeassistant/components/alexa_devices/notify.py index 08f2e214f38c..d046b580cb7f 100644 --- a/homeassistant/components/alexa_devices/notify.py +++ b/homeassistant/components/alexa_devices/notify.py @@ -57,13 +57,23 @@ async def async_setup_entry( coordinator = entry.runtime_data - async_add_entities( - AmazonNotifyEntity(coordinator, serial_num, sensor_desc) - for sensor_desc in NOTIFY - for serial_num in coordinator.data - if sensor_desc.subkey in coordinator.data[serial_num].capabilities - and sensor_desc.is_supported(coordinator.data[serial_num]) - ) + 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( + AmazonNotifyEntity(coordinator, serial_num, sensor_desc) + for sensor_desc in NOTIFY + for serial_num in new_devices + if sensor_desc.subkey in coordinator.data[serial_num].capabilities + and sensor_desc.is_supported(coordinator.data[serial_num]) + ) + + _check_device() + entry.async_on_unload(coordinator.async_add_listener(_check_device)) class AmazonNotifyEntity(AmazonEntity, NotifyEntity): diff --git a/homeassistant/components/alexa_devices/quality_scale.yaml b/homeassistant/components/alexa_devices/quality_scale.yaml index da48f366a6c2..0933f1783599 100644 --- a/homeassistant/components/alexa_devices/quality_scale.yaml +++ b/homeassistant/components/alexa_devices/quality_scale.yaml @@ -53,7 +53,7 @@ rules: docs-supported-functions: done docs-troubleshooting: done docs-use-cases: done - dynamic-devices: todo + dynamic-devices: done entity-category: done entity-device-class: done entity-disabled-by-default: done diff --git a/homeassistant/components/alexa_devices/sensor.py b/homeassistant/components/alexa_devices/sensor.py index 738e0ac2de57..1a863e87c1a7 100644 --- a/homeassistant/components/alexa_devices/sensor.py +++ b/homeassistant/components/alexa_devices/sensor.py @@ -62,12 +62,22 @@ async def async_setup_entry( coordinator = entry.runtime_data - async_add_entities( - AmazonSensorEntity(coordinator, serial_num, sensor_desc) - for sensor_desc in SENSORS - for serial_num in coordinator.data - if coordinator.data[serial_num].sensors.get(sensor_desc.key) is not None - ) + 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( + AmazonSensorEntity(coordinator, serial_num, sensor_desc) + for sensor_desc in SENSORS + for serial_num in new_devices + if coordinator.data[serial_num].sensors.get(sensor_desc.key) is not None + ) + + _check_device() + entry.async_on_unload(coordinator.async_add_listener(_check_device)) class AmazonSensorEntity(AmazonEntity, SensorEntity): diff --git a/homeassistant/components/alexa_devices/switch.py b/homeassistant/components/alexa_devices/switch.py index e53ea40965a8..138013666c6e 100644 --- a/homeassistant/components/alexa_devices/switch.py +++ b/homeassistant/components/alexa_devices/switch.py @@ -48,12 +48,22 @@ async def async_setup_entry( coordinator = entry.runtime_data - async_add_entities( - AmazonSwitchEntity(coordinator, serial_num, switch_desc) - for switch_desc in SWITCHES - for serial_num in coordinator.data - if switch_desc.subkey in coordinator.data[serial_num].capabilities - ) + 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( + AmazonSwitchEntity(coordinator, serial_num, switch_desc) + for switch_desc in SWITCHES + for serial_num in new_devices + if switch_desc.subkey in coordinator.data[serial_num].capabilities + ) + + _check_device() + entry.async_on_unload(coordinator.async_add_listener(_check_device)) class AmazonSwitchEntity(AmazonEntity, SwitchEntity): diff --git a/tests/components/alexa_devices/conftest.py b/tests/components/alexa_devices/conftest.py index d9864fdeb313..bed7abc3e336 100644 --- a/tests/components/alexa_devices/conftest.py +++ b/tests/components/alexa_devices/conftest.py @@ -14,7 +14,7 @@ from homeassistant.components.alexa_devices.const import ( ) from homeassistant.const import CONF_PASSWORD, CONF_USERNAME -from .const import TEST_DEVICE, TEST_PASSWORD, TEST_SERIAL_NUMBER, TEST_USERNAME +from .const import TEST_DEVICE_1, TEST_DEVICE_1_SN, TEST_PASSWORD, TEST_USERNAME from tests.common import MockConfigEntry @@ -48,7 +48,7 @@ def mock_amazon_devices_client() -> Generator[AsyncMock]: CONF_SITE: "https://www.amazon.com", } client.get_devices_data.return_value = { - TEST_SERIAL_NUMBER: deepcopy(TEST_DEVICE) + TEST_DEVICE_1_SN: deepcopy(TEST_DEVICE_1) } client.get_model_details = lambda device: DEVICE_TYPE_TO_MODEL.get( device.device_type diff --git a/tests/components/alexa_devices/const.py b/tests/components/alexa_devices/const.py index fa30226849ee..d078e92199ed 100644 --- a/tests/components/alexa_devices/const.py +++ b/tests/components/alexa_devices/const.py @@ -4,20 +4,19 @@ from aioamazondevices.api import AmazonDevice, AmazonDeviceSensor TEST_CODE = "023123" TEST_PASSWORD = "fake_password" -TEST_SERIAL_NUMBER = "echo_test_serial_number" TEST_USERNAME = "fake_email@gmail.com" -TEST_DEVICE_ID = "echo_test_device_id" - -TEST_DEVICE = AmazonDevice( +TEST_DEVICE_1_SN = "echo_test_serial_number" +TEST_DEVICE_1_ID = "echo_test_device_id" +TEST_DEVICE_1 = AmazonDevice( account_name="Echo Test", capabilities=["AUDIO_PLAYER", "MICROPHONE"], device_family="mine", device_type="echo", device_owner_customer_id="amazon_ower_id", - device_cluster_members=[TEST_SERIAL_NUMBER], + device_cluster_members=[TEST_DEVICE_1_SN], online=True, - serial_number=TEST_SERIAL_NUMBER, + serial_number=TEST_DEVICE_1_SN, software_version="echo_test_software_version", do_not_disturb=False, response_style=None, @@ -30,3 +29,27 @@ TEST_DEVICE = 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"], + device_family="mine", + device_type="echo", + device_owner_customer_id="amazon_ower_id", + device_cluster_members=[TEST_DEVICE_2_SN], + online=True, + serial_number=TEST_DEVICE_2_SN, + software_version="echo_test_2_software_version", + do_not_disturb=False, + response_style=None, + bluetooth_state=True, + entity_id="11111111-2222-3333-4444-555555555555", + appliance_id="G1234567890123456789012345678A", + sensors={ + "temperature": AmazonDeviceSensor( + name="temperature", value="22.5", scale="CELSIUS" + ) + }, +) diff --git a/tests/components/alexa_devices/test_binary_sensor.py b/tests/components/alexa_devices/test_binary_sensor.py index a2e38b3459b1..bcb89664da46 100644 --- a/tests/components/alexa_devices/test_binary_sensor.py +++ b/tests/components/alexa_devices/test_binary_sensor.py @@ -17,7 +17,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from . import setup_integration -from .const import TEST_SERIAL_NUMBER +from .const import TEST_DEVICE_1, TEST_DEVICE_1_SN, TEST_DEVICE_2, TEST_DEVICE_2_SN from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform @@ -83,7 +83,7 @@ async def test_offline_device( entity_id = "binary_sensor.echo_test_connectivity" mock_amazon_devices_client.get_devices_data.return_value[ - TEST_SERIAL_NUMBER + TEST_DEVICE_1_SN ].online = False await setup_integration(hass, mock_config_entry) @@ -92,7 +92,7 @@ async def test_offline_device( assert state.state == STATE_UNAVAILABLE mock_amazon_devices_client.get_devices_data.return_value[ - TEST_SERIAL_NUMBER + TEST_DEVICE_1_SN ].online = True freezer.tick(SCAN_INTERVAL) @@ -101,3 +101,39 @@ async def test_offline_device( assert (state := hass.states.get(entity_id)) assert state.state != STATE_UNAVAILABLE + + +async def test_dynamic_device( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_amazon_devices_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test device added dynamically.""" + + entity_id_1 = "binary_sensor.echo_test_connectivity" + entity_id_2 = "binary_sensor.echo_test_2_connectivity" + + mock_amazon_devices_client.get_devices_data.return_value = { + TEST_DEVICE_1_SN: TEST_DEVICE_1, + } + + await setup_integration(hass, mock_config_entry) + + assert (state := hass.states.get(entity_id_1)) + assert state.state == STATE_ON + + mock_amazon_devices_client.get_devices_data.return_value = { + TEST_DEVICE_1_SN: TEST_DEVICE_1, + TEST_DEVICE_2_SN: TEST_DEVICE_2, + } + + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert (state := hass.states.get(entity_id_1)) + assert state.state == STATE_ON + + assert (state := hass.states.get(entity_id_2)) + assert state.state == STATE_ON diff --git a/tests/components/alexa_devices/test_coordinator.py b/tests/components/alexa_devices/test_coordinator.py index 3768404f8717..3e0880fcd078 100644 --- a/tests/components/alexa_devices/test_coordinator.py +++ b/tests/components/alexa_devices/test_coordinator.py @@ -2,7 +2,6 @@ from unittest.mock import AsyncMock -from aioamazondevices.api import AmazonDevice, AmazonDeviceSensor from freezegun.api import FrozenDateTimeFactory from homeassistant.components.alexa_devices.coordinator import SCAN_INTERVAL @@ -10,7 +9,7 @@ from homeassistant.const import STATE_ON from homeassistant.core import HomeAssistant from . import setup_integration -from .const import TEST_DEVICE, TEST_SERIAL_NUMBER +from .const import TEST_DEVICE_1, TEST_DEVICE_1_SN, TEST_DEVICE_2, TEST_DEVICE_2_SN from tests.common import MockConfigEntry, async_fire_time_changed @@ -27,28 +26,8 @@ async def test_coordinator_stale_device( entity_id_1 = "binary_sensor.echo_test_2_connectivity" mock_amazon_devices_client.get_devices_data.return_value = { - TEST_SERIAL_NUMBER: TEST_DEVICE, - "echo_test_2_serial_number_2": AmazonDevice( - account_name="Echo Test 2", - capabilities=["AUDIO_PLAYER", "MICROPHONE"], - device_family="mine", - device_type="echo", - device_owner_customer_id="amazon_ower_id", - device_cluster_members=["echo_test_2_serial_number_2"], - online=True, - serial_number="echo_test_2_serial_number_2", - software_version="echo_test_2_software_version", - do_not_disturb=False, - response_style=None, - bluetooth_state=True, - entity_id="11111111-2222-3333-4444-555555555555", - appliance_id="G1234567890123456789012345678A", - sensors={ - "temperature": AmazonDeviceSensor( - name="temperature", value="22.5", scale="CELSIUS" - ) - }, - ), + TEST_DEVICE_1_SN: TEST_DEVICE_1, + TEST_DEVICE_2_SN: TEST_DEVICE_2, } await setup_integration(hass, mock_config_entry) @@ -59,7 +38,7 @@ async def test_coordinator_stale_device( assert state.state == STATE_ON mock_amazon_devices_client.get_devices_data.return_value = { - TEST_SERIAL_NUMBER: TEST_DEVICE, + TEST_DEVICE_1_SN: TEST_DEVICE_1, } freezer.tick(SCAN_INTERVAL) diff --git a/tests/components/alexa_devices/test_diagnostics.py b/tests/components/alexa_devices/test_diagnostics.py index 3c18d4325438..6c7a6ef4a81e 100644 --- a/tests/components/alexa_devices/test_diagnostics.py +++ b/tests/components/alexa_devices/test_diagnostics.py @@ -12,7 +12,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr from . import setup_integration -from .const import TEST_SERIAL_NUMBER +from .const import TEST_DEVICE_1_SN from tests.common import MockConfigEntry from tests.components.diagnostics import ( @@ -54,9 +54,7 @@ async def test_device_diagnostics( """Test Amazon device diagnostics.""" await setup_integration(hass, mock_config_entry) - device = device_registry.async_get_device( - identifiers={(DOMAIN, TEST_SERIAL_NUMBER)} - ) + device = device_registry.async_get_device(identifiers={(DOMAIN, TEST_DEVICE_1_SN)}) assert device, repr(device_registry.devices) assert await get_diagnostics_for_device( diff --git a/tests/components/alexa_devices/test_init.py b/tests/components/alexa_devices/test_init.py index 328654682e91..0b20b1fe239e 100644 --- a/tests/components/alexa_devices/test_init.py +++ b/tests/components/alexa_devices/test_init.py @@ -16,7 +16,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr from . import setup_integration -from .const import TEST_PASSWORD, TEST_SERIAL_NUMBER, TEST_USERNAME +from .const import TEST_DEVICE_1_SN, TEST_PASSWORD, TEST_USERNAME from tests.common import MockConfigEntry @@ -31,7 +31,7 @@ async def test_device_info( """Test device registry integration.""" await setup_integration(hass, mock_config_entry) device_entry = device_registry.async_get_device( - identifiers={(DOMAIN, TEST_SERIAL_NUMBER)} + identifiers={(DOMAIN, TEST_DEVICE_1_SN)} ) assert device_entry is not None assert device_entry == snapshot diff --git a/tests/components/alexa_devices/test_notify.py b/tests/components/alexa_devices/test_notify.py index 6067874e3706..eafea4b525c3 100644 --- a/tests/components/alexa_devices/test_notify.py +++ b/tests/components/alexa_devices/test_notify.py @@ -18,7 +18,7 @@ from homeassistant.helpers import entity_registry as er from homeassistant.util import dt as dt_util from . import setup_integration -from .const import TEST_SERIAL_NUMBER +from .const import TEST_DEVICE_1_SN from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform @@ -83,7 +83,7 @@ async def test_offline_device( entity_id = "notify.echo_test_announce" mock_amazon_devices_client.get_devices_data.return_value[ - TEST_SERIAL_NUMBER + TEST_DEVICE_1_SN ].online = False await setup_integration(hass, mock_config_entry) @@ -92,7 +92,7 @@ async def test_offline_device( assert state.state == STATE_UNAVAILABLE mock_amazon_devices_client.get_devices_data.return_value[ - TEST_SERIAL_NUMBER + TEST_DEVICE_1_SN ].online = True freezer.tick(SCAN_INTERVAL) diff --git a/tests/components/alexa_devices/test_sensor.py b/tests/components/alexa_devices/test_sensor.py index e8875fe08a44..560a7e10b90d 100644 --- a/tests/components/alexa_devices/test_sensor.py +++ b/tests/components/alexa_devices/test_sensor.py @@ -19,7 +19,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from . import setup_integration -from .const import TEST_SERIAL_NUMBER +from .const import TEST_DEVICE_1_SN from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform @@ -83,7 +83,7 @@ async def test_offline_device( entity_id = "sensor.echo_test_temperature" mock_amazon_devices_client.get_devices_data.return_value[ - TEST_SERIAL_NUMBER + TEST_DEVICE_1_SN ].online = False await setup_integration(hass, mock_config_entry) @@ -92,7 +92,7 @@ async def test_offline_device( assert state.state == STATE_UNAVAILABLE mock_amazon_devices_client.get_devices_data.return_value[ - TEST_SERIAL_NUMBER + TEST_DEVICE_1_SN ].online = True freezer.tick(SCAN_INTERVAL) @@ -133,7 +133,7 @@ async def test_unit_of_measurement( entity_id = f"sensor.echo_test_{sensor}" mock_amazon_devices_client.get_devices_data.return_value[ - TEST_SERIAL_NUMBER + TEST_DEVICE_1_SN ].sensors = {sensor: AmazonDeviceSensor(name=sensor, value=api_value, scale=scale)} await setup_integration(hass, mock_config_entry) diff --git a/tests/components/alexa_devices/test_services.py b/tests/components/alexa_devices/test_services.py index 72cef62a9662..9ea1a271a7f0 100644 --- a/tests/components/alexa_devices/test_services.py +++ b/tests/components/alexa_devices/test_services.py @@ -19,7 +19,7 @@ from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import device_registry as dr from . import setup_integration -from .const import TEST_DEVICE_ID, TEST_SERIAL_NUMBER +from .const import TEST_DEVICE_1_ID, TEST_DEVICE_1_SN from tests.common import MockConfigEntry, mock_device_registry @@ -49,7 +49,7 @@ async def test_send_sound_service( await setup_integration(hass, mock_config_entry) device_entry = device_registry.async_get_device( - identifiers={(DOMAIN, TEST_SERIAL_NUMBER)} + identifiers={(DOMAIN, TEST_DEVICE_1_SN)} ) assert device_entry @@ -79,7 +79,7 @@ async def test_send_text_service( await setup_integration(hass, mock_config_entry) device_entry = device_registry.async_get_device( - identifiers={(DOMAIN, TEST_SERIAL_NUMBER)} + identifiers={(DOMAIN, TEST_DEVICE_1_SN)} ) assert device_entry @@ -108,7 +108,7 @@ async def test_send_text_service( ), ( "wrong_sound_name", - TEST_DEVICE_ID, + TEST_DEVICE_1_ID, "invalid_sound_value", { "sound": "wrong_sound_name", @@ -128,7 +128,7 @@ async def test_invalid_parameters( """Test invalid service parameters.""" device_entry = dr.DeviceEntry( - id=TEST_DEVICE_ID, identifiers={(DOMAIN, TEST_SERIAL_NUMBER)} + id=TEST_DEVICE_1_ID, identifiers={(DOMAIN, TEST_DEVICE_1_SN)} ) mock_device_registry( hass, @@ -164,7 +164,7 @@ async def test_config_entry_not_loaded( await setup_integration(hass, mock_config_entry) device_entry = device_registry.async_get_device( - identifiers={(DOMAIN, TEST_SERIAL_NUMBER)} + identifiers={(DOMAIN, TEST_DEVICE_1_SN)} ) assert device_entry diff --git a/tests/components/alexa_devices/test_switch.py b/tests/components/alexa_devices/test_switch.py index 26a18fb731a7..c5039d68da25 100644 --- a/tests/components/alexa_devices/test_switch.py +++ b/tests/components/alexa_devices/test_switch.py @@ -23,7 +23,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from . import setup_integration -from .conftest import TEST_SERIAL_NUMBER +from .conftest import TEST_DEVICE_1_SN from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform @@ -67,7 +67,7 @@ async def test_switch_dnd( assert mock_amazon_devices_client.set_do_not_disturb.call_count == 1 mock_amazon_devices_client.get_devices_data.return_value[ - TEST_SERIAL_NUMBER + TEST_DEVICE_1_SN ].do_not_disturb = True freezer.tick(SCAN_INTERVAL) @@ -85,7 +85,7 @@ async def test_switch_dnd( ) mock_amazon_devices_client.get_devices_data.return_value[ - TEST_SERIAL_NUMBER + TEST_DEVICE_1_SN ].do_not_disturb = False freezer.tick(SCAN_INTERVAL) @@ -108,7 +108,7 @@ async def test_offline_device( entity_id = "switch.echo_test_do_not_disturb" mock_amazon_devices_client.get_devices_data.return_value[ - TEST_SERIAL_NUMBER + TEST_DEVICE_1_SN ].online = False await setup_integration(hass, mock_config_entry) @@ -117,7 +117,7 @@ async def test_offline_device( assert state.state == STATE_UNAVAILABLE mock_amazon_devices_client.get_devices_data.return_value[ - TEST_SERIAL_NUMBER + TEST_DEVICE_1_SN ].online = True freezer.tick(SCAN_INTERVAL)