diff --git a/.coveragerc b/.coveragerc index 12aa3972f1a..da12b41d222 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1467,6 +1467,12 @@ omit = homeassistant/components/yandex_transport/* homeassistant/components/yeelightsunflower/light.py homeassistant/components/yi/camera.py + homeassistant/components/yolink/__init__.py + homeassistant/components/yolink/api.py + homeassistant/components/yolink/const.py + homeassistant/components/yolink/coordinator.py + homeassistant/components/yolink/entity.py + homeassistant/components/yolink/sensor.py homeassistant/components/youless/__init__.py homeassistant/components/youless/const.py homeassistant/components/youless/sensor.py diff --git a/CODEOWNERS b/CODEOWNERS index 0805b17cff5..a1d329cbee0 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1201,6 +1201,8 @@ build.json @home-assistant/supervisor /tests/components/yeelight/ @zewelor @shenxn @starkillerOG @alexyao2015 /homeassistant/components/yeelightsunflower/ @lindsaymarkward /homeassistant/components/yi/ @bachya +/homeassistant/components/yolink/ @YoSmart-Inc +/tests/components/yolink/ @YoSmart-Inc /homeassistant/components/youless/ @gjong /tests/components/youless/ @gjong /homeassistant/components/zengge/ @emontnemery diff --git a/homeassistant/components/yolink/__init__.py b/homeassistant/components/yolink/__init__.py new file mode 100644 index 00000000000..d31a082f82f --- /dev/null +++ b/homeassistant/components/yolink/__init__.py @@ -0,0 +1,64 @@ +"""The yolink integration.""" +from __future__ import annotations + +from datetime import timedelta +import logging + +from yolink.client import YoLinkClient +from yolink.mqtt_client import MqttClient + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import aiohttp_client, config_entry_oauth2_flow + +from . import api +from .const import ATTR_CLIENT, ATTR_COORDINATOR, ATTR_MQTT_CLIENT, DOMAIN +from .coordinator import YoLinkCoordinator + +SCAN_INTERVAL = timedelta(minutes=5) + +_LOGGER = logging.getLogger(__name__) + +PLATFORMS = [Platform.SENSOR] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up yolink from a config entry.""" + hass.data.setdefault(DOMAIN, {}) + implementation = ( + await config_entry_oauth2_flow.async_get_config_entry_implementation( + hass, entry + ) + ) + + session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation) + + auth_mgr = api.ConfigEntryAuth( + hass, aiohttp_client.async_get_clientsession(hass), session + ) + + yolink_http_client = YoLinkClient(auth_mgr) + yolink_mqtt_client = MqttClient(auth_mgr) + coordinator = YoLinkCoordinator(hass, yolink_http_client, yolink_mqtt_client) + await coordinator.init_coordinator() + try: + await coordinator.async_config_entry_first_refresh() + except ConfigEntryNotReady as ex: + _LOGGER.error("Fetching initial data failed: %s", ex) + + hass.data[DOMAIN][entry.entry_id] = { + ATTR_CLIENT: yolink_http_client, + ATTR_MQTT_CLIENT: yolink_mqtt_client, + ATTR_COORDINATOR: coordinator, + } + hass.config_entries.async_setup_platforms(entry, PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + hass.data[DOMAIN].pop(entry.entry_id) + return unload_ok diff --git a/homeassistant/components/yolink/api.py b/homeassistant/components/yolink/api.py new file mode 100644 index 00000000000..0991baed23f --- /dev/null +++ b/homeassistant/components/yolink/api.py @@ -0,0 +1,30 @@ +"""API for yolink bound to Home Assistant OAuth.""" +from aiohttp import ClientSession +from yolink.auth_mgr import YoLinkAuthMgr + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_entry_oauth2_flow + + +class ConfigEntryAuth(YoLinkAuthMgr): + """Provide yolink authentication tied to an OAuth2 based config entry.""" + + def __init__( + self, + hass: HomeAssistant, + websession: ClientSession, + oauth2Session: config_entry_oauth2_flow.OAuth2Session, + ) -> None: + """Initialize yolink Auth.""" + self.hass = hass + self.oauth_session = oauth2Session + super().__init__(websession) + + def access_token(self) -> str: + """Return the access token.""" + return self.oauth_session.token["access_token"] + + async def check_and_refresh_token(self) -> str: + """Check the token.""" + await self.oauth_session.async_ensure_token_valid() + return self.access_token() diff --git a/homeassistant/components/yolink/application_credentials.py b/homeassistant/components/yolink/application_credentials.py new file mode 100644 index 00000000000..f8378299952 --- /dev/null +++ b/homeassistant/components/yolink/application_credentials.py @@ -0,0 +1,14 @@ +"""Application credentials platform for yolink.""" + +from yolink.const import OAUTH2_AUTHORIZE, OAUTH2_TOKEN + +from homeassistant.components.application_credentials import AuthorizationServer +from homeassistant.core import HomeAssistant + + +async def async_get_authorization_server(hass: HomeAssistant) -> AuthorizationServer: + """Return authorization server.""" + return AuthorizationServer( + authorize_url=OAUTH2_AUTHORIZE, + token_url=OAUTH2_TOKEN, + ) diff --git a/homeassistant/components/yolink/config_flow.py b/homeassistant/components/yolink/config_flow.py new file mode 100644 index 00000000000..35a4c4ebea8 --- /dev/null +++ b/homeassistant/components/yolink/config_flow.py @@ -0,0 +1,63 @@ +"""Config flow for yolink.""" +from __future__ import annotations + +import logging +from typing import Any + +from homeassistant.config_entries import ConfigEntry +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers import config_entry_oauth2_flow + +from .const import DOMAIN + + +class OAuth2FlowHandler( + config_entry_oauth2_flow.AbstractOAuth2FlowHandler, domain=DOMAIN +): + """Config flow to handle yolink OAuth2 authentication.""" + + DOMAIN = DOMAIN + _reauth_entry: ConfigEntry | None = None + + @property + def logger(self) -> logging.Logger: + """Return logger.""" + return logging.getLogger(__name__) + + @property + def extra_authorize_data(self) -> dict: + """Extra data that needs to be appended to the authorize url.""" + scopes = ["create"] + return {"scope": " ".join(scopes)} + + async def async_step_reauth(self, user_input=None) -> FlowResult: + """Perform reauth upon an API authentication error.""" + self._reauth_entry = self.hass.config_entries.async_get_entry( + self.context["entry_id"] + ) + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm(self, user_input=None) -> FlowResult: + """Dialog that informs the user that reauth is required.""" + if user_input is None: + return self.async_show_form(step_id="reauth_confirm") + return await self.async_step_user() + + async def async_oauth_create_entry(self, data: dict) -> FlowResult: + """Create an oauth config entry or update existing entry for reauth.""" + if existing_entry := self._reauth_entry: + self.hass.config_entries.async_update_entry( + existing_entry, data=existing_entry.data | data + ) + await self.hass.config_entries.async_reload(existing_entry.entry_id) + return self.async_abort(reason="reauth_successful") + return self.async_create_entry(title="YoLink", data=data) + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle a flow start.""" + existing_entry = await self.async_set_unique_id(DOMAIN) + if existing_entry and not self._reauth_entry: + return self.async_abort(reason="already_configured") + return await super().async_step_user(user_input) diff --git a/homeassistant/components/yolink/const.py b/homeassistant/components/yolink/const.py new file mode 100644 index 00000000000..1804838e8b3 --- /dev/null +++ b/homeassistant/components/yolink/const.py @@ -0,0 +1,16 @@ +"""Constants for the yolink integration.""" + +DOMAIN = "yolink" +MANUFACTURER = "YoLink" +HOME_ID = "homeId" +HOME_SUBSCRIPTION = "home_subscription" +ATTR_PLATFORM_SENSOR = "sensor" +ATTR_COORDINATOR = "coordinator" +ATTR_DEVICE = "devices" +ATTR_DEVICE_TYPE = "type" +ATTR_DEVICE_NAME = "name" +ATTR_DEVICE_STATE = "state" +ATTR_CLIENT = "client" +ATTR_MQTT_CLIENT = "mqtt_client" +ATTR_DEVICE_ID = "deviceId" +ATTR_DEVICE_DOOR_SENSOR = "DoorSensor" diff --git a/homeassistant/components/yolink/coordinator.py b/homeassistant/components/yolink/coordinator.py new file mode 100644 index 00000000000..e5578eae4b2 --- /dev/null +++ b/homeassistant/components/yolink/coordinator.py @@ -0,0 +1,109 @@ +"""YoLink DataUpdateCoordinator.""" +from __future__ import annotations + +import asyncio +from datetime import timedelta +import logging + +import async_timeout +from yolink.client import YoLinkClient +from yolink.device import YoLinkDevice +from yolink.exception import YoLinkAuthFailError, YoLinkClientError +from yolink.model import BRDP +from yolink.mqtt_client import MqttClient + +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import ATTR_DEVICE, ATTR_DEVICE_STATE, DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class YoLinkCoordinator(DataUpdateCoordinator[dict]): + """YoLink DataUpdateCoordinator.""" + + def __init__( + self, hass: HomeAssistant, yl_client: YoLinkClient, yl_mqtt_client: MqttClient + ) -> None: + """Init YoLink DataUpdateCoordinator. + + fetch state every 30 minutes base on yolink device heartbeat interval + data is None before the first successful update, but we need to use data at first update + """ + super().__init__( + hass, _LOGGER, name=DOMAIN, update_interval=timedelta(minutes=30) + ) + self._client = yl_client + self._mqtt_client = yl_mqtt_client + self.yl_devices: list[YoLinkDevice] = [] + self.data = {} + + def on_message_callback(self, message: tuple[str, BRDP]): + """On message callback.""" + data = message[1] + if data.event is None: + return + event_param = data.event.split(".") + event_type = event_param[len(event_param) - 1] + if event_type not in ( + "Report", + "Alert", + "StatusChange", + "getState", + ): + return + resolved_state = data.data + if resolved_state is None: + return + self.data[message[0]] = resolved_state + self.async_set_updated_data(self.data) + + async def init_coordinator(self): + """Init coordinator.""" + try: + async with async_timeout.timeout(10): + home_info = await self._client.get_general_info() + await self._mqtt_client.init_home_connection( + home_info.data["id"], self.on_message_callback + ) + async with async_timeout.timeout(10): + device_response = await self._client.get_auth_devices() + + except YoLinkAuthFailError as yl_auth_err: + raise ConfigEntryAuthFailed from yl_auth_err + + except (YoLinkClientError, asyncio.TimeoutError) as err: + raise ConfigEntryNotReady from err + + yl_devices: list[YoLinkDevice] = [] + + for device_info in device_response.data[ATTR_DEVICE]: + yl_devices.append(YoLinkDevice(device_info, self._client)) + + self.yl_devices = yl_devices + + async def fetch_device_state(self, device: YoLinkDevice): + """Fetch Device State.""" + try: + async with async_timeout.timeout(10): + device_state_resp = await device.fetch_state_with_api() + if ATTR_DEVICE_STATE in device_state_resp.data: + self.data[device.device_id] = device_state_resp.data[ + ATTR_DEVICE_STATE + ] + except YoLinkAuthFailError as yl_auth_err: + raise ConfigEntryAuthFailed from yl_auth_err + except YoLinkClientError as yl_client_err: + raise UpdateFailed( + f"Error communicating with API: {yl_client_err}" + ) from yl_client_err + + async def _async_update_data(self) -> dict: + fetch_tasks = [] + for yl_device in self.yl_devices: + fetch_tasks.append(self.fetch_device_state(yl_device)) + if fetch_tasks: + await asyncio.gather(*fetch_tasks) + return self.data diff --git a/homeassistant/components/yolink/entity.py b/homeassistant/components/yolink/entity.py new file mode 100644 index 00000000000..6954b117728 --- /dev/null +++ b/homeassistant/components/yolink/entity.py @@ -0,0 +1,52 @@ +"""Support for YoLink Device.""" +from __future__ import annotations + +from abc import abstractmethod + +from yolink.device import YoLinkDevice + +from homeassistant.core import callback +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN, MANUFACTURER +from .coordinator import YoLinkCoordinator + + +class YoLinkEntity(CoordinatorEntity[YoLinkCoordinator]): + """YoLink Device Basic Entity.""" + + def __init__( + self, + coordinator: YoLinkCoordinator, + device_info: YoLinkDevice, + ) -> None: + """Init YoLink Entity.""" + super().__init__(coordinator) + self.device = device_info + + @property + def device_id(self) -> str: + """Return the device id of the YoLink device.""" + return self.device.device_id + + @callback + def _handle_coordinator_update(self) -> None: + data = self.coordinator.data.get(self.device.device_id) + if data is not None: + self.update_entity_state(data) + + @property + def device_info(self) -> DeviceInfo: + """Return the device info for HA.""" + return DeviceInfo( + identifiers={(DOMAIN, self.device.device_id)}, + manufacturer=MANUFACTURER, + model=self.device.device_type, + name=self.device.device_name, + ) + + @callback + @abstractmethod + def update_entity_state(self, state: dict) -> None: + """Parse and update entity state, should be overridden.""" diff --git a/homeassistant/components/yolink/manifest.json b/homeassistant/components/yolink/manifest.json new file mode 100644 index 00000000000..d0072145c19 --- /dev/null +++ b/homeassistant/components/yolink/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "yolink", + "name": "YoLink", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/yolink", + "requirements": ["yolink-api==0.0.5"], + "dependencies": ["auth", "application_credentials"], + "codeowners": ["@YoSmart-Inc"], + "iot_class": "cloud_push" +} diff --git a/homeassistant/components/yolink/sensor.py b/homeassistant/components/yolink/sensor.py new file mode 100644 index 00000000000..075cfc24179 --- /dev/null +++ b/homeassistant/components/yolink/sensor.py @@ -0,0 +1,104 @@ +"""YoLink Binary Sensor.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass + +from yolink.device import YoLinkDevice + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import PERCENTAGE +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.util import percentage + +from .const import ATTR_COORDINATOR, ATTR_DEVICE_DOOR_SENSOR, DOMAIN +from .coordinator import YoLinkCoordinator +from .entity import YoLinkEntity + + +@dataclass +class YoLinkSensorEntityDescriptionMixin: + """Mixin for device type.""" + + exists_fn: Callable[[YoLinkDevice], bool] = lambda _: True + + +@dataclass +class YoLinkSensorEntityDescription( + YoLinkSensorEntityDescriptionMixin, SensorEntityDescription +): + """YoLink SensorEntityDescription.""" + + value: Callable = lambda state: state + + +SENSOR_TYPES: tuple[YoLinkSensorEntityDescription, ...] = ( + YoLinkSensorEntityDescription( + key="battery", + device_class=SensorDeviceClass.BATTERY, + native_unit_of_measurement=PERCENTAGE, + name="Battery", + state_class=SensorStateClass.MEASUREMENT, + value=lambda value: percentage.ordered_list_item_to_percentage( + [1, 2, 3, 4], value + ), + exists_fn=lambda device: device.device_type in [ATTR_DEVICE_DOOR_SENSOR], + ), +) + +SENSOR_DEVICE_TYPE = [ATTR_DEVICE_DOOR_SENSOR] + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up YoLink Sensor from a config entry.""" + coordinator = hass.data[DOMAIN][config_entry.entry_id][ATTR_COORDINATOR] + sensor_devices = [ + device + for device in coordinator.yl_devices + if device.device_type in SENSOR_DEVICE_TYPE + ] + entities = [] + for sensor_device in sensor_devices: + for description in SENSOR_TYPES: + if description.exists_fn(sensor_device): + entities.append( + YoLinkSensorEntity(coordinator, description, sensor_device) + ) + async_add_entities(entities) + + +class YoLinkSensorEntity(YoLinkEntity, SensorEntity): + """YoLink Sensor Entity.""" + + entity_description: YoLinkSensorEntityDescription + + def __init__( + self, + coordinator: YoLinkCoordinator, + description: YoLinkSensorEntityDescription, + device: YoLinkDevice, + ) -> None: + """Init YoLink Sensor.""" + super().__init__(coordinator, device) + self.entity_description = description + self._attr_unique_id = f"{device.device_id} {self.entity_description.key}" + self._attr_name = f"{device.device_name} ({self.entity_description.name})" + + @callback + def update_entity_state(self, state: dict) -> None: + """Update HA Entity State.""" + self._attr_native_value = self.entity_description.value( + state[self.entity_description.key] + ) + self.async_write_ha_state() diff --git a/homeassistant/components/yolink/strings.json b/homeassistant/components/yolink/strings.json new file mode 100644 index 00000000000..94fe5dc09aa --- /dev/null +++ b/homeassistant/components/yolink/strings.json @@ -0,0 +1,25 @@ +{ + "config": { + "step": { + "pick_implementation": { + "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" + }, + "reauth_confirm": { + "title": "[%key:common::config_flow::title::reauth%]", + "description": "The yolink integration needs to re-authenticate your account" + } + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", + "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]", + "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]", + "authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]", + "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + }, + "create_entry": { + "default": "[%key:common::config_flow::create_entry::authenticated%]" + } + } +} diff --git a/homeassistant/components/yolink/translations/en.json b/homeassistant/components/yolink/translations/en.json new file mode 100644 index 00000000000..d1817fbe011 --- /dev/null +++ b/homeassistant/components/yolink/translations/en.json @@ -0,0 +1,25 @@ +{ + "config": { + "abort": { + "already_configured": "Account is already configured", + "already_in_progress": "Configuration flow is already in progress", + "authorize_url_timeout": "Timeout generating authorize URL.", + "missing_configuration": "The component is not configured. Please follow the documentation.", + "no_url_available": "No URL available. For information about this error, [check the help section]({docs_url})", + "oauth_error": "Received invalid token data.", + "reauth_successful": "Re-authentication was successful" + }, + "create_entry": { + "default": "Successfully authenticated" + }, + "step": { + "pick_implementation": { + "title": "Pick Authentication Method" + }, + "reauth_confirm": { + "description": "The yolink integration needs to re-authenticate your account", + "title": "Reauthenticate Integration" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/generated/application_credentials.py b/homeassistant/generated/application_credentials.py index 40f451de153..0c3e60b8b8d 100644 --- a/homeassistant/generated/application_credentials.py +++ b/homeassistant/generated/application_credentials.py @@ -10,5 +10,6 @@ APPLICATION_CREDENTIALS = [ "google", "netatmo", "spotify", - "xbox" + "xbox", + "yolink" ] diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 77146689b8c..7f0059b2da9 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -411,6 +411,7 @@ FLOWS = { "yale_smart_alarm", "yamaha_musiccast", "yeelight", + "yolink", "youless", "zerproc", "zha", diff --git a/requirements_all.txt b/requirements_all.txt index a7c59f1853d..687962e94b6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2479,6 +2479,9 @@ yeelight==0.7.10 # homeassistant.components.yeelightsunflower yeelightsunflower==0.0.10 +# homeassistant.components.yolink +yolink-api==0.0.5 + # homeassistant.components.youless youless-api==0.16 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f9926797e6f..05a7207c01a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1631,6 +1631,9 @@ yalexs==1.1.25 # homeassistant.components.yeelight yeelight==0.7.10 +# homeassistant.components.yolink +yolink-api==0.0.5 + # homeassistant.components.youless youless-api==0.16 diff --git a/tests/components/yolink/__init__.py b/tests/components/yolink/__init__.py new file mode 100644 index 00000000000..a72667661b9 --- /dev/null +++ b/tests/components/yolink/__init__.py @@ -0,0 +1 @@ +"""Tests for the yolink integration.""" diff --git a/tests/components/yolink/test_config_flow.py b/tests/components/yolink/test_config_flow.py new file mode 100644 index 00000000000..4dd347f4076 --- /dev/null +++ b/tests/components/yolink/test_config_flow.py @@ -0,0 +1,210 @@ +"""Test yolink config flow.""" +import asyncio +from http import HTTPStatus +from unittest.mock import patch + +from homeassistant import config_entries, data_entry_flow, setup +from homeassistant.components import application_credentials +from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_entry_oauth2_flow + +from tests.common import MockConfigEntry + +CLIENT_ID = "12345" +CLIENT_SECRET = "6789" +YOLINK_HOST = "api.yosmart.com" +YOLINK_HTTP_HOST = f"http://{YOLINK_HOST}" +DOMAIN = "yolink" +OAUTH2_AUTHORIZE = f"{YOLINK_HTTP_HOST}/oauth/v2/authorization.htm" +OAUTH2_TOKEN = f"{YOLINK_HTTP_HOST}/open/yolink/token" + + +async def test_abort_if_no_configuration(hass): + """Check flow abort when no configuration.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "missing_configuration" + + +async def test_abort_if_existing_entry(hass: HomeAssistant): + """Check flow abort when an entry already exist.""" + MockConfigEntry(domain=DOMAIN, unique_id=DOMAIN).add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + + +async def test_full_flow( + hass, hass_client_no_auth, aioclient_mock, current_request_with_host +): + """Check full flow.""" + assert await setup.async_setup_component( + hass, + DOMAIN, + {}, + ) + await application_credentials.async_import_client_credential( + hass, + DOMAIN, + application_credentials.ClientCredential(CLIENT_ID, CLIENT_SECRET), + ) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_EXTERNAL_STEP + assert result["url"] == ( + f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}" + "&redirect_uri=https://example.com/auth/external/callback" + f"&state={state}&scope=create" + ) + + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == HTTPStatus.OK + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + aioclient_mock.post( + OAUTH2_TOKEN, + json={ + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + }, + ) + + with patch("homeassistant.components.yolink.api.ConfigEntryAuth"), patch( + "homeassistant.components.yolink.async_setup_entry", return_value=True + ) as mock_setup: + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["data"]["auth_implementation"] == DOMAIN + + result["data"]["token"].pop("expires_at") + assert result["data"]["token"] == { + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + } + + assert DOMAIN in hass.config.components + entry = hass.config_entries.async_entries(DOMAIN)[0] + assert entry.state is config_entries.ConfigEntryState.LOADED + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert len(mock_setup.mock_calls) == 1 + + +async def test_abort_if_authorization_timeout(hass, current_request_with_host): + """Check yolink authorization timeout.""" + assert await setup.async_setup_component( + hass, + DOMAIN, + {}, + ) + await application_credentials.async_import_client_credential( + hass, + DOMAIN, + application_credentials.ClientCredential(CLIENT_ID, CLIENT_SECRET), + ) + with patch( + "homeassistant.components.yolink.config_entry_oauth2_flow." + "LocalOAuth2Implementation.async_generate_authorize_url", + side_effect=asyncio.TimeoutError, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "authorize_url_timeout" + + +async def test_reauthentication( + hass, hass_client_no_auth, aioclient_mock, current_request_with_host +): + """Test yolink reauthentication.""" + await setup.async_setup_component( + hass, + DOMAIN, + {}, + ) + + await application_credentials.async_import_client_credential( + hass, + DOMAIN, + application_credentials.ClientCredential(CLIENT_ID, CLIENT_SECRET), + ) + + old_entry = MockConfigEntry( + domain=DOMAIN, + unique_id=DOMAIN, + version=1, + data={ + "refresh_token": "outdated_fresh_token", + "access_token": "outdated_access_token", + }, + ) + old_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "unique_id": old_entry.unique_id, + "entry_id": old_entry.entry_id, + }, + data=old_entry.data, + ) + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + + result = await hass.config_entries.flow.async_configure(flows[0]["flow_id"], {}) + + # pylint: disable=protected-access + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + client = await hass_client_no_auth() + await client.get(f"/auth/external/callback?code=abcd&state={state}") + + aioclient_mock.post( + OAUTH2_TOKEN, + json={ + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + }, + ) + + with patch("homeassistant.components.yolink.api.ConfigEntryAuth"): + with patch( + "homeassistant.components.yolink.async_setup_entry", return_value=True + ) as mock_setup: + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + token_data = old_entry.data["token"] + assert token_data["access_token"] == "mock-access-token" + assert token_data["refresh_token"] == "mock-refresh-token" + assert token_data["type"] == "Bearer" + assert token_data["expires_in"] == 60 + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "reauth_successful" + assert len(mock_setup.mock_calls) == 1