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 changepull/71993/head
parent
513e276bba
commit
e0154d6fb1
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -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()
|
|
@ -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,
|
||||
)
|
|
@ -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)
|
|
@ -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"
|
|
@ -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
|
|
@ -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."""
|
|
@ -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"
|
||||
}
|
|
@ -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()
|
|
@ -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%]"
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -10,5 +10,6 @@ APPLICATION_CREDENTIALS = [
|
|||
"google",
|
||||
"netatmo",
|
||||
"spotify",
|
||||
"xbox"
|
||||
"xbox",
|
||||
"yolink"
|
||||
]
|
||||
|
|
|
@ -411,6 +411,7 @@ FLOWS = {
|
|||
"yale_smart_alarm",
|
||||
"yamaha_musiccast",
|
||||
"yeelight",
|
||||
"yolink",
|
||||
"youless",
|
||||
"zerproc",
|
||||
"zha",
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
"""Tests for the yolink integration."""
|
|
@ -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
|
Loading…
Reference in New Issue