diff --git a/CODEOWNERS b/CODEOWNERS index ca1135832fe98c..e178275a717b6c 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -68,6 +68,8 @@ CLAUDE.md @home-assistant/core /tests/components/agent_dvr/ @ispysoftware /homeassistant/components/ai_task/ @home-assistant/core /tests/components/ai_task/ @home-assistant/core +/homeassistant/components/aidot/ @s1eedz @HongBryan +/tests/components/aidot/ @s1eedz @HongBryan /homeassistant/components/air_quality/ @home-assistant/core /tests/components/air_quality/ @home-assistant/core /homeassistant/components/airgradient/ @airgradienthq @joostlek diff --git a/homeassistant/components/aidot/__init__.py b/homeassistant/components/aidot/__init__.py new file mode 100644 index 00000000000000..d9a438cbb0bb14 --- /dev/null +++ b/homeassistant/components/aidot/__init__.py @@ -0,0 +1,26 @@ +"""The aidot integration.""" + +from __future__ import annotations + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from .coordinator import AidotConfigEntry, AidotDeviceManagerCoordinator + +PLATFORMS: list[Platform] = [Platform.LIGHT] + + +async def async_setup_entry(hass: HomeAssistant, entry: AidotConfigEntry) -> bool: + """Set up aidot from a config entry.""" + + coordinator = AidotDeviceManagerCoordinator(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: AidotConfigEntry) -> bool: + """Unload a config entry.""" + await entry.runtime_data.async_cleanup() + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/aidot/config_flow.py b/homeassistant/components/aidot/config_flow.py new file mode 100644 index 00000000000000..e7383522d19381 --- /dev/null +++ b/homeassistant/components/aidot/config_flow.py @@ -0,0 +1,67 @@ +"""Config flow for Aidot integration.""" + +from __future__ import annotations + +from typing import Any + +from aidot.client import AidotClient +from aidot.const import CONF_LOGIN_INFO, DEFAULT_COUNTRY_CODE, SUPPORTED_COUNTRY_CODES +from aidot.exceptions import AidotUserOrPassIncorrect +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_COUNTRY_CODE, CONF_PASSWORD, CONF_USERNAME +from homeassistant.helpers import selector +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import DOMAIN + +DATA_SCHEMA = vol.Schema( + { + vol.Required( + CONF_COUNTRY_CODE, + default=DEFAULT_COUNTRY_CODE, + ): selector.CountrySelector( + selector.CountrySelectorConfig( + countries=SUPPORTED_COUNTRY_CODES, + ) + ), + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + } +) + + +class AidotConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle aidot config flow.""" + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + if user_input is not None: + client = AidotClient( + session=async_get_clientsession(self.hass), + country_code=user_input[CONF_COUNTRY_CODE], + username=user_input[CONF_USERNAME], + password=user_input[CONF_PASSWORD], + ) + await self.async_set_unique_id(client.get_identifier()) + self._abort_if_unique_id_configured() + try: + login_info = await client.async_post_login() + except AidotUserOrPassIncorrect: + errors["base"] = "invalid_auth" + + if not errors: + return self.async_create_entry( + title=f"{user_input[CONF_USERNAME]} {user_input[CONF_COUNTRY_CODE]}", + data={ + CONF_LOGIN_INFO: login_info, + }, + ) + + return self.async_show_form( + step_id="user", data_schema=DATA_SCHEMA, errors=errors + ) diff --git a/homeassistant/components/aidot/const.py b/homeassistant/components/aidot/const.py new file mode 100644 index 00000000000000..c9c9cf62463853 --- /dev/null +++ b/homeassistant/components/aidot/const.py @@ -0,0 +1,3 @@ +"""Constants for the aidot integration.""" + +DOMAIN = "aidot" diff --git a/homeassistant/components/aidot/coordinator.py b/homeassistant/components/aidot/coordinator.py new file mode 100644 index 00000000000000..f3833de42b9bc1 --- /dev/null +++ b/homeassistant/components/aidot/coordinator.py @@ -0,0 +1,162 @@ +"""Coordinator for Aidot.""" + +from datetime import timedelta +import logging + +from aidot.client import AidotClient +from aidot.const import ( + CONF_ACCESS_TOKEN, + CONF_AES_KEY, + CONF_DEVICE_LIST, + CONF_ID, + CONF_LOGIN_INFO, + CONF_TYPE, +) +from aidot.device_client import DeviceClient, DeviceStatusData +from aidot.exceptions import AidotAuthFailed, AidotUserOrPassIncorrect + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryError +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import DOMAIN + +type AidotConfigEntry = ConfigEntry[AidotDeviceManagerCoordinator] +_LOGGER = logging.getLogger(__name__) + +UPDATE_DEVICE_LIST_INTERVAL = timedelta(hours=6) + + +class AidotDeviceUpdateCoordinator(DataUpdateCoordinator[DeviceStatusData]): + """Class to manage Aidot data.""" + + def __init__( + self, + hass: HomeAssistant, + config_entry: AidotConfigEntry, + device_client: DeviceClient, + ) -> None: + """Initialize coordinator.""" + super().__init__( + hass, + _LOGGER, + config_entry=config_entry, + name=DOMAIN, + update_interval=None, + ) + self.device_client = device_client + + async def _async_setup(self) -> None: + """Set up the coordinator.""" + self.device_client.on_status_update = self._handle_status_update + + def _handle_status_update(self, status: DeviceStatusData) -> None: + """Handle status callback.""" + self.async_set_updated_data(status) + + async def _async_update_data(self) -> DeviceStatusData: + """Return current status.""" + return self.device_client.status + + +class AidotDeviceManagerCoordinator(DataUpdateCoordinator[None]): + """Class to manage fetching Aidot data.""" + + config_entry: AidotConfigEntry + + def __init__( + self, + hass: HomeAssistant, + config_entry: AidotConfigEntry, + ) -> None: + """Initialize coordinator.""" + super().__init__( + hass, + _LOGGER, + config_entry=config_entry, + name=DOMAIN, + update_interval=UPDATE_DEVICE_LIST_INTERVAL, + ) + self.client = AidotClient( + session=async_get_clientsession(hass), + token=config_entry.data[CONF_LOGIN_INFO], + ) + self.client.set_token_fresh_cb(self.token_fresh_cb) + self.device_coordinators: dict[str, AidotDeviceUpdateCoordinator] = {} + + async def _async_setup(self) -> None: + """Set up the coordinator.""" + try: + await self.async_auto_login() + except AidotUserOrPassIncorrect as error: + raise ConfigEntryAuthFailed from error + + async def _async_update_data(self) -> None: + """Update data async.""" + try: + data = await self.client.async_get_all_device() + except AidotAuthFailed as error: + self.token_fresh_cb() + raise ConfigEntryError from error + current_devices = { + device[CONF_ID]: device + for device in data[CONF_DEVICE_LIST] + if ( + device[CONF_TYPE] == "light" + and CONF_AES_KEY in device + and device[CONF_AES_KEY][0] is not None + ) + } + + removed_ids = set(self.device_coordinators) - set(current_devices) + for dev_id in removed_ids: + del self.device_coordinators[dev_id] + if removed_ids: + self._purge_deleted_lists() + + for dev_id, device in current_devices.items(): + if dev_id not in self.device_coordinators: + device_client = self.client.get_device_client(device) + device_coordinator = AidotDeviceUpdateCoordinator( + self.hass, self.config_entry, device_client + ) + await device_coordinator.async_config_entry_first_refresh() + self.device_coordinators[dev_id] = device_coordinator + + async def async_cleanup(self) -> None: + """Perform cleanup actions.""" + await self.client.async_cleanup() + + def token_fresh_cb(self) -> None: + """Update token.""" + self.hass.config_entries.async_update_entry( + self.config_entry, data={CONF_LOGIN_INFO: self.client.login_info.copy()} + ) + + async def async_auto_login(self) -> None: + """Async auto login.""" + if self.client.login_info.get(CONF_ACCESS_TOKEN) is None: + await self.client.async_post_login() + + def _purge_deleted_lists(self) -> None: + """Purge device entries of deleted lists.""" + + device_reg = dr.async_get(self.hass) + identifiers = { + ( + DOMAIN, + f"{device_coordinator.device_client.info.dev_id}", + ) + for device_coordinator in self.device_coordinators.values() + } + for device in dr.async_entries_for_config_entry( + device_reg, self.config_entry.entry_id + ): + if not set(device.identifiers) & identifiers: + _LOGGER.debug("Removing obsolete device entry %s", device.name) + device_reg.async_update_device( + device.id, remove_config_entry_id=self.config_entry.entry_id + ) diff --git a/homeassistant/components/aidot/light.py b/homeassistant/components/aidot/light.py new file mode 100644 index 00000000000000..61f16cae070a74 --- /dev/null +++ b/homeassistant/components/aidot/light.py @@ -0,0 +1,122 @@ +"""Support for Aidot lights.""" + +from typing import Any + +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + ATTR_COLOR_TEMP_KELVIN, + ATTR_RGBW_COLOR, + ColorMode, + LightEntity, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.device_registry import ( + CONNECTION_NETWORK_MAC, + DeviceInfo, + format_mac, +) +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import AidotConfigEntry, AidotDeviceUpdateCoordinator + + +async def async_setup_entry( + hass: HomeAssistant, + entry: AidotConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Light.""" + coordinator = entry.runtime_data + async_add_entities( + AidotLight(device_coordinator) + for device_coordinator in coordinator.device_coordinators.values() + ) + + +class AidotLight(CoordinatorEntity[AidotDeviceUpdateCoordinator], LightEntity): + """Representation of a Aidot Wi-Fi Light.""" + + _attr_has_entity_name = True + _attr_name = None + + def __init__(self, coordinator: AidotDeviceUpdateCoordinator) -> None: + """Initialize the light.""" + super().__init__(coordinator) + self._attr_unique_id = coordinator.device_client.info.dev_id + if hasattr(coordinator.device_client.info, "cct_max"): + self._attr_max_color_temp_kelvin = coordinator.device_client.info.cct_max + if hasattr(coordinator.device_client.info, "cct_min"): + self._attr_min_color_temp_kelvin = coordinator.device_client.info.cct_min + + model_id = coordinator.device_client.info.model_id + manufacturer = model_id.split(".")[0] + model = model_id[len(manufacturer) + 1 :] + mac = format_mac(coordinator.device_client.info.mac) + + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self._attr_unique_id)}, + connections={(CONNECTION_NETWORK_MAC, mac)}, + manufacturer=manufacturer, + model=model, + name=coordinator.device_client.info.name, + hw_version=coordinator.device_client.info.hw_version, + ) + if coordinator.device_client.info.enable_rgbw: + self._attr_color_mode = ColorMode.RGBW + self._attr_supported_color_modes = {ColorMode.RGBW, ColorMode.COLOR_TEMP} + elif coordinator.device_client.info.enable_cct: + self._attr_color_mode = ColorMode.COLOR_TEMP + self._attr_supported_color_modes = {ColorMode.COLOR_TEMP} + else: + self._attr_color_mode = ColorMode.BRIGHTNESS + self._attr_supported_color_modes = {ColorMode.BRIGHTNESS} + self._update_status() + + def _update_status(self) -> None: + """Update light status from coordinator data.""" + self._attr_available = self.coordinator.data.online + self._attr_is_on = self.coordinator.data.on + self._attr_brightness = self.coordinator.data.dimming + self._attr_color_temp_kelvin = self.coordinator.data.cct + self._attr_rgbw_color = self.coordinator.data.rgbw + + @property + def available(self) -> bool: + """Return if entity is available.""" + return self.coordinator.data.online + + @callback + def _handle_coordinator_update(self) -> None: + """Update.""" + self._update_status() + super()._handle_coordinator_update() + + async def async_turn_on(self, **kwargs: Any) -> None: + """Fix brightness state synchronization by updating the coordinator's `dimming` field.""" + if ATTR_BRIGHTNESS in kwargs: + brightness = kwargs.get(ATTR_BRIGHTNESS, 255) + await self.coordinator.device_client.async_set_brightness(brightness) + self.coordinator.data.dimming = brightness + elif ATTR_COLOR_TEMP_KELVIN in kwargs: + color_temp_kelvin = kwargs.get(ATTR_COLOR_TEMP_KELVIN) + await self.coordinator.device_client.async_set_cct(color_temp_kelvin) + self.coordinator.data.cct = color_temp_kelvin + self._attr_color_mode = ColorMode.COLOR_TEMP + elif ATTR_RGBW_COLOR in kwargs: + rgbw_color = kwargs.get(ATTR_RGBW_COLOR) + await self.coordinator.device_client.async_set_rgbw(rgbw_color) + self.coordinator.data.rgbw = rgbw_color + self._attr_color_mode = ColorMode.RGBW + else: + await self.coordinator.device_client.async_turn_on() + + self.coordinator.data.on = True + self._attr_is_on = True + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the light off.""" + await self.coordinator.device_client.async_turn_off() + self.coordinator.data.on = False + self._attr_is_on = False diff --git a/homeassistant/components/aidot/manifest.json b/homeassistant/components/aidot/manifest.json new file mode 100644 index 00000000000000..0be205f2a791da --- /dev/null +++ b/homeassistant/components/aidot/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "aidot", + "name": "AiDot", + "codeowners": ["@s1eedz", "@HongBryan"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/aidot", + "integration_type": "hub", + "iot_class": "local_polling", + "quality_scale": "bronze", + "requirements": ["python-aidot==0.3.49"] +} diff --git a/homeassistant/components/aidot/quality_scale.yaml b/homeassistant/components/aidot/quality_scale.yaml new file mode 100644 index 00000000000000..a08b8e14d781db --- /dev/null +++ b/homeassistant/components/aidot/quality_scale.yaml @@ -0,0 +1,67 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: This integration does not provide additional 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: This integration does not provide additional actions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: This integration does not register any 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: + status: exempt + comment: This integration has no option flow. + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: todo + reauthentication-flow: todo + test-coverage: todo + + # 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: todo + entity-device-class: todo + entity-translations: done + exception-translations: todo + icon-translations: todo + reconfiguration-flow: todo + repair-issues: todo + stale-devices: todo + entity-disabled-by-default: todo + # Platinum + async-dependency: done + inject-websession: todo + strict-typing: todo diff --git a/homeassistant/components/aidot/strings.json b/homeassistant/components/aidot/strings.json new file mode 100644 index 00000000000000..47af81044acd8e --- /dev/null +++ b/homeassistant/components/aidot/strings.json @@ -0,0 +1,24 @@ +{ + "config": { + "abort": { + "already_configured": "The account in the current region has already been configured" + }, + "error": { + "invalid_auth": "Authentication failed, please ensure that the network is functioning properly and the account password is correct." + }, + "step": { + "user": { + "data": { + "country_code": "Country", + "password": "[%key:common::config_flow::data::password%]", + "username": "[%key:common::config_flow::data::username%]" + }, + "data_description": { + "country_code": "The country selected by Aidot app when logging in", + "password": "Password for logging in through Aidot app", + "username": "Account logged in through Aidot app" + } + } + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index eb103b00ced2e1..bbcbf47a2d01df 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -35,6 +35,7 @@ "aemet", "aftership", "agent_dvr", + "aidot", "airgradient", "airly", "airnow", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 1f9c0286ca3708..d23f25b285eb36 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -105,6 +105,12 @@ "config_flow": true, "iot_class": "local_polling" }, + "aidot": { + "name": "AiDot Lights Local", + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_polling" + }, "airgradient": { "name": "AirGradient", "integration_type": "device", diff --git a/requirements_all.txt b/requirements_all.txt index 5d60045d5f17f1..759abcef3f800f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2550,6 +2550,9 @@ pythinkingcleaner==0.0.3 # homeassistant.components.motionmount python-MotionMount==2.3.0 +# homeassistant.components.aidot +python-aidot==0.3.49 + # homeassistant.components.awair python-awair==0.2.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7dcbafe210f448..974824862217c9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2182,6 +2182,9 @@ pytautulli==23.1.1 # homeassistant.components.motionmount python-MotionMount==2.3.0 +# homeassistant.components.aidot +python-aidot==0.3.49 + # homeassistant.components.awair python-awair==0.2.5 diff --git a/tests/components/aidot/__init__.py b/tests/components/aidot/__init__.py new file mode 100644 index 00000000000000..418311f51cc67e --- /dev/null +++ b/tests/components/aidot/__init__.py @@ -0,0 +1,14 @@ +"""Tests for the aidot integration.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def async_init_integration( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Set up the Aidot integration for testing.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/aidot/conftest.py b/tests/components/aidot/conftest.py new file mode 100644 index 00000000000000..90848f17c4bccc --- /dev/null +++ b/tests/components/aidot/conftest.py @@ -0,0 +1,136 @@ +"""Common fixtures for the aidot tests.""" + +from collections.abc import Generator +from typing import Any +from unittest.mock import AsyncMock, MagicMock, Mock, patch + +from aidot.client import AidotClient +from aidot.const import ( + CONF_ACCESS_TOKEN, + CONF_HARDWARE_VERSION, + CONF_ID, + CONF_LOGIN_INFO, + CONF_MAC, + CONF_MODEL_ID, + CONF_NAME, + CONF_REGION, +) +from aidot.device_client import DeviceClient, DeviceInformation, DeviceStatusData +import pytest + +from homeassistant.components.aidot.const import DOMAIN +from homeassistant.const import CONF_COUNTRY_CODE, CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import callback + +from .const import ( + TEST_COUNTRY, + TEST_DEVICE1, + TEST_DEVICE_LIST, + TEST_EMAIL, + TEST_LOGIN_RESP, + TEST_PASSWORD, + TEST_REGION, +) + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.aidot.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + domain=DOMAIN, + unique_id=f"{TEST_REGION}-{TEST_EMAIL}", + title=TEST_EMAIL, + data={ + CONF_LOGIN_INFO: { + CONF_USERNAME: TEST_EMAIL, + CONF_PASSWORD: TEST_PASSWORD, + CONF_REGION: TEST_REGION, + CONF_COUNTRY_CODE: TEST_COUNTRY, + CONF_ACCESS_TOKEN: "123456789", + CONF_ID: "123456", + } + }, + ) + + +def create_device_client(device: dict[str, Any]) -> MagicMock: + """Create DeviceClient.""" + mock_device_client = MagicMock(spec=DeviceClient) + mock_device_client.device_id = device.get(CONF_ID) + + mock_info = Mock(spec=DeviceInformation) + mock_info.enable_rgbw = True + mock_info.enable_dimming = True + mock_info.enable_cct = True + mock_info.cct_min = 2700 + mock_info.cct_max = 6500 + mock_info.dev_id = device.get(CONF_ID) + mock_info.mac = device.get(CONF_MAC) + mock_info.model_id = device.get(CONF_MODEL_ID) + mock_info.name = device.get(CONF_NAME) + mock_info.hw_version = device.get(CONF_HARDWARE_VERSION) + mock_device_client.info = mock_info + + status = Mock(spec=DeviceStatusData) + status.online = True + status.dimming = 255 + status.cct = 3000 + status.on = True + status.rgbw = (255, 255, 255, 255) + mock_device_client.status = status + mock_device_client.read_status = AsyncMock(return_value=status) + + return mock_device_client + + +@pytest.fixture +def mocked_device_client() -> MagicMock: + """Fixture DeviceClient.""" + return create_device_client(TEST_DEVICE1) + + +@pytest.fixture +def mocked_aidot_client(mocked_device_client: MagicMock) -> MagicMock: + """Fixture AidotClient.""" + + @callback + def get_device_client(device: dict[str, Any]): + if device.get(CONF_ID) == "device_id": + return mocked_device_client + return create_device_client(device) + + mock_aidot_client = MagicMock(spec=AidotClient) + mock_aidot_client.get_device_client = get_device_client + mock_aidot_client.async_get_all_device.return_value = TEST_DEVICE_LIST + mock_aidot_client.async_post_login.return_value = TEST_LOGIN_RESP + mock_aidot_client.get_identifier.return_value = f"{TEST_REGION}-{TEST_EMAIL}" + return mock_aidot_client + + +@pytest.fixture(autouse=True) +def patch_aidot_client( + mocked_aidot_client: MagicMock, +) -> Generator[None]: + """Patch AidotClient.""" + with ( + patch( + "homeassistant.components.aidot.config_flow.AidotClient", + return_value=mocked_aidot_client, + ), + patch( + "homeassistant.components.aidot.coordinator.AidotClient", + return_value=mocked_aidot_client, + ), + ): + yield diff --git a/tests/components/aidot/const.py b/tests/components/aidot/const.py new file mode 100644 index 00000000000000..af55a493072b54 --- /dev/null +++ b/tests/components/aidot/const.py @@ -0,0 +1,64 @@ +"""Const for the aidot tests.""" + +from aidot.const import CONF_DEVICE_LIST + +TEST_COUNTRY = "US" +TEST_EMAIL = "test@gmail.com" +TEST_PASSWORD = "123456" +TEST_REGION = "us" + +TEST_LOGIN_RESP = { + "id": "314159263367458941151", + "accessToken": "1234567891011121314151617181920", + "refreshToken": "2021222324252627282930313233343", + "expiresIn": 10000, + "nickname": TEST_EMAIL, + "username": TEST_EMAIL, +} + +ENTITY_LIGHT = "light.test_light" +ENTITY_LIGHT2 = "light.test_light2" +LIGHT_DOMAIN = "light" + +TEST_DEVICE1 = { + "id": "device_id", + "name": "Test Light", + "modelId": "aidot.light.rgbw", + "mac": "AA:BB:CC:DD:EE:FF", + "hardwareVersion": "1.0", + "type": "light", + "aesKey": ["mock_aes_key"], + "product": { + "id": "test_product", + "serviceModules": [ + {"identity": "control.light.rgbw"}, + { + "identity": "control.light.cct", + "properties": [{"identity": "CCT", "maxValue": 6500, "minValue": 2700}], + }, + ], + }, +} + +TEST_DEVICE2 = { + "id": "device_id2", + "name": "Test Light2", + "modelId": "aidot.light.rgbw", + "mac": "AA:BB:CC:DD:EE:EE", + "hardwareVersion": "1.0", + "type": "light", + "aesKey": ["mock_aes_key"], + "product": { + "id": "test_product", + "serviceModules": [ + {"identity": "control.light.rgbw"}, + { + "identity": "control.light.cct", + "properties": [{"identity": "CCT", "maxValue": 6500, "minValue": 2700}], + }, + ], + }, +} + +TEST_DEVICE_LIST = {CONF_DEVICE_LIST: [TEST_DEVICE1]} +TEST_MULTI_DEVICE_LIST = {CONF_DEVICE_LIST: [TEST_DEVICE1, TEST_DEVICE2]} diff --git a/tests/components/aidot/snapshots/test_light.ambr b/tests/components/aidot/snapshots/test_light.ambr new file mode 100644 index 00000000000000..7e01550a5fcd52 --- /dev/null +++ b/tests/components/aidot/snapshots/test_light.ambr @@ -0,0 +1,87 @@ +# serializer version: 1 +# name: test_state[light.test_light-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'max_color_temp_kelvin': 6500, + 'min_color_temp_kelvin': 2700, + 'supported_color_modes': list([ + , + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.test_light', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'aidot', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'device_id', + 'unit_of_measurement': None, + }) +# --- +# name: test_state[light.test_light-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': 255, + 'color_mode': , + 'color_temp_kelvin': None, + 'friendly_name': 'Test Light', + 'hs_color': tuple( + 0.0, + 0.0, + ), + 'max_color_temp_kelvin': 6500, + 'min_color_temp_kelvin': 2700, + 'rgb_color': tuple( + 255, + 255, + 255, + ), + 'rgbw_color': tuple( + 255, + 255, + 255, + 255, + ), + 'supported_color_modes': list([ + , + , + ]), + 'supported_features': , + 'xy_color': tuple( + 0.323, + 0.329, + ), + }), + 'context': , + 'entity_id': 'light.test_light', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/aidot/test_config_flow.py b/tests/components/aidot/test_config_flow.py new file mode 100644 index 00000000000000..386861911463d3 --- /dev/null +++ b/tests/components/aidot/test_config_flow.py @@ -0,0 +1,110 @@ +"""Test the aidot config flow.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, MagicMock + +from aidot.const import CONF_LOGIN_INFO +from aidot.exceptions import AidotUserOrPassIncorrect + +from homeassistant import config_entries +from homeassistant.components.aidot.const import DOMAIN +from homeassistant.const import CONF_COUNTRY_CODE, CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from .conftest import TEST_COUNTRY, TEST_EMAIL, TEST_LOGIN_RESP, TEST_PASSWORD + +from tests.common import MockConfigEntry + + +async def test_config_flow_cloud_login_success( + hass: HomeAssistant, mock_setup_entry: Generator[AsyncMock] +) -> None: + """Test a successful config flow using cloud login.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_COUNTRY_CODE: TEST_COUNTRY, + CONF_USERNAME: TEST_EMAIL, + CONF_PASSWORD: TEST_PASSWORD, + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == f"{TEST_EMAIL} {TEST_COUNTRY}" + assert result["data"] == {CONF_LOGIN_INFO: TEST_LOGIN_RESP} + + +async def test_config_flow_login_user_password_incorrect( + hass: HomeAssistant, mocked_aidot_client: MagicMock +) -> None: + """Test a failed config flow using cloud connect error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + mocked_aidot_client.async_post_login = AsyncMock( + side_effect=AidotUserOrPassIncorrect() + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_COUNTRY_CODE: TEST_COUNTRY, + CONF_USERNAME: TEST_EMAIL, + CONF_PASSWORD: "ErrorPassword", + }, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "invalid_auth"} + + mocked_aidot_client.async_post_login.side_effect = None + mocked_aidot_client.async_post_login.return_value = TEST_LOGIN_RESP + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_COUNTRY_CODE: TEST_COUNTRY, + CONF_USERNAME: TEST_EMAIL, + CONF_PASSWORD: TEST_PASSWORD, + }, + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == {CONF_LOGIN_INFO: TEST_LOGIN_RESP} + + +async def test_form_abort_already_configured( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test we abort if already configured.""" + mock_config_entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_COUNTRY_CODE: TEST_COUNTRY, + CONF_USERNAME: TEST_EMAIL, + CONF_PASSWORD: TEST_PASSWORD, + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" diff --git a/tests/components/aidot/test_init.py b/tests/components/aidot/test_init.py new file mode 100644 index 00000000000000..80af83da2afdbc --- /dev/null +++ b/tests/components/aidot/test_init.py @@ -0,0 +1,47 @@ +"""Test aidot.""" + +from unittest.mock import MagicMock + +from aidot.const import CONF_ACCESS_TOKEN, CONF_LOGIN_INFO +from aidot.exceptions import AidotUserOrPassIncorrect + +from homeassistant.components.aidot.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from . import async_init_integration + +from tests.common import MockConfigEntry + + +async def test_async_unload_entry( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test that async_unload_entry unloads the component correctly.""" + await async_init_integration(hass, mock_config_entry) + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert mock_config_entry.state is ConfigEntryState.LOADED + + assert await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED + assert not hass.data.get(DOMAIN) + + +async def test_async_setup_entry_auth_failed( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mocked_aidot_client: MagicMock, +) -> None: + """Test setup fails with auth error.""" + mocked_aidot_client.async_post_login.side_effect = AidotUserOrPassIncorrect() + # Remove access token to trigger login + mock_config_entry.data[CONF_LOGIN_INFO].pop(CONF_ACCESS_TOKEN, None) + + 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 ConfigEntryState.SETUP_ERROR diff --git a/tests/components/aidot/test_light.py b/tests/components/aidot/test_light.py new file mode 100644 index 00000000000000..303baae9a67e7c --- /dev/null +++ b/tests/components/aidot/test_light.py @@ -0,0 +1,201 @@ +"""Test the aidot device.""" + +from unittest.mock import AsyncMock, MagicMock, Mock + +from aidot.const import CONF_DEVICE_LIST +from aidot.device_client import DeviceStatusData +from aidot.exceptions import AidotAuthFailed +from freezegun.api import FrozenDateTimeFactory +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.aidot.coordinator import UPDATE_DEVICE_LIST_INTERVAL +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + ATTR_COLOR_TEMP_KELVIN, + ATTR_RGBW_COLOR, +) +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + STATE_ON, + STATE_UNAVAILABLE, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import async_init_integration +from .const import ENTITY_LIGHT, LIGHT_DOMAIN + +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform + + +async def test_state( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + await async_init_integration(hass, mock_config_entry) + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_turn_on( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mocked_device_client: MagicMock, +) -> None: + """Test turn on.""" + await async_init_integration(hass, mock_config_entry) + + assert hass.states.get(ENTITY_LIGHT).state == STATE_ON + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: ENTITY_LIGHT}, + blocking=True, + ) + mocked_device_client.async_turn_on.assert_called_once() + + +async def test_turn_off( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mocked_device_client: MagicMock, +) -> None: + """Test turn off.""" + await async_init_integration(hass, mock_config_entry) + + assert hass.states.get(ENTITY_LIGHT).state == STATE_ON + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: ENTITY_LIGHT}, + blocking=True, + ) + mocked_device_client.async_turn_off.assert_called_once() + + +async def test_turn_on_brightness( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mocked_device_client: MagicMock, +) -> None: + """Test turn on brightness.""" + await async_init_integration(hass, mock_config_entry) + + assert hass.states.get(ENTITY_LIGHT).state == STATE_ON + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: ENTITY_LIGHT, ATTR_BRIGHTNESS: 100}, + blocking=True, + ) + mocked_device_client.async_set_brightness.assert_called_once() + + +async def test_turn_on_with_color_temp( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mocked_device_client: MagicMock, +) -> None: + """Test turn on with color temp.""" + await async_init_integration(hass, mock_config_entry) + + assert hass.states.get(ENTITY_LIGHT).state == STATE_ON + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: ENTITY_LIGHT, ATTR_COLOR_TEMP_KELVIN: 3000}, + blocking=True, + ) + mocked_device_client.async_set_cct.assert_called_once() + + +async def test_turn_on_with_rgbw( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mocked_device_client: MagicMock, +) -> None: + """Test turn on with rgbw.""" + await async_init_integration(hass, mock_config_entry) + + assert hass.states.get(ENTITY_LIGHT).state == STATE_ON + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: ENTITY_LIGHT, ATTR_RGBW_COLOR: (255, 255, 255, 255)}, + blocking=True, + ) + mocked_device_client.async_set_rgbw.assert_called_once() + + +async def test_light_unavailable( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mocked_device_client: MagicMock, +) -> None: + """Test light becomes unavailable when device goes offline.""" + await async_init_integration(hass, mock_config_entry) + + assert hass.states.get(ENTITY_LIGHT).state == STATE_ON + + # Simulate device going offline + mocked_device_client.status.online = False + status = Mock(spec=DeviceStatusData) + status.online = False + status.on = False + status.dimming = 0 + status.cct = 0 + status.rgbw = (0, 0, 0, 0) + + # Trigger coordinator update via callback + coordinator = mock_config_entry.runtime_data.device_coordinators["device_id"] + coordinator.async_set_updated_data(status) + await hass.async_block_till_done() + + assert hass.states.get(ENTITY_LIGHT).state == STATE_UNAVAILABLE + + +async def test_coordinator_auth_failed( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mocked_aidot_client: MagicMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test coordinator handles auth failure during update.""" + await async_init_integration(hass, mock_config_entry) + + mocked_aidot_client.async_get_all_device = AsyncMock(side_effect=AidotAuthFailed()) + freezer.tick(UPDATE_DEVICE_LIST_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert mock_config_entry.state is not None + + +async def test_coordinator_device_removal( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mocked_aidot_client: MagicMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test coordinator handles device removal.""" + await async_init_integration(hass, mock_config_entry) + + assert hass.states.get(ENTITY_LIGHT) is not None + + # Return empty device list + mocked_aidot_client.async_get_all_device.return_value = {CONF_DEVICE_LIST: []} + freezer.tick(UPDATE_DEVICE_LIST_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + coordinator = mock_config_entry.runtime_data + assert len(coordinator.device_coordinators) == 0