diff --git a/CODEOWNERS b/CODEOWNERS index c68c96f4f24..5a130d0278b 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -316,6 +316,8 @@ build.json @home-assistant/supervisor /tests/components/crownstone/ @Crownstone @RicArch97 /homeassistant/components/cups/ @fabaff /tests/components/cups/ @fabaff +/homeassistant/components/cync/ @Kinachi249 +/tests/components/cync/ @Kinachi249 /homeassistant/components/daikin/ @fredrike /tests/components/daikin/ @fredrike /homeassistant/components/date/ @home-assistant/core diff --git a/homeassistant/components/cync/__init__.py b/homeassistant/components/cync/__init__.py new file mode 100644 index 00000000000..a2fa7ad509a --- /dev/null +++ b/homeassistant/components/cync/__init__.py @@ -0,0 +1,58 @@ +"""The Cync integration.""" + +from __future__ import annotations + +from pycync import Auth, Cync, User +from pycync.exceptions import AuthFailedError, CyncError + +from homeassistant.const import CONF_ACCESS_TOKEN, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import ( + CONF_AUTHORIZE_STRING, + CONF_EXPIRES_AT, + CONF_REFRESH_TOKEN, + CONF_USER_ID, +) +from .coordinator import CyncConfigEntry, CyncCoordinator + +_PLATFORMS: list[Platform] = [Platform.LIGHT] + + +async def async_setup_entry(hass: HomeAssistant, entry: CyncConfigEntry) -> bool: + """Set up Cync from a config entry.""" + user_info = User( + entry.data[CONF_ACCESS_TOKEN], + entry.data[CONF_REFRESH_TOKEN], + entry.data[CONF_AUTHORIZE_STRING], + entry.data[CONF_USER_ID], + expires_at=entry.data[CONF_EXPIRES_AT], + ) + cync_auth = Auth(async_get_clientsession(hass), user=user_info) + + try: + cync = await Cync.create(cync_auth) + except AuthFailedError as ex: + raise ConfigEntryAuthFailed("User token invalid") from ex + except CyncError as ex: + raise ConfigEntryNotReady("Unable to connect to Cync") from ex + + devices_coordinator = CyncCoordinator(hass, entry, cync) + + cync.set_update_callback(devices_coordinator.on_data_update) + + await devices_coordinator.async_config_entry_first_refresh() + entry.runtime_data = devices_coordinator + + await hass.config_entries.async_forward_entry_setups(entry, _PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: CyncConfigEntry) -> bool: + """Unload a config entry.""" + cync = entry.runtime_data.cync + await cync.shut_down() + return await hass.config_entries.async_unload_platforms(entry, _PLATFORMS) diff --git a/homeassistant/components/cync/config_flow.py b/homeassistant/components/cync/config_flow.py new file mode 100644 index 00000000000..b10f1c03cc3 --- /dev/null +++ b/homeassistant/components/cync/config_flow.py @@ -0,0 +1,118 @@ +"""Config flow for the Cync integration.""" + +from __future__ import annotations + +import logging +from typing import Any + +from pycync import Auth +from pycync.exceptions import AuthFailedError, CyncError, TwoFactorRequiredError +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_EMAIL, CONF_PASSWORD +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import ( + CONF_AUTHORIZE_STRING, + CONF_EXPIRES_AT, + CONF_REFRESH_TOKEN, + CONF_TWO_FACTOR_CODE, + CONF_USER_ID, + DOMAIN, +) + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_EMAIL): str, + vol.Required(CONF_PASSWORD): str, + } +) + +STEP_TWO_FACTOR_SCHEMA = vol.Schema({vol.Required(CONF_TWO_FACTOR_CODE): str}) + + +class CyncConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Cync.""" + + VERSION = 1 + + cync_auth: Auth + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Attempt login with user credentials.""" + errors: dict[str, str] = {} + + if user_input is None: + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + ) + + self.cync_auth = Auth( + async_get_clientsession(self.hass), + username=user_input[CONF_EMAIL], + password=user_input[CONF_PASSWORD], + ) + try: + await self.cync_auth.login() + except AuthFailedError: + errors["base"] = "invalid_auth" + except TwoFactorRequiredError: + return await self.async_step_two_factor() + except CyncError: + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + return await self._create_config_entry(self.cync_auth.username) + + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + ) + + async def async_step_two_factor( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Attempt login with the two factor auth code sent to the user.""" + errors: dict[str, str] = {} + + if user_input is None: + return self.async_show_form( + step_id="two_factor", data_schema=STEP_TWO_FACTOR_SCHEMA, errors=errors + ) + try: + await self.cync_auth.login(user_input[CONF_TWO_FACTOR_CODE]) + except AuthFailedError: + errors["base"] = "invalid_auth" + except CyncError: + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + return await self._create_config_entry(self.cync_auth.username) + + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + ) + + async def _create_config_entry(self, user_email: str) -> ConfigFlowResult: + """Create the Cync config entry using input user data.""" + + cync_user = self.cync_auth.user + await self.async_set_unique_id(str(cync_user.user_id)) + self._abort_if_unique_id_configured() + + config = { + CONF_USER_ID: cync_user.user_id, + CONF_AUTHORIZE_STRING: cync_user.authorize, + CONF_EXPIRES_AT: cync_user.expires_at, + CONF_ACCESS_TOKEN: cync_user.access_token, + CONF_REFRESH_TOKEN: cync_user.refresh_token, + } + return self.async_create_entry(title=user_email, data=config) diff --git a/homeassistant/components/cync/const.py b/homeassistant/components/cync/const.py new file mode 100644 index 00000000000..410863b624d --- /dev/null +++ b/homeassistant/components/cync/const.py @@ -0,0 +1,9 @@ +"""Constants for the Cync integration.""" + +DOMAIN = "cync" + +CONF_TWO_FACTOR_CODE = "two_factor_code" +CONF_USER_ID = "user_id" +CONF_AUTHORIZE_STRING = "authorize_string" +CONF_EXPIRES_AT = "expires_at" +CONF_REFRESH_TOKEN = "refresh_token" diff --git a/homeassistant/components/cync/coordinator.py b/homeassistant/components/cync/coordinator.py new file mode 100644 index 00000000000..84bfa6d0fee --- /dev/null +++ b/homeassistant/components/cync/coordinator.py @@ -0,0 +1,87 @@ +"""Coordinator to handle keeping device states up to date.""" + +from __future__ import annotations + +from datetime import timedelta +import logging +import time + +from pycync import Cync, CyncDevice, User +from pycync.exceptions import AuthFailedError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_ACCESS_TOKEN +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import CONF_EXPIRES_AT, CONF_REFRESH_TOKEN + +_LOGGER = logging.getLogger(__name__) + +type CyncConfigEntry = ConfigEntry[CyncCoordinator] + + +class CyncCoordinator(DataUpdateCoordinator[dict[int, CyncDevice]]): + """Coordinator to handle updating Cync device states.""" + + config_entry: CyncConfigEntry + + def __init__( + self, hass: HomeAssistant, config_entry: CyncConfigEntry, cync: Cync + ) -> None: + """Initialize the Cync coordinator.""" + super().__init__( + hass, + _LOGGER, + name="Cync Data Coordinator", + config_entry=config_entry, + update_interval=timedelta(seconds=30), + always_update=True, + ) + self.cync = cync + + async def on_data_update(self, data: dict[int, CyncDevice]) -> None: + """Update registered devices with new data.""" + merged_data = self.data | data if self.data else data + self.async_set_updated_data(merged_data) + + async def _async_setup(self) -> None: + """Set up the coordinator with initial device states.""" + logged_in_user = self.cync.get_logged_in_user() + if logged_in_user.access_token != self.config_entry.data[CONF_ACCESS_TOKEN]: + await self._update_config_cync_credentials(logged_in_user) + + async def _async_update_data(self) -> dict[int, CyncDevice]: + """First, refresh the user's auth token if it is set to expire in less than one hour. + + Then, fetch all current device states. + """ + + logged_in_user = self.cync.get_logged_in_user() + if logged_in_user.expires_at - time.time() < 3600: + await self._async_refresh_cync_credentials() + + self.cync.update_device_states() + current_device_states = self.cync.get_devices() + + return {device.device_id: device for device in current_device_states} + + async def _async_refresh_cync_credentials(self) -> None: + """Attempt to refresh the Cync user's authentication token.""" + + try: + refreshed_user = await self.cync.refresh_credentials() + except AuthFailedError as ex: + raise ConfigEntryAuthFailed("Unable to refresh user token") from ex + else: + await self._update_config_cync_credentials(refreshed_user) + + async def _update_config_cync_credentials(self, user_info: User) -> None: + """Update the config entry with current user info.""" + + new_data = {**self.config_entry.data} + new_data[CONF_ACCESS_TOKEN] = user_info.access_token + new_data[CONF_REFRESH_TOKEN] = user_info.refresh_token + new_data[CONF_EXPIRES_AT] = user_info.expires_at + self.hass.config_entries.async_update_entry(self.config_entry, data=new_data) diff --git a/homeassistant/components/cync/entity.py b/homeassistant/components/cync/entity.py new file mode 100644 index 00000000000..c2946615e1c --- /dev/null +++ b/homeassistant/components/cync/entity.py @@ -0,0 +1,45 @@ +"""Setup for a generic entity type for the Cync integration.""" + +from pycync.devices import CyncDevice + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import CyncCoordinator + + +class CyncBaseEntity(CoordinatorEntity[CyncCoordinator]): + """Generic base entity for Cync devices.""" + + _attr_has_entity_name = True + + def __init__( + self, + device: CyncDevice, + coordinator: CyncCoordinator, + room_name: str | None = None, + ) -> None: + """Pass coordinator to CoordinatorEntity.""" + super().__init__(coordinator) + + self._cync_device_id = device.device_id + self._attr_unique_id = device.unique_id + + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, device.unique_id)}, + manufacturer="GE Lighting", + name=device.name, + suggested_area=room_name, + ) + + @property + def available(self) -> bool: + """Determines whether this device is currently available.""" + + return ( + super().available + and self.coordinator.data is not None + and self._cync_device_id in self.coordinator.data + and self.coordinator.data[self._cync_device_id].is_online + ) diff --git a/homeassistant/components/cync/light.py b/homeassistant/components/cync/light.py new file mode 100644 index 00000000000..8604beab417 --- /dev/null +++ b/homeassistant/components/cync/light.py @@ -0,0 +1,180 @@ +"""Support for Cync light entities.""" + +from typing import Any + +from pycync import CyncLight +from pycync.devices.capabilities import CyncCapability + +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + ATTR_COLOR_TEMP_KELVIN, + ATTR_RGB_COLOR, + ColorMode, + LightEntity, + filter_supported_color_modes, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.util.color import value_to_brightness +from homeassistant.util.scaling import scale_ranged_value_to_int_range + +from .coordinator import CyncConfigEntry, CyncCoordinator +from .entity import CyncBaseEntity + + +async def async_setup_entry( + hass: HomeAssistant, + entry: CyncConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Cync lights from a config entry.""" + + coordinator = entry.runtime_data + cync = coordinator.cync + + entities_to_add = [] + + for home in cync.get_homes(): + for room in home.rooms: + room_lights = [ + CyncLightEntity(device, coordinator, room.name) + for device in room.devices + if isinstance(device, CyncLight) + ] + entities_to_add.extend(room_lights) + + group_lights = [ + CyncLightEntity(device, coordinator, room.name) + for group in room.groups + for device in group.devices + if isinstance(device, CyncLight) + ] + entities_to_add.extend(group_lights) + + async_add_entities(entities_to_add) + + +class CyncLightEntity(CyncBaseEntity, LightEntity): + """Representation of a Cync light.""" + + _attr_color_mode = ColorMode.ONOFF + _attr_min_color_temp_kelvin = 2000 + _attr_max_color_temp_kelvin = 7000 + _attr_translation_key = "light" + _attr_name = None + + BRIGHTNESS_SCALE = (0, 100) + + def __init__( + self, + device: CyncLight, + coordinator: CyncCoordinator, + room_name: str | None = None, + ) -> None: + """Set up base attributes.""" + super().__init__(device, coordinator, room_name) + + supported_color_modes = {ColorMode.ONOFF} + if device.supports_capability(CyncCapability.CCT_COLOR): + supported_color_modes.add(ColorMode.COLOR_TEMP) + if device.supports_capability(CyncCapability.DIMMING): + supported_color_modes.add(ColorMode.BRIGHTNESS) + if device.supports_capability(CyncCapability.RGB_COLOR): + supported_color_modes.add(ColorMode.RGB) + self._attr_supported_color_modes = filter_supported_color_modes( + supported_color_modes + ) + + @property + def is_on(self) -> bool | None: + """Return True if the light is on.""" + return self._device.is_on + + @property + def brightness(self) -> int: + """Provide the light's current brightness.""" + return value_to_brightness(self.BRIGHTNESS_SCALE, self._device.brightness) + + @property + def color_temp_kelvin(self) -> int: + """Return color temperature in kelvin.""" + return scale_ranged_value_to_int_range( + (1, 100), + (self.min_color_temp_kelvin, self.max_color_temp_kelvin), + self._device.color_temp, + ) + + @property + def rgb_color(self) -> tuple[int, int, int]: + """Provide the light's current color in RGB format.""" + return self._device.rgb + + @property + def color_mode(self) -> str | None: + """Return the active color mode.""" + + if ( + self._device.supports_capability(CyncCapability.CCT_COLOR) + and self._device.color_mode > 0 + and self._device.color_mode <= 100 + ): + return ColorMode.COLOR_TEMP + if ( + self._device.supports_capability(CyncCapability.RGB_COLOR) + and self._device.color_mode == 254 + ): + return ColorMode.RGB + if self._device.supports_capability(CyncCapability.DIMMING): + return ColorMode.BRIGHTNESS + + return ColorMode.ONOFF + + async def async_turn_on(self, **kwargs: Any) -> None: + """Process an action on the light.""" + if not kwargs: + await self._device.turn_on() + + elif kwargs.get(ATTR_COLOR_TEMP_KELVIN) is not None: + color_temp = kwargs.get(ATTR_COLOR_TEMP_KELVIN) + converted_color_temp = self._normalize_color_temp(color_temp) + + await self._device.set_color_temp(converted_color_temp) + elif kwargs.get(ATTR_RGB_COLOR) is not None: + rgb = kwargs.get(ATTR_RGB_COLOR) + + await self._device.set_rgb(rgb) + elif kwargs.get(ATTR_BRIGHTNESS) is not None: + brightness = kwargs.get(ATTR_BRIGHTNESS) + converted_brightness = self._normalize_brightness(brightness) + + await self._device.set_brightness(converted_brightness) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off the light.""" + await self._device.turn_off() + + def _normalize_brightness(self, brightness: float | None) -> int | None: + """Return calculated brightness value scaled between 0-100.""" + if brightness is not None: + return int((brightness / 255) * 100) + + return None + + def _normalize_color_temp(self, color_temp_kelvin: float | None) -> int | None: + """Return calculated color temp value scaled between 1-100.""" + if color_temp_kelvin is not None: + kelvin_range = self.max_color_temp_kelvin - self.min_color_temp_kelvin + scaled_kelvin = int( + ((color_temp_kelvin - self.min_color_temp_kelvin) / kelvin_range) * 100 + ) + if scaled_kelvin == 0: + scaled_kelvin += 1 + + return scaled_kelvin + return None + + @property + def _device(self) -> CyncLight: + """Fetch the reference to the backing Cync light for this device.""" + + return self.coordinator.data[self._cync_device_id] diff --git a/homeassistant/components/cync/manifest.json b/homeassistant/components/cync/manifest.json new file mode 100644 index 00000000000..d02b6ed1d9b --- /dev/null +++ b/homeassistant/components/cync/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "cync", + "name": "Cync", + "codeowners": ["@Kinachi249"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/cync", + "integration_type": "hub", + "iot_class": "cloud_push", + "quality_scale": "bronze", + "requirements": ["pycync==0.4.0"] +} diff --git a/homeassistant/components/cync/quality_scale.yaml b/homeassistant/components/cync/quality_scale.yaml new file mode 100644 index 00000000000..7e106cdd49e --- /dev/null +++ b/homeassistant/components/cync/quality_scale.yaml @@ -0,0 +1,69 @@ +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: done + 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: + status: exempt + comment: | + This integration does not provide additional actions. + config-entry-unloading: done + docs-configuration-parameters: todo + docs-installation-parameters: todo + entity-unavailable: todo + integration-owner: done + log-when-unavailable: todo + 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: done + docs-supported-devices: todo + docs-supported-functions: done + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: todo + entity-category: todo + entity-device-class: todo + entity-disabled-by-default: todo + entity-translations: todo + exception-translations: todo + icon-translations: todo + reconfiguration-flow: todo + repair-issues: todo + stale-devices: todo + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: todo diff --git a/homeassistant/components/cync/strings.json b/homeassistant/components/cync/strings.json new file mode 100644 index 00000000000..0515c053cfc --- /dev/null +++ b/homeassistant/components/cync/strings.json @@ -0,0 +1,32 @@ +{ + "config": { + "step": { + "user": { + "data": { + "email": "[%key:common::config_flow::data::email%]", + "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "email": "Your Cync account's email address", + "password": "Your Cync account's password" + } + }, + "two_factor": { + "data": { + "two_factor_code": "Two-factor code" + }, + "data_description": { + "two_factor_code": "The two-factor code sent to your Cync account's email" + } + } + }, + "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%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]" + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 711c9f793e2..03b8f57c6eb 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -127,6 +127,7 @@ FLOWS = { "coolmaster", "cpuspeed", "crownstone", + "cync", "daikin", "datadog", "deako", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 8ab7e165dcf..e260b37afe6 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -1163,6 +1163,12 @@ "config_flow": false, "iot_class": "cloud_polling" }, + "cync": { + "name": "Cync", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_push" + }, "dacia": { "name": "Dacia", "integration_type": "virtual", diff --git a/requirements_all.txt b/requirements_all.txt index c92bc0b3d1c..1f16fc78a34 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1929,6 +1929,9 @@ pycsspeechtts==1.0.8 # homeassistant.components.cups # pycups==2.0.4 +# homeassistant.components.cync +pycync==0.4.0 + # homeassistant.components.daikin pydaikin==2.16.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5264bd7150e..48ad0d5f077 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1622,6 +1622,9 @@ pycsspeechtts==1.0.8 # homeassistant.components.cups # pycups==2.0.4 +# homeassistant.components.cync +pycync==0.4.0 + # homeassistant.components.daikin pydaikin==2.16.0 diff --git a/tests/components/cync/__init__.py b/tests/components/cync/__init__.py new file mode 100644 index 00000000000..56cab084f99 --- /dev/null +++ b/tests/components/cync/__init__.py @@ -0,0 +1,15 @@ +"""Tests for the Cync integration.""" + +from __future__ import annotations + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Sets up the Cync integration to be used in testing.""" + 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/cync/conftest.py b/tests/components/cync/conftest.py new file mode 100644 index 00000000000..2ea6e352a75 --- /dev/null +++ b/tests/components/cync/conftest.py @@ -0,0 +1,91 @@ +"""Common fixtures for the Cync tests.""" + +from collections.abc import Generator +import time +from unittest.mock import AsyncMock, patch + +from pycync import Cync, CyncHome +import pytest + +from homeassistant.components.cync.const import ( + CONF_AUTHORIZE_STRING, + CONF_EXPIRES_AT, + CONF_REFRESH_TOKEN, + CONF_USER_ID, + DOMAIN, +) +from homeassistant.const import CONF_ACCESS_TOKEN + +from .const import MOCKED_EMAIL, MOCKED_USER + +from tests.common import MockConfigEntry, load_json_object_fixture + + +@pytest.fixture(autouse=True) +def auth_client(): + """Mock a pycync.Auth client.""" + with patch( + "homeassistant.components.cync.config_flow.Auth", autospec=True + ) as sc_class_mock: + client_mock = sc_class_mock.return_value + client_mock.user = MOCKED_USER + client_mock.username = MOCKED_EMAIL + yield client_mock + + +@pytest.fixture(autouse=True) +def cync_client(): + """Mock a pycync.Cync client.""" + with ( + patch( + "homeassistant.components.cync.coordinator.Cync", + spec=Cync, + ) as cync_mock, + patch( + "homeassistant.components.cync.Cync", + new=cync_mock, + ), + ): + cync_mock.get_logged_in_user.return_value = MOCKED_USER + + home_fixture: CyncHome = CyncHome.from_dict( + load_json_object_fixture("home.json", DOMAIN) + ) + cync_mock.get_homes.return_value = [home_fixture] + + available_mock_devices = [ + device + for device in home_fixture.get_flattened_device_list() + if device.is_online + ] + cync_mock.get_devices.return_value = available_mock_devices + + cync_mock.create.return_value = cync_mock + client_mock = cync_mock.return_value + yield client_mock + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.cync.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Mock a Cync config entry.""" + return MockConfigEntry( + domain=DOMAIN, + title=MOCKED_EMAIL, + unique_id=str(MOCKED_USER.user_id), + data={ + CONF_USER_ID: MOCKED_USER.user_id, + CONF_AUTHORIZE_STRING: "test_authorize_string", + CONF_EXPIRES_AT: (time.time() * 1000) + 3600000, + CONF_ACCESS_TOKEN: "test_token", + CONF_REFRESH_TOKEN: "test_refresh_token", + }, + ) diff --git a/tests/components/cync/const.py b/tests/components/cync/const.py new file mode 100644 index 00000000000..79f7e8b8b21 --- /dev/null +++ b/tests/components/cync/const.py @@ -0,0 +1,14 @@ +"""Test constants used in Cync tests.""" + +import time + +import pycync + +MOCKED_USER = pycync.User( + "test_token", + "test_refresh_token", + "test_authorize_string", + 123456789, + expires_at=(time.time() * 1000) + 3600000, +) +MOCKED_EMAIL = "test@testuser.com" diff --git a/tests/components/cync/fixtures/home.json b/tests/components/cync/fixtures/home.json new file mode 100644 index 00000000000..22e009de965 --- /dev/null +++ b/tests/components/cync/fixtures/home.json @@ -0,0 +1,76 @@ +{ + "name": "My Home", + "home_id": 1000, + "rooms": [ + { + "name": "Bedroom", + "room_id": 1100, + "home_id": 1000, + "groups": [], + "devices": [ + { + "name": "Bedroom Lamp", + "is_online": true, + "wifi_connected": true, + "device_id": 1101, + "mesh_device_id": 10001, + "home_id": 1000, + "device_type_id": 137, + "device_type": "LIGHT", + "mac_address": "ABCDEF123456", + "product_id": "product123", + "authorize_code": "abcd_code", + "is_on": true, + "brightness": 80, + "color_temp": 20 + } + ] + }, + { + "name": "Office", + "room_id": 1200, + "home_id": 1000, + "groups": [ + { + "name": "Office Lamp", + "group_id": 1110, + "home_id": 1000, + "devices": [ + { + "name": "Lamp Bulb 1", + "is_online": true, + "wifi_connected": false, + "device_id": 1111, + "mesh_device_id": 10002, + "home_id": 1000, + "device_type_id": 137, + "device_type": "LIGHT", + "mac_address": "654321ABCDEF", + "product_id": "product123", + "authorize_code": "abcd_code", + "is_on": true, + "brightness": 90, + "color_temp": 254, + "rgb": [120, 145, 180] + }, + { + "name": "Lamp Bulb 2", + "is_online": false, + "wifi_connected": false, + "device_id": 1112, + "mesh_device_id": 10003, + "home_id": 1000, + "device_type_id": 137, + "device_type": "LIGHT", + "mac_address": "FEDCBA654321", + "product_id": "product123", + "authorize_code": "abcd_code" + } + ] + } + ], + "devices": [] + } + ], + "global_devices": [] +} diff --git a/tests/components/cync/snapshots/test_light.ambr b/tests/components/cync/snapshots/test_light.ambr new file mode 100644 index 00000000000..fbe56bb1c75 --- /dev/null +++ b/tests/components/cync/snapshots/test_light.ambr @@ -0,0 +1,233 @@ +# serializer version: 1 +# name: test_entities[light.bedroom_lamp-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max_color_temp_kelvin': 7000, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 142, + '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.bedroom_lamp', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'cync', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'light', + 'unique_id': '1000-1101', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[light.bedroom_lamp-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': 205, + 'color_mode': , + 'color_temp': 333, + 'color_temp_kelvin': 2999, + 'friendly_name': 'Bedroom Lamp', + 'hs_color': tuple( + 27.827, + 56.922, + ), + 'max_color_temp_kelvin': 7000, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 142, + 'rgb_color': tuple( + 255, + 177, + 110, + ), + 'supported_color_modes': list([ + , + , + ]), + 'supported_features': , + 'xy_color': tuple( + 0.496, + 0.383, + ), + }), + 'context': , + 'entity_id': 'light.bedroom_lamp', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entities[light.lamp_bulb_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max_color_temp_kelvin': 7000, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 142, + '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.lamp_bulb_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'cync', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'light', + 'unique_id': '1000-1111', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[light.lamp_bulb_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': 230, + 'color_mode': , + 'color_temp': None, + 'color_temp_kelvin': None, + 'friendly_name': 'Lamp Bulb 1', + 'hs_color': tuple( + 215.0, + 33.333, + ), + 'max_color_temp_kelvin': 7000, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 142, + 'rgb_color': tuple( + 120, + 145, + 180, + ), + 'supported_color_modes': list([ + , + , + ]), + 'supported_features': , + 'xy_color': tuple( + 0.248, + 0.27, + ), + }), + 'context': , + 'entity_id': 'light.lamp_bulb_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entities[light.lamp_bulb_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max_color_temp_kelvin': 7000, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 142, + '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.lamp_bulb_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'cync', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'light', + 'unique_id': '1000-1112', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[light.lamp_bulb_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Lamp Bulb 2', + 'max_color_temp_kelvin': 7000, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 142, + 'supported_color_modes': list([ + , + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'light.lamp_bulb_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- diff --git a/tests/components/cync/test_config_flow.py b/tests/components/cync/test_config_flow.py new file mode 100644 index 00000000000..28f0aee09da --- /dev/null +++ b/tests/components/cync/test_config_flow.py @@ -0,0 +1,260 @@ +"""Test the Cync config flow.""" + +from unittest.mock import ANY, AsyncMock, MagicMock + +from pycync.exceptions import AuthFailedError, CyncError, TwoFactorRequiredError +import pytest + +from homeassistant.components.cync.const import ( + CONF_AUTHORIZE_STRING, + CONF_EXPIRES_AT, + CONF_REFRESH_TOKEN, + CONF_TWO_FACTOR_CODE, + CONF_USER_ID, + DOMAIN, +) +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_EMAIL, CONF_PASSWORD +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from .const import MOCKED_EMAIL, MOCKED_USER + +from tests.common import MockConfigEntry + + +async def test_form_auth_success( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: + """Test that an auth flow without two factor succeeds.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_EMAIL: MOCKED_EMAIL, + CONF_PASSWORD: "test-password", + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == MOCKED_EMAIL + assert result["data"] == { + CONF_USER_ID: MOCKED_USER.user_id, + CONF_AUTHORIZE_STRING: "test_authorize_string", + CONF_EXPIRES_AT: ANY, + CONF_ACCESS_TOKEN: "test_token", + CONF_REFRESH_TOKEN: "test_refresh_token", + } + assert result["result"].unique_id == str(MOCKED_USER.user_id) + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_two_factor_success( + hass: HomeAssistant, mock_setup_entry: AsyncMock, auth_client: MagicMock +) -> None: + """Test we handle a request for a two factor code.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + auth_client.login.side_effect = TwoFactorRequiredError + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_EMAIL: MOCKED_EMAIL, + CONF_PASSWORD: "test-password", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + assert result["step_id"] == "two_factor" + + # Enter two factor code + auth_client.login.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_TWO_FACTOR_CODE: "123456", + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == MOCKED_EMAIL + assert result["data"] == { + CONF_USER_ID: MOCKED_USER.user_id, + CONF_AUTHORIZE_STRING: "test_authorize_string", + CONF_EXPIRES_AT: ANY, + CONF_ACCESS_TOKEN: "test_token", + CONF_REFRESH_TOKEN: "test_refresh_token", + } + assert result["result"].unique_id == str(MOCKED_USER.user_id) + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_unique_id_already_exists( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test that setting up a config with a unique ID that already exists fails.""" + 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["errors"] == {} + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_EMAIL: MOCKED_EMAIL, + CONF_PASSWORD: "test-password", + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.parametrize( + ("error_type", "error_string"), + [ + (AuthFailedError, "invalid_auth"), + (CyncError, "cannot_connect"), + (Exception, "unknown"), + ], +) +async def test_form_two_factor_errors( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + auth_client: MagicMock, + error_type: Exception, + error_string: str, +) -> None: + """Test we handle a request for a two factor code with errors.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + auth_client.login.side_effect = TwoFactorRequiredError + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_EMAIL: MOCKED_EMAIL, + CONF_PASSWORD: "test-password", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + assert result["step_id"] == "two_factor" + + # Enter two factor code + auth_client.login.side_effect = error_type + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_TWO_FACTOR_CODE: "123456", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": error_string} + assert result["step_id"] == "user" + + # Make sure the config flow tests finish with either an + # FlowResultType.CREATE_ENTRY or FlowResultType.ABORT so + # we can show the config flow is able to recover from an error. + auth_client.login.side_effect = TwoFactorRequiredError + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_EMAIL: MOCKED_EMAIL, + CONF_PASSWORD: "test-password", + }, + ) + + # Enter two factor code + auth_client.login.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_TWO_FACTOR_CODE: "567890", + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == MOCKED_EMAIL + assert result["data"] == { + CONF_USER_ID: MOCKED_USER.user_id, + CONF_AUTHORIZE_STRING: "test_authorize_string", + CONF_EXPIRES_AT: ANY, + CONF_ACCESS_TOKEN: "test_token", + CONF_REFRESH_TOKEN: "test_refresh_token", + } + assert result["result"].unique_id == str(MOCKED_USER.user_id) + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("error_type", "error_string"), + [ + (AuthFailedError, "invalid_auth"), + (CyncError, "cannot_connect"), + (Exception, "unknown"), + ], +) +async def test_form_errors( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + auth_client: MagicMock, + error_type: Exception, + error_string: str, +) -> None: + """Test we handle errors in the user step of the setup.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + auth_client.login.side_effect = error_type + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_EMAIL: MOCKED_EMAIL, + CONF_PASSWORD: "test-password", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": error_string} + assert result["step_id"] == "user" + + # Make sure the config flow tests finish with either an + # FlowResultType.CREATE_ENTRY or FlowResultType.ABORT so + # we can show the config flow is able to recover from an error. + auth_client.login.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_EMAIL: MOCKED_EMAIL, + CONF_PASSWORD: "test-password", + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == MOCKED_EMAIL + assert result["data"] == { + CONF_USER_ID: MOCKED_USER.user_id, + CONF_AUTHORIZE_STRING: "test_authorize_string", + CONF_EXPIRES_AT: ANY, + CONF_ACCESS_TOKEN: "test_token", + CONF_REFRESH_TOKEN: "test_refresh_token", + } + assert result["result"].unique_id == str(MOCKED_USER.user_id) + assert len(mock_setup_entry.mock_calls) == 1 diff --git a/tests/components/cync/test_light.py b/tests/components/cync/test_light.py new file mode 100644 index 00000000000..b5563949f45 --- /dev/null +++ b/tests/components/cync/test_light.py @@ -0,0 +1,23 @@ +"""Tests for the Cync integration light platform.""" + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +async def test_entities( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Test that light attributes are properly set on setup.""" + + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)