From d0b2331a5f58681f6beea8c3d5f01a7fa3712540 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Sun, 25 May 2025 18:42:07 +0300 Subject: [PATCH] New integration Amazon Devices (#144422) * New integration Amazon Devices * apply review comments * bump aioamazondevices * Add notify platform * pylance * full coverage for coordinator tests * cleanup imports * Add switch platform * update quality scale: docs items * update quality scale: brands * apply review comments * fix new ruff rule * simplify EntityDescription code * remove additional platforms for first PR * apply review comments * update IQS * apply last review comments * snapshot update * apply review comments * apply review comments --- .strict-typing | 1 + CODEOWNERS | 2 + homeassistant/brands/amazon.json | 1 + .../components/amazon_devices/__init__.py | 28 ++++ .../amazon_devices/binary_sensor.py | 72 ++++++++++ .../components/amazon_devices/config_flow.py | 63 ++++++++ .../components/amazon_devices/const.py | 8 ++ .../components/amazon_devices/coordinator.py | 58 ++++++++ .../components/amazon_devices/entity.py | 57 ++++++++ .../components/amazon_devices/icons.json | 12 ++ .../components/amazon_devices/manifest.json | 12 ++ .../amazon_devices/quality_scale.yaml | 72 ++++++++++ .../components/amazon_devices/strings.json | 47 ++++++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + mypy.ini | 10 ++ requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/amazon_devices/__init__.py | 13 ++ tests/components/amazon_devices/conftest.py | 76 ++++++++++ tests/components/amazon_devices/const.py | 7 + .../snapshots/test_binary_sensor.ambr | 97 +++++++++++++ .../amazon_devices/snapshots/test_init.ambr | 34 +++++ .../amazon_devices/test_binary_sensor.py | 71 ++++++++++ .../amazon_devices/test_config_flow.py | 134 ++++++++++++++++++ tests/components/amazon_devices/test_init.py | 30 ++++ 26 files changed, 918 insertions(+) create mode 100644 homeassistant/components/amazon_devices/__init__.py create mode 100644 homeassistant/components/amazon_devices/binary_sensor.py create mode 100644 homeassistant/components/amazon_devices/config_flow.py create mode 100644 homeassistant/components/amazon_devices/const.py create mode 100644 homeassistant/components/amazon_devices/coordinator.py create mode 100644 homeassistant/components/amazon_devices/entity.py create mode 100644 homeassistant/components/amazon_devices/icons.json create mode 100644 homeassistant/components/amazon_devices/manifest.json create mode 100644 homeassistant/components/amazon_devices/quality_scale.yaml create mode 100644 homeassistant/components/amazon_devices/strings.json create mode 100644 tests/components/amazon_devices/__init__.py create mode 100644 tests/components/amazon_devices/conftest.py create mode 100644 tests/components/amazon_devices/const.py create mode 100644 tests/components/amazon_devices/snapshots/test_binary_sensor.ambr create mode 100644 tests/components/amazon_devices/snapshots/test_init.ambr create mode 100644 tests/components/amazon_devices/test_binary_sensor.py create mode 100644 tests/components/amazon_devices/test_config_flow.py create mode 100644 tests/components/amazon_devices/test_init.py diff --git a/.strict-typing b/.strict-typing index 7cd54374616..4febfd68486 100644 --- a/.strict-typing +++ b/.strict-typing @@ -66,6 +66,7 @@ homeassistant.components.alarm_control_panel.* homeassistant.components.alert.* homeassistant.components.alexa.* homeassistant.components.alpha_vantage.* +homeassistant.components.amazon_devices.* homeassistant.components.amazon_polly.* homeassistant.components.amberelectric.* homeassistant.components.ambient_network.* diff --git a/CODEOWNERS b/CODEOWNERS index 5bc9a2dd8d7..25c842cc6fa 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -89,6 +89,8 @@ build.json @home-assistant/supervisor /tests/components/alert/ @home-assistant/core @frenck /homeassistant/components/alexa/ @home-assistant/cloud @ochlocracy @jbouwh /tests/components/alexa/ @home-assistant/cloud @ochlocracy @jbouwh +/homeassistant/components/amazon_devices/ @chemelli74 +/tests/components/amazon_devices/ @chemelli74 /homeassistant/components/amazon_polly/ @jschlyter /homeassistant/components/amberelectric/ @madpilot /tests/components/amberelectric/ @madpilot diff --git a/homeassistant/brands/amazon.json b/homeassistant/brands/amazon.json index 624a8a17b7d..d2e25468388 100644 --- a/homeassistant/brands/amazon.json +++ b/homeassistant/brands/amazon.json @@ -3,6 +3,7 @@ "name": "Amazon", "integrations": [ "alexa", + "amazon_devices", "amazon_polly", "aws", "aws_s3", diff --git a/homeassistant/components/amazon_devices/__init__.py b/homeassistant/components/amazon_devices/__init__.py new file mode 100644 index 00000000000..a7318824b4c --- /dev/null +++ b/homeassistant/components/amazon_devices/__init__.py @@ -0,0 +1,28 @@ +"""Amazon Devices integration.""" + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from .coordinator import AmazonConfigEntry, AmazonDevicesCoordinator + +PLATFORMS = [Platform.BINARY_SENSOR] + + +async def async_setup_entry(hass: HomeAssistant, entry: AmazonConfigEntry) -> bool: + """Set up Amazon Devices platform.""" + + coordinator = AmazonDevicesCoordinator(hass, entry) + + await coordinator.async_config_entry_first_refresh() + + entry.runtime_data = coordinator + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: AmazonConfigEntry) -> bool: + """Unload a config entry.""" + await entry.runtime_data.api.close() + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/amazon_devices/binary_sensor.py b/homeassistant/components/amazon_devices/binary_sensor.py new file mode 100644 index 00000000000..0528ffbe1e4 --- /dev/null +++ b/homeassistant/components/amazon_devices/binary_sensor.py @@ -0,0 +1,72 @@ +"""Support for binary sensors.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from typing import Final + +from aioamazondevices.api import AmazonDevice + +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, + BinarySensorEntityDescription, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import AmazonConfigEntry +from .entity import AmazonEntity + +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + + +@dataclass(frozen=True, kw_only=True) +class AmazonBinarySensorEntityDescription(BinarySensorEntityDescription): + """Amazon Devices binary sensor entity description.""" + + is_on_fn: Callable[[AmazonDevice], bool] + + +BINARY_SENSORS: Final = ( + AmazonBinarySensorEntityDescription( + key="online", + device_class=BinarySensorDeviceClass.CONNECTIVITY, + is_on_fn=lambda _device: _device.online, + ), + AmazonBinarySensorEntityDescription( + key="bluetooth", + translation_key="bluetooth", + device_class=BinarySensorDeviceClass.CONNECTIVITY, + is_on_fn=lambda _device: _device.bluetooth_state, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: AmazonConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Amazon Devices binary sensors based on a config 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 + ) + + +class AmazonBinarySensorEntity(AmazonEntity, BinarySensorEntity): + """Binary sensor device.""" + + entity_description: AmazonBinarySensorEntityDescription + + @property + def is_on(self) -> bool: + """Return True if the binary sensor is on.""" + return self.entity_description.is_on_fn(self.device) diff --git a/homeassistant/components/amazon_devices/config_flow.py b/homeassistant/components/amazon_devices/config_flow.py new file mode 100644 index 00000000000..5566c16602b --- /dev/null +++ b/homeassistant/components/amazon_devices/config_flow.py @@ -0,0 +1,63 @@ +"""Config flow for Amazon Devices integration.""" + +from __future__ import annotations + +from typing import Any + +from aioamazondevices.api import AmazonEchoApi +from aioamazondevices.exceptions import CannotAuthenticate, CannotConnect +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_CODE, CONF_COUNTRY, CONF_PASSWORD, CONF_USERNAME +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.selector import CountrySelector + +from .const import CONF_LOGIN_DATA, DOMAIN + + +class AmazonDevicesConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Amazon Devices.""" + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + errors = {} + if user_input: + client = AmazonEchoApi( + user_input[CONF_COUNTRY], + user_input[CONF_USERNAME], + user_input[CONF_PASSWORD], + ) + try: + data = await client.login_mode_interactive(user_input[CONF_CODE]) + except CannotConnect: + errors["base"] = "cannot_connect" + except CannotAuthenticate: + errors["base"] = "invalid_auth" + else: + await self.async_set_unique_id(data["customer_info"]["user_id"]) + self._abort_if_unique_id_configured() + user_input.pop(CONF_CODE) + return self.async_create_entry( + title=user_input[CONF_USERNAME], + data=user_input | {CONF_LOGIN_DATA: data}, + ) + finally: + await client.close() + + return self.async_show_form( + step_id="user", + errors=errors, + data_schema=vol.Schema( + { + vol.Required( + CONF_COUNTRY, default=self.hass.config.country + ): CountrySelector(), + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Required(CONF_CODE): cv.positive_int, + } + ), + ) diff --git a/homeassistant/components/amazon_devices/const.py b/homeassistant/components/amazon_devices/const.py new file mode 100644 index 00000000000..b8cf2c264b1 --- /dev/null +++ b/homeassistant/components/amazon_devices/const.py @@ -0,0 +1,8 @@ +"""Amazon Devices constants.""" + +import logging + +_LOGGER = logging.getLogger(__package__) + +DOMAIN = "amazon_devices" +CONF_LOGIN_DATA = "login_data" diff --git a/homeassistant/components/amazon_devices/coordinator.py b/homeassistant/components/amazon_devices/coordinator.py new file mode 100644 index 00000000000..48e31cb3f94 --- /dev/null +++ b/homeassistant/components/amazon_devices/coordinator.py @@ -0,0 +1,58 @@ +"""Support for Amazon Devices.""" + +from datetime import timedelta + +from aioamazondevices.api import AmazonDevice, AmazonEchoApi +from aioamazondevices.exceptions import ( + CannotAuthenticate, + CannotConnect, + CannotRetrieveData, +) + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_COUNTRY, CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryError +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import _LOGGER, CONF_LOGIN_DATA + +SCAN_INTERVAL = 30 + +type AmazonConfigEntry = ConfigEntry[AmazonDevicesCoordinator] + + +class AmazonDevicesCoordinator(DataUpdateCoordinator[dict[str, AmazonDevice]]): + """Base coordinator for Amazon Devices.""" + + config_entry: AmazonConfigEntry + + def __init__( + self, + hass: HomeAssistant, + entry: AmazonConfigEntry, + ) -> None: + """Initialize the scanner.""" + super().__init__( + hass, + _LOGGER, + name=entry.title, + config_entry=entry, + update_interval=timedelta(seconds=SCAN_INTERVAL), + ) + self.api = AmazonEchoApi( + entry.data[CONF_COUNTRY], + entry.data[CONF_USERNAME], + entry.data[CONF_PASSWORD], + entry.data[CONF_LOGIN_DATA], + ) + + async def _async_update_data(self) -> dict[str, AmazonDevice]: + """Update device data.""" + try: + await self.api.login_mode_stored_data() + return await self.api.get_devices_data() + except (CannotConnect, CannotRetrieveData) as err: + raise UpdateFailed(f"Error occurred while updating {self.name}") from err + except CannotAuthenticate as err: + raise ConfigEntryError("Could not authenticate") from err diff --git a/homeassistant/components/amazon_devices/entity.py b/homeassistant/components/amazon_devices/entity.py new file mode 100644 index 00000000000..2ac90410bec --- /dev/null +++ b/homeassistant/components/amazon_devices/entity.py @@ -0,0 +1,57 @@ +"""Defines a base Amazon Devices entity.""" + +from typing import cast + +from aioamazondevices.api import AmazonDevice +from aioamazondevices.const import DEVICE_TYPE_TO_MODEL, SPEAKER_GROUP_MODEL + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import EntityDescription +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import AmazonDevicesCoordinator + + +class AmazonEntity(CoordinatorEntity[AmazonDevicesCoordinator]): + """Defines a base Amazon Devices entity.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: AmazonDevicesCoordinator, + serial_num: str, + description: EntityDescription, + ) -> None: + """Initialize the entity.""" + super().__init__(coordinator) + self._serial_num = serial_num + model_details: dict[str, str] = cast( + "dict", DEVICE_TYPE_TO_MODEL.get(self.device.device_type) + ) + model = model_details["model"] if model_details else None + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, serial_num)}, + name=self.device.account_name, + model=model, + model_id=self.device.device_type, + manufacturer="Amazon", + hw_version=model_details["hw_version"] if model_details else None, + sw_version=( + self.device.software_version if model != SPEAKER_GROUP_MODEL else None + ), + serial_number=serial_num if model != SPEAKER_GROUP_MODEL else None, + ) + self.entity_description = description + self._attr_unique_id = f"{serial_num}-{description.key}" + + @property + def device(self) -> AmazonDevice: + """Return the device.""" + return self.coordinator.data[self._serial_num] + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return super().available and self._serial_num in self.coordinator.data diff --git a/homeassistant/components/amazon_devices/icons.json b/homeassistant/components/amazon_devices/icons.json new file mode 100644 index 00000000000..e3b20eb2c4a --- /dev/null +++ b/homeassistant/components/amazon_devices/icons.json @@ -0,0 +1,12 @@ +{ + "entity": { + "binary_sensor": { + "bluetooth": { + "default": "mdi:bluetooth", + "state": { + "off": "mdi:bluetooth-off" + } + } + } + } +} diff --git a/homeassistant/components/amazon_devices/manifest.json b/homeassistant/components/amazon_devices/manifest.json new file mode 100644 index 00000000000..675433387bb --- /dev/null +++ b/homeassistant/components/amazon_devices/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "amazon_devices", + "name": "Amazon Devices", + "codeowners": ["@chemelli74"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/amazon_devices", + "integration_type": "hub", + "iot_class": "cloud_polling", + "loggers": ["aioamazondevices"], + "quality_scale": "bronze", + "requirements": ["aioamazondevices==2.0.1"] +} diff --git a/homeassistant/components/amazon_devices/quality_scale.yaml b/homeassistant/components/amazon_devices/quality_scale.yaml new file mode 100644 index 00000000000..1234fd574a3 --- /dev/null +++ b/homeassistant/components/amazon_devices/quality_scale.yaml @@ -0,0 +1,72 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: no actions + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: no actions + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: entities do not explicitly subscribe to events + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: todo + config-entry-unloading: done + docs-configuration-parameters: todo + docs-installation-parameters: todo + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: todo + test-coverage: + status: todo + comment: all tests missing + + # Gold + devices: done + diagnostics: todo + discovery-update-info: todo + discovery: todo + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: todo + docs-supported-functions: todo + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: todo + entity-category: done + entity-device-class: done + entity-disabled-by-default: done + entity-translations: done + exception-translations: todo + icon-translations: done + reconfiguration-flow: todo + repair-issues: + status: exempt + comment: no known use cases for repair issues or flows, yet + stale-devices: + status: todo + comment: automate the cleanup process + + # Platinum + async-dependency: done + inject-websession: todo + strict-typing: done diff --git a/homeassistant/components/amazon_devices/strings.json b/homeassistant/components/amazon_devices/strings.json new file mode 100644 index 00000000000..edc10aa9d40 --- /dev/null +++ b/homeassistant/components/amazon_devices/strings.json @@ -0,0 +1,47 @@ +{ + "common": { + "data_country": "Country code", + "data_code": "One-time password (OTP code)", + "data_description_country": "The country of your Amazon account.", + "data_description_username": "The email address of your Amazon account.", + "data_description_password": "The password of your Amazon account.", + "data_description_code": "The one-time password sent to your email address." + }, + "config": { + "flow_title": "{username}", + "step": { + "user": { + "data": { + "country": "[%key:component::amazon_devices::common::data_country%]", + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]", + "code": "[%key:component::amazon_devices::common::data_description_code%]" + }, + "data_description": { + "country": "[%key:component::amazon_devices::common::data_description_country%]", + "username": "[%key:component::amazon_devices::common::data_description_username%]", + "password": "[%key:component::amazon_devices::common::data_description_password%]", + "code": "[%key:component::amazon_devices::common::data_description_code%]" + } + } + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + } + }, + "entity": { + "binary_sensor": { + "bluetooth": { + "name": "Bluetooth" + } + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 43db3f5be10..1cba78af0b0 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -47,6 +47,7 @@ FLOWS = { "airzone", "airzone_cloud", "alarmdecoder", + "amazon_devices", "amberelectric", "ambient_network", "ambient_station", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 9357424dc76..66693d41396 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -207,6 +207,12 @@ "amazon": { "name": "Amazon", "integrations": { + "amazon_devices": { + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_polling", + "name": "Amazon Devices" + }, "amazon_polly": { "integration_type": "hub", "config_flow": false, diff --git a/mypy.ini b/mypy.ini index f09e68bdcbe..da76e4ae2cd 100644 --- a/mypy.ini +++ b/mypy.ini @@ -415,6 +415,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.amazon_devices.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.amazon_polly.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index a7f8bdcc110..3e722a9b329 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -181,6 +181,9 @@ aioairzone-cloud==0.6.12 # homeassistant.components.airzone aioairzone==1.0.0 +# homeassistant.components.amazon_devices +aioamazondevices==2.0.1 + # homeassistant.components.ambient_network # homeassistant.components.ambient_station aioambient==2024.08.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1914f1abf88..c9d2e340806 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -169,6 +169,9 @@ aioairzone-cloud==0.6.12 # homeassistant.components.airzone aioairzone==1.0.0 +# homeassistant.components.amazon_devices +aioamazondevices==2.0.1 + # homeassistant.components.ambient_network # homeassistant.components.ambient_station aioambient==2024.08.0 diff --git a/tests/components/amazon_devices/__init__.py b/tests/components/amazon_devices/__init__.py new file mode 100644 index 00000000000..47ee520b124 --- /dev/null +++ b/tests/components/amazon_devices/__init__.py @@ -0,0 +1,13 @@ +"""Tests for the Amazon Devices integration.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Fixture for setting up the component.""" + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/amazon_devices/conftest.py b/tests/components/amazon_devices/conftest.py new file mode 100644 index 00000000000..5978faa0b31 --- /dev/null +++ b/tests/components/amazon_devices/conftest.py @@ -0,0 +1,76 @@ +"""Amazon Devices tests configuration.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +from aioamazondevices.api import AmazonDevice +import pytest + +from homeassistant.components.amazon_devices.const import CONF_LOGIN_DATA, DOMAIN +from homeassistant.const import CONF_COUNTRY, CONF_PASSWORD, CONF_USERNAME + +from .const import TEST_COUNTRY, TEST_PASSWORD, TEST_SERIAL_NUMBER, TEST_USERNAME + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.amazon_devices.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_amazon_devices_client() -> Generator[AsyncMock]: + """Mock an Amazon Devices client.""" + with ( + patch( + "homeassistant.components.amazon_devices.coordinator.AmazonEchoApi", + autospec=True, + ) as mock_client, + patch( + "homeassistant.components.amazon_devices.config_flow.AmazonEchoApi", + new=mock_client, + ), + ): + client = mock_client.return_value + client.login_mode_interactive.return_value = { + "customer_info": {"user_id": TEST_USERNAME}, + } + client.get_devices_data.return_value = { + TEST_SERIAL_NUMBER: 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], + online=True, + serial_number=TEST_SERIAL_NUMBER, + software_version="echo_test_software_version", + do_not_disturb=False, + response_style=None, + bluetooth_state=True, + ) + } + yield client + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Mock a config entry.""" + return MockConfigEntry( + domain=DOMAIN, + title="Amazon Test Account", + data={ + CONF_COUNTRY: TEST_COUNTRY, + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + CONF_LOGIN_DATA: {"session": "test-session"}, + }, + unique_id=TEST_USERNAME, + ) diff --git a/tests/components/amazon_devices/const.py b/tests/components/amazon_devices/const.py new file mode 100644 index 00000000000..94b5b7052e6 --- /dev/null +++ b/tests/components/amazon_devices/const.py @@ -0,0 +1,7 @@ +"""Amazon Devices tests const.""" + +TEST_CODE = 123123 +TEST_COUNTRY = "IT" +TEST_PASSWORD = "fake_password" +TEST_SERIAL_NUMBER = "echo_test_serial_number" +TEST_USERNAME = "fake_email@gmail.com" diff --git a/tests/components/amazon_devices/snapshots/test_binary_sensor.ambr b/tests/components/amazon_devices/snapshots/test_binary_sensor.ambr new file mode 100644 index 00000000000..647fa39540f --- /dev/null +++ b/tests/components/amazon_devices/snapshots/test_binary_sensor.ambr @@ -0,0 +1,97 @@ +# serializer version: 1 +# name: test_all_entities[binary_sensor.echo_test_bluetooth-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.echo_test_bluetooth', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Bluetooth', + 'platform': 'amazon_devices', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'bluetooth', + 'unique_id': 'echo_test_serial_number-bluetooth', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.echo_test_bluetooth-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'connectivity', + 'friendly_name': 'Echo Test Bluetooth', + }), + 'context': , + 'entity_id': 'binary_sensor.echo_test_bluetooth', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[binary_sensor.echo_test_connectivity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.echo_test_connectivity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Connectivity', + 'platform': 'amazon_devices', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'echo_test_serial_number-online', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.echo_test_connectivity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'connectivity', + 'friendly_name': 'Echo Test Connectivity', + }), + 'context': , + 'entity_id': 'binary_sensor.echo_test_connectivity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/amazon_devices/snapshots/test_init.ambr b/tests/components/amazon_devices/snapshots/test_init.ambr new file mode 100644 index 00000000000..be0a5894eea --- /dev/null +++ b/tests/components/amazon_devices/snapshots/test_init.ambr @@ -0,0 +1,34 @@ +# serializer version: 1 +# name: test_device_info + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'amazon_devices', + 'echo_test_serial_number', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Amazon', + 'model': None, + 'model_id': 'echo', + 'name': 'Echo Test', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': 'echo_test_serial_number', + 'suggested_area': None, + 'sw_version': 'echo_test_software_version', + 'via_device_id': None, + }) +# --- diff --git a/tests/components/amazon_devices/test_binary_sensor.py b/tests/components/amazon_devices/test_binary_sensor.py new file mode 100644 index 00000000000..bbe8af17a8e --- /dev/null +++ b/tests/components/amazon_devices/test_binary_sensor.py @@ -0,0 +1,71 @@ +"""Tests for the Amazon Devices binary sensor platform.""" + +from unittest.mock import AsyncMock, patch + +from aioamazondevices.exceptions import ( + CannotAuthenticate, + CannotConnect, + CannotRetrieveData, +) +from freezegun.api import FrozenDateTimeFactory +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.amazon_devices.coordinator import SCAN_INTERVAL +from homeassistant.const import STATE_ON, STATE_UNAVAILABLE, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +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.amazon_devices.PLATFORMS", [Platform.BINARY_SENSOR] + ): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.parametrize( + "side_effect", + [ + CannotConnect, + CannotRetrieveData, + CannotAuthenticate, + ], +) +async def test_coordinator_data_update_fails( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_amazon_devices_client: AsyncMock, + mock_config_entry: MockConfigEntry, + side_effect: Exception, +) -> None: + """Test coordinator data update exceptions.""" + + entity_id = "binary_sensor.echo_test_connectivity" + + await setup_integration(hass, mock_config_entry) + + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_ON + + mock_amazon_devices_client.get_devices_data.side_effect = side_effect + + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_UNAVAILABLE diff --git a/tests/components/amazon_devices/test_config_flow.py b/tests/components/amazon_devices/test_config_flow.py new file mode 100644 index 00000000000..e60ae9543a3 --- /dev/null +++ b/tests/components/amazon_devices/test_config_flow.py @@ -0,0 +1,134 @@ +"""Tests for the Amazon Devices config flow.""" + +from unittest.mock import AsyncMock + +from aioamazondevices.exceptions import CannotAuthenticate, CannotConnect +import pytest + +from homeassistant.components.amazon_devices.const import CONF_LOGIN_DATA, DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_CODE, CONF_COUNTRY, CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from .const import TEST_CODE, TEST_COUNTRY, TEST_PASSWORD, TEST_USERNAME + +from tests.common import MockConfigEntry + + +async def test_full_flow( + hass: HomeAssistant, + mock_amazon_devices_client: AsyncMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test full flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_COUNTRY: TEST_COUNTRY, + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + CONF_CODE: TEST_CODE, + }, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == TEST_USERNAME + assert result["data"] == { + CONF_COUNTRY: TEST_COUNTRY, + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + CONF_LOGIN_DATA: { + "customer_info": {"user_id": TEST_USERNAME}, + }, + } + assert result["result"].unique_id == TEST_USERNAME + + +@pytest.mark.parametrize( + ("exception", "error"), + [ + (CannotConnect, "cannot_connect"), + (CannotAuthenticate, "invalid_auth"), + ], +) +async def test_flow_errors( + hass: HomeAssistant, + mock_amazon_devices_client: AsyncMock, + mock_setup_entry: AsyncMock, + exception: Exception, + error: str, +) -> None: + """Test flow errors.""" + mock_amazon_devices_client.login_mode_interactive.side_effect = exception + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_COUNTRY: TEST_COUNTRY, + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + CONF_CODE: TEST_CODE, + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": error} + + mock_amazon_devices_client.login_mode_interactive.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_COUNTRY: TEST_COUNTRY, + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + CONF_CODE: TEST_CODE, + }, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + + +async def test_already_configured( + hass: HomeAssistant, + mock_amazon_devices_client: AsyncMock, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test duplicate flow.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_COUNTRY: TEST_COUNTRY, + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + CONF_CODE: TEST_CODE, + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" diff --git a/tests/components/amazon_devices/test_init.py b/tests/components/amazon_devices/test_init.py new file mode 100644 index 00000000000..489952dbd4c --- /dev/null +++ b/tests/components/amazon_devices/test_init.py @@ -0,0 +1,30 @@ +"""Tests for the Amazon Devices integration.""" + +from unittest.mock import AsyncMock + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.amazon_devices.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from . import setup_integration +from .const import TEST_SERIAL_NUMBER + +from tests.common import MockConfigEntry + + +async def test_device_info( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_amazon_devices_client: AsyncMock, + mock_config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, +) -> None: + """Test device registry integration.""" + await setup_integration(hass, mock_config_entry) + device_entry = device_registry.async_get_device( + identifiers={(DOMAIN, TEST_SERIAL_NUMBER)} + ) + assert device_entry is not None + assert device_entry == snapshot