Add YoLink product integration (#69167)

* add yolink integration with door sensor

* Add test flow and update .coveragerc

* Yolink integration Bug fix

* resovle test flow

* issues resolve

* issues resolve

* issues resolve

* resolve issues

* issues resolve

* issues resolve

* change .coveragerc and test_flow

* Update yolink api version

* add test for config entry

* change config flow and add test cases

* remove config entry data

* Add token check for re-auth test flow

* fix test flow issues

* Add application credentials

* Add alias for application_credentials

* support application credentials and cloud account linking

* fix suggest change
pull/71993/head
Matrix 2022-05-17 15:59:39 +08:00 committed by GitHub
parent 513e276bba
commit e0154d6fb1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 740 additions and 1 deletions

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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()

View File

@ -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,
)

View File

@ -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)

View File

@ -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"

View File

@ -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

View File

@ -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."""

View File

@ -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"
}

View File

@ -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()

View File

@ -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%]"
}
}
}

View File

@ -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"
}
}
}
}

View File

@ -10,5 +10,6 @@ APPLICATION_CREDENTIALS = [
"google",
"netatmo",
"spotify",
"xbox"
"xbox",
"yolink"
]

View File

@ -411,6 +411,7 @@ FLOWS = {
"yale_smart_alarm",
"yamaha_musiccast",
"yeelight",
"yolink",
"youless",
"zerproc",
"zha",

View File

@ -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

View File

@ -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

View File

@ -0,0 +1 @@
"""Tests for the yolink integration."""

View File

@ -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