Add microBees integration (#99573)
* Create a new homeassistan integration for microBees * black --fast homeassistant tests * Switch platform * rename folder * rename folder * Update owners * aiohttp removed in favor of hass * Update config_flow.py * Update __init__.py * Update const.py * Update manifest.json * Update string.json * Update servicesMicrobees.py * Update switch.py * Update __init__.py * Update it.json * Create a new homeassistan integration for microBees * black --fast homeassistant tests * Switch platform * rename folder * rename folder * Update owners * aiohttp removed in favor of hass * Update config_flow.py * Update __init__.py * Update const.py * Update manifest.json * Update string.json * Update servicesMicrobees.py * Update switch.py * Update __init__.py * Update it.json * fixes review * fixes review * fixes review * pyproject.toml * Update package_constraints.txt * fixes review * bug fixes * bug fixes * delete microbees connector * add other productID in switch * added coordinator and enanchments * added coordinator and enanchments * fixes from suggestions * fixes from suggestions * fixes from suggestions * fixes from suggestions * fixes from suggestions * fixes from suggestions * fixes from suggestions * fixes from suggestions * fixes from suggestions * fixes from suggestions * fixes from suggestions * fixes from suggestions * add test * add test * add test * add test * requested commit * requested commit * requested commit * requested commit * reverting .strict-typing and added microbees to .coveragerc * remove log * remove log * remove log * remove log * add test for microbeesExeption and Exeption * add test for microbeesExeption and Exeption * add test for microbeesException and Exception * add test for microbeesException and Exception * add test for microbeesException and Exception --------- Co-authored-by: FedDam <noceracity@gmail.com> Co-authored-by: Federico D'Amico <48856240+FedDam@users.noreply.github.com>
This commit is contained in:
parent
b349a466ba
commit
3a4c6fc7f3
23 changed files with 1012 additions and 0 deletions
|
@ -769,6 +769,13 @@ omit =
|
|||
homeassistant/components/meteoclimatic/__init__.py
|
||||
homeassistant/components/meteoclimatic/sensor.py
|
||||
homeassistant/components/meteoclimatic/weather.py
|
||||
homeassistant/components/microbees/__init__.py
|
||||
homeassistant/components/microbees/api.py
|
||||
homeassistant/components/microbees/application_credentials.py
|
||||
homeassistant/components/microbees/const.py
|
||||
homeassistant/components/microbees/coordinator.py
|
||||
homeassistant/components/microbees/entity.py
|
||||
homeassistant/components/microbees/switch.py
|
||||
homeassistant/components/microsoft/tts.py
|
||||
homeassistant/components/mikrotik/hub.py
|
||||
homeassistant/components/mill/climate.py
|
||||
|
|
|
@ -805,6 +805,8 @@ build.json @home-assistant/supervisor
|
|||
/tests/components/meteoclimatic/ @adrianmo
|
||||
/homeassistant/components/metoffice/ @MrHarcombe @avee87
|
||||
/tests/components/metoffice/ @MrHarcombe @avee87
|
||||
/homeassistant/components/microbees/ @microBeesTech
|
||||
/tests/components/microbees/ @microBeesTech
|
||||
/homeassistant/components/mikrotik/ @engrbm87
|
||||
/tests/components/mikrotik/ @engrbm87
|
||||
/homeassistant/components/mill/ @danielhiversen
|
||||
|
|
64
homeassistant/components/microbees/__init__.py
Normal file
64
homeassistant/components/microbees/__init__.py
Normal file
|
@ -0,0 +1,64 @@
|
|||
"""The microBees integration."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from http import HTTPStatus
|
||||
|
||||
import aiohttp
|
||||
from microBeesPy.microbees import MicroBees
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_ACCESS_TOKEN
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||
from homeassistant.helpers import config_entry_oauth2_flow
|
||||
|
||||
from .const import DOMAIN, PLATFORMS
|
||||
from .coordinator import MicroBeesUpdateCoordinator
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class HomeAssistantMicroBeesData:
|
||||
"""Microbees data stored in the Home Assistant data object."""
|
||||
|
||||
connector: MicroBees
|
||||
coordinator: MicroBeesUpdateCoordinator
|
||||
session: config_entry_oauth2_flow.OAuth2Session
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up microBees from a config entry."""
|
||||
implementation = (
|
||||
await config_entry_oauth2_flow.async_get_config_entry_implementation(
|
||||
hass, entry
|
||||
)
|
||||
)
|
||||
|
||||
session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation)
|
||||
try:
|
||||
await session.async_ensure_token_valid()
|
||||
except aiohttp.ClientResponseError as ex:
|
||||
if ex.status in (
|
||||
HTTPStatus.BAD_REQUEST,
|
||||
HTTPStatus.UNAUTHORIZED,
|
||||
HTTPStatus.FORBIDDEN,
|
||||
):
|
||||
raise ConfigEntryAuthFailed("Token not valid, trigger renewal") from ex
|
||||
raise ConfigEntryNotReady from ex
|
||||
microbees = MicroBees(token=session.token[CONF_ACCESS_TOKEN])
|
||||
coordinator = MicroBeesUpdateCoordinator(hass, microbees)
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = HomeAssistantMicroBeesData(
|
||||
connector=microbees,
|
||||
coordinator=coordinator,
|
||||
session=session,
|
||||
)
|
||||
await hass.config_entries.async_forward_entry_setups(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
|
28
homeassistant/components/microbees/api.py
Normal file
28
homeassistant/components/microbees/api.py
Normal file
|
@ -0,0 +1,28 @@
|
|||
"""API for microBees bound to Home Assistant OAuth."""
|
||||
|
||||
from homeassistant.const import CONF_ACCESS_TOKEN
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import config_entry_oauth2_flow
|
||||
|
||||
|
||||
class ConfigEntryAuth:
|
||||
"""Provide microBees authentication tied to an OAuth2 based config entry."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
oauth2_session: config_entry_oauth2_flow.OAuth2Session,
|
||||
) -> None:
|
||||
"""Initialize microBees Auth."""
|
||||
self.oauth_session = oauth2_session
|
||||
self.hass = hass
|
||||
|
||||
@property
|
||||
def access_token(self) -> str:
|
||||
"""Return the access token."""
|
||||
return self.oauth_session.token[CONF_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 the microBees integration."""
|
||||
|
||||
from homeassistant.components.application_credentials import AuthorizationServer
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .const import OAUTH2_AUTHORIZE, OAUTH2_TOKEN
|
||||
|
||||
|
||||
async def async_get_authorization_server(hass: HomeAssistant) -> AuthorizationServer:
|
||||
"""Return auth implementation."""
|
||||
return AuthorizationServer(
|
||||
authorize_url=OAUTH2_AUTHORIZE,
|
||||
token_url=OAUTH2_TOKEN,
|
||||
)
|
77
homeassistant/components/microbees/config_flow.py
Normal file
77
homeassistant/components/microbees/config_flow.py
Normal file
|
@ -0,0 +1,77 @@
|
|||
"""Config flow for microBees integration."""
|
||||
from collections.abc import Mapping
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from microBeesPy.microbees import MicroBees, MicroBeesException
|
||||
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN
|
||||
from homeassistant.data_entry_flow import FlowResult
|
||||
from homeassistant.helpers import aiohttp_client, config_entry_oauth2_flow
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
|
||||
class OAuth2FlowHandler(
|
||||
config_entry_oauth2_flow.AbstractOAuth2FlowHandler, domain=DOMAIN
|
||||
):
|
||||
"""Handle a config flow for microBees."""
|
||||
|
||||
DOMAIN = DOMAIN
|
||||
reauth_entry: config_entries.ConfigEntry | None = None
|
||||
|
||||
@property
|
||||
def logger(self) -> logging.Logger:
|
||||
"""Return logger."""
|
||||
return logging.getLogger(__name__)
|
||||
|
||||
@property
|
||||
def extra_authorize_data(self) -> dict[str, Any]:
|
||||
"""Extra data that needs to be appended to the authorize url."""
|
||||
scopes = ["read", "write"]
|
||||
return {"scope": " ".join(scopes)}
|
||||
|
||||
async def async_oauth_create_entry(self, data: dict[str, Any]) -> FlowResult:
|
||||
"""Create an oauth config entry or update existing entry for reauth."""
|
||||
|
||||
microbees = MicroBees(
|
||||
session=aiohttp_client.async_get_clientsession(self.hass),
|
||||
token=data[CONF_TOKEN][CONF_ACCESS_TOKEN],
|
||||
)
|
||||
|
||||
try:
|
||||
current_user = await microbees.getMyProfile()
|
||||
except MicroBeesException:
|
||||
return self.async_abort(reason="invalid_auth")
|
||||
except Exception: # pylint: disable=broad-except
|
||||
self.logger.exception("Unexpected error")
|
||||
return self.async_abort(reason="unknown")
|
||||
|
||||
if not self.reauth_entry:
|
||||
await self.async_set_unique_id(current_user.id)
|
||||
self._abort_if_unique_id_configured()
|
||||
return self.async_create_entry(
|
||||
title=current_user.username,
|
||||
data=data,
|
||||
)
|
||||
if self.reauth_entry.unique_id == current_user.id:
|
||||
self.hass.config_entries.async_update_entry(self.reauth_entry, data=data)
|
||||
await self.hass.config_entries.async_reload(self.reauth_entry.entry_id)
|
||||
return self.async_abort(reason="reauth_successful")
|
||||
return self.async_abort(reason="wrong_account")
|
||||
|
||||
async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> 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: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
"""Confirm reauth dialog."""
|
||||
if user_input is None:
|
||||
return self.async_show_form(step_id="reauth_confirm")
|
||||
return await self.async_step_user()
|
9
homeassistant/components/microbees/const.py
Normal file
9
homeassistant/components/microbees/const.py
Normal file
|
@ -0,0 +1,9 @@
|
|||
"""Constants for the microBees integration."""
|
||||
from homeassistant.const import Platform
|
||||
|
||||
DOMAIN = "microbees"
|
||||
OAUTH2_AUTHORIZE = "https://dev.microbees.com/oauth/authorize"
|
||||
OAUTH2_TOKEN = "https://dev.microbees.com/oauth/token"
|
||||
PLATFORMS = [
|
||||
Platform.SWITCH,
|
||||
]
|
61
homeassistant/components/microbees/coordinator.py
Normal file
61
homeassistant/components/microbees/coordinator.py
Normal file
|
@ -0,0 +1,61 @@
|
|||
"""The microBees Coordinator."""
|
||||
|
||||
import asyncio
|
||||
from dataclasses import dataclass
|
||||
from datetime import timedelta
|
||||
from http import HTTPStatus
|
||||
import logging
|
||||
|
||||
import aiohttp
|
||||
from microBeesPy.microbees import Actuator, Bee, MicroBees, MicroBeesException
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class MicroBeesCoordinatorData:
|
||||
"""Microbees data from the Coordinator."""
|
||||
|
||||
bees: dict[int, Bee]
|
||||
actuators: dict[int, Actuator]
|
||||
|
||||
|
||||
class MicroBeesUpdateCoordinator(DataUpdateCoordinator[MicroBeesCoordinatorData]):
|
||||
"""MicroBees coordinator."""
|
||||
|
||||
def __init__(self, hass: HomeAssistant, microbees: MicroBees) -> None:
|
||||
"""Initialize microBees coordinator."""
|
||||
super().__init__(
|
||||
hass,
|
||||
_LOGGER,
|
||||
name="microBees Coordinator",
|
||||
update_interval=timedelta(seconds=30),
|
||||
)
|
||||
self.microbees = microbees
|
||||
|
||||
async def _async_update_data(self) -> MicroBeesCoordinatorData:
|
||||
"""Fetch data from API endpoint."""
|
||||
async with asyncio.timeout(10):
|
||||
try:
|
||||
bees = await self.microbees.getBees()
|
||||
except aiohttp.ClientResponseError as err:
|
||||
if err.status is HTTPStatus.UNAUTHORIZED:
|
||||
raise ConfigEntryAuthFailed(
|
||||
"Token not valid, trigger renewal"
|
||||
) from err
|
||||
raise UpdateFailed(f"Error communicating with API: {err}") from err
|
||||
|
||||
except MicroBeesException as err:
|
||||
raise UpdateFailed(f"Error communicating with API: {err}") from err
|
||||
|
||||
bees_dict = {}
|
||||
actuators_dict = {}
|
||||
for bee in bees:
|
||||
bees_dict[bee.id] = bee
|
||||
for actuator in bee.actuators:
|
||||
actuators_dict[actuator.id] = actuator
|
||||
return MicroBeesCoordinatorData(bees=bees_dict, actuators=actuators_dict)
|
52
homeassistant/components/microbees/entity.py
Normal file
52
homeassistant/components/microbees/entity.py
Normal file
|
@ -0,0 +1,52 @@
|
|||
"""Base entity for microBees."""
|
||||
|
||||
from microBeesPy.microbees import Actuator, Bee
|
||||
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import MicroBeesUpdateCoordinator
|
||||
|
||||
|
||||
class MicroBeesEntity(CoordinatorEntity[MicroBeesUpdateCoordinator]):
|
||||
"""Base class for microBees entities."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: MicroBeesUpdateCoordinator,
|
||||
bee_id: int,
|
||||
actuator_id: int,
|
||||
) -> None:
|
||||
"""Initialize the microBees entity."""
|
||||
super().__init__(coordinator)
|
||||
self.bee_id = bee_id
|
||||
self.actuator_id = actuator_id
|
||||
self._attr_unique_id = f"{bee_id}_{actuator_id}"
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, str(bee_id))},
|
||||
manufacturer="microBees",
|
||||
name=self.bee.name,
|
||||
model=self.bee.prototypeName,
|
||||
)
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Status of the bee."""
|
||||
return (
|
||||
super().available
|
||||
and self.bee_id in self.coordinator.data.bees
|
||||
and self.bee.active
|
||||
)
|
||||
|
||||
@property
|
||||
def bee(self) -> Bee:
|
||||
"""Return the bee."""
|
||||
return self.coordinator.data.bees[self.bee_id]
|
||||
|
||||
@property
|
||||
def actuator(self) -> Actuator:
|
||||
"""Return the actuator."""
|
||||
return self.coordinator.data.actuators[self.actuator_id]
|
12
homeassistant/components/microbees/icons.json
Normal file
12
homeassistant/components/microbees/icons.json
Normal file
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"entity": {
|
||||
"switch": {
|
||||
"socket_eu": {
|
||||
"default": "mdi:power-socket-eu"
|
||||
},
|
||||
"socket_it": {
|
||||
"default": "mdi:power-socket-it"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
10
homeassistant/components/microbees/manifest.json
Normal file
10
homeassistant/components/microbees/manifest.json
Normal file
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"domain": "microbees",
|
||||
"name": "microBees",
|
||||
"codeowners": ["@microBeesTech"],
|
||||
"config_flow": true,
|
||||
"dependencies": ["application_credentials"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/microbees",
|
||||
"iot_class": "cloud_polling",
|
||||
"requirements": ["microBeesPy==0.2.5"]
|
||||
}
|
28
homeassistant/components/microbees/strings.json
Normal file
28
homeassistant/components/microbees/strings.json
Normal file
|
@ -0,0 +1,28 @@
|
|||
{
|
||||
"config": {
|
||||
"step": {
|
||||
"pick_implementation": {
|
||||
"title": "[%key:common::config_flow::title::oauth2_pick_implementation%]"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"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%]",
|
||||
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
|
||||
"oauth_error": "[%key:common::config_flow::abort::oauth2_error%]",
|
||||
"oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]",
|
||||
"oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]",
|
||||
"oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]",
|
||||
"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%]",
|
||||
"user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]"
|
||||
},
|
||||
"create_entry": {
|
||||
"default": "[%key:common::config_flow::create_entry::authenticated%]"
|
||||
}
|
||||
}
|
||||
}
|
70
homeassistant/components/microbees/switch.py
Normal file
70
homeassistant/components/microbees/switch.py
Normal file
|
@ -0,0 +1,70 @@
|
|||
"""Switch integration microBees."""
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.switch import SwitchEntity
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import MicroBeesUpdateCoordinator
|
||||
from .entity import MicroBeesEntity
|
||||
|
||||
SOCKET_TRANSLATIONS = {46: "socket_it", 38: "socket_eu"}
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
|
||||
) -> None:
|
||||
"""Config entry."""
|
||||
coordinator = hass.data[DOMAIN][entry.entry_id].coordinator
|
||||
switches = []
|
||||
for bee_id, bee in coordinator.data.bees.items():
|
||||
if bee.productID in (25, 26, 27, 35, 38, 46, 63, 64, 65, 86):
|
||||
for switch in bee.actuators:
|
||||
switches.append(MBSwitch(coordinator, bee_id, switch.id))
|
||||
|
||||
async_add_entities(switches)
|
||||
|
||||
|
||||
class MBSwitch(MicroBeesEntity, SwitchEntity):
|
||||
"""Representation of a microBees switch."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: MicroBeesUpdateCoordinator,
|
||||
bee_id: int,
|
||||
actuator_id: int,
|
||||
) -> None:
|
||||
"""Initialize the microBees switch."""
|
||||
super().__init__(coordinator, bee_id, actuator_id)
|
||||
self._attr_translation_key = SOCKET_TRANSLATIONS.get(self.bee.productID)
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
"""Name of the switch."""
|
||||
return self.actuator.name
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
"""Status of the switch."""
|
||||
return self.actuator.value
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn on the switch."""
|
||||
send_command = await self.coordinator.microbees.sendCommand(self.actuator_id, 1)
|
||||
if send_command:
|
||||
self.actuator.value = True
|
||||
self.async_write_ha_state()
|
||||
else:
|
||||
raise HomeAssistantError(f"Failed to turn on {self.name}")
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn off the switch."""
|
||||
send_command = await self.coordinator.microbees.sendCommand(self.actuator_id, 0)
|
||||
if send_command:
|
||||
self.actuator.value = False
|
||||
self.async_write_ha_state()
|
||||
else:
|
||||
raise HomeAssistantError(f"Failed to turn off {self.name}")
|
|
@ -16,6 +16,7 @@ APPLICATION_CREDENTIALS = [
|
|||
"husqvarna_automower",
|
||||
"lametric",
|
||||
"lyric",
|
||||
"microbees",
|
||||
"myuplink",
|
||||
"neato",
|
||||
"nest",
|
||||
|
|
|
@ -309,6 +309,7 @@ FLOWS = {
|
|||
"meteo_france",
|
||||
"meteoclimatic",
|
||||
"metoffice",
|
||||
"microbees",
|
||||
"mikrotik",
|
||||
"mill",
|
||||
"minecraft_server",
|
||||
|
|
|
@ -3536,6 +3536,12 @@
|
|||
"config_flow": false,
|
||||
"iot_class": "local_polling"
|
||||
},
|
||||
"microbees": {
|
||||
"name": "microBees",
|
||||
"integration_type": "hub",
|
||||
"config_flow": true,
|
||||
"iot_class": "cloud_polling"
|
||||
},
|
||||
"microsoft": {
|
||||
"name": "Microsoft",
|
||||
"integrations": {
|
||||
|
|
|
@ -1303,6 +1303,9 @@ mficlient==0.3.0
|
|||
# homeassistant.components.xiaomi_miio
|
||||
micloud==0.5
|
||||
|
||||
# homeassistant.components.microbees
|
||||
microBeesPy==0.2.5
|
||||
|
||||
# homeassistant.components.mill
|
||||
mill-local==0.3.0
|
||||
|
||||
|
|
|
@ -1039,6 +1039,9 @@ mficlient==0.3.0
|
|||
# homeassistant.components.xiaomi_miio
|
||||
micloud==0.5
|
||||
|
||||
# homeassistant.components.microbees
|
||||
microBeesPy==0.2.5
|
||||
|
||||
# homeassistant.components.mill
|
||||
mill-local==0.3.0
|
||||
|
||||
|
|
10
tests/components/microbees/__init__.py
Normal file
10
tests/components/microbees/__init__.py
Normal file
|
@ -0,0 +1,10 @@
|
|||
"""Tests for the MicroBees component."""
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
|
||||
async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None:
|
||||
"""Fixture for setting up the component."""
|
||||
config_entry.add_to_hass(hass)
|
||||
await hass.config_entries.async_setup(config_entry.entry_id)
|
93
tests/components/microbees/conftest.py
Normal file
93
tests/components/microbees/conftest.py
Normal file
|
@ -0,0 +1,93 @@
|
|||
"""Conftest for microBees tests."""
|
||||
import time
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from microBeesPy.microbees import Bee, MicroBees, Profile
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.application_credentials import (
|
||||
ClientCredential,
|
||||
async_import_client_credential,
|
||||
)
|
||||
from homeassistant.components.microbees.const import DOMAIN
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.setup import async_setup_component
|
||||
|
||||
from tests.common import (
|
||||
MockConfigEntry,
|
||||
load_json_array_fixture,
|
||||
load_json_object_fixture,
|
||||
)
|
||||
|
||||
CLIENT_ID = "1234"
|
||||
CLIENT_SECRET = "5678"
|
||||
TITLE = "MicroBees"
|
||||
MICROBEES_AUTH_URI = "https://dev.microbees.com/oauth/authorize"
|
||||
MICROBEES_TOKEN_URI = "https://dev.microbees.com/oauth/token"
|
||||
|
||||
SCOPES = ["read", "write"]
|
||||
|
||||
|
||||
@pytest.fixture(name="scopes")
|
||||
def mock_scopes() -> list[str]:
|
||||
"""Fixture to set the scopes present in the OAuth token."""
|
||||
return SCOPES
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
async def setup_credentials(hass: HomeAssistant) -> None:
|
||||
"""Fixture to setup credentials."""
|
||||
assert await async_setup_component(hass, "application_credentials", {})
|
||||
await async_import_client_credential(
|
||||
hass,
|
||||
DOMAIN,
|
||||
ClientCredential(CLIENT_ID, CLIENT_SECRET),
|
||||
DOMAIN,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(name="expires_at")
|
||||
def mock_expires_at() -> int:
|
||||
"""Fixture to set the oauth token expiration time."""
|
||||
return time.time() + 3600
|
||||
|
||||
|
||||
@pytest.fixture(name="config_entry")
|
||||
def mock_config_entry(expires_at: int, scopes: list[str]) -> MockConfigEntry:
|
||||
"""Create YouTube entry in Home Assistant."""
|
||||
return MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
title=TITLE,
|
||||
unique_id=54321,
|
||||
data={
|
||||
"auth_implementation": DOMAIN,
|
||||
"token": {
|
||||
"access_token": "mock-access-token",
|
||||
"refresh_token": "mock-refresh-token",
|
||||
"expires_at": expires_at,
|
||||
"scope": " ".join(scopes),
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(name="microbees")
|
||||
def mock_microbees():
|
||||
"""Mock microbees."""
|
||||
|
||||
devices_json = load_json_array_fixture("microbees/bees.json")
|
||||
devices = [Bee.from_dict(device) for device in devices_json]
|
||||
profile_json = load_json_object_fixture("microbees/profile.json")
|
||||
profile = Profile.from_dict(profile_json)
|
||||
mock = AsyncMock(spec=MicroBees)
|
||||
mock.getBees.return_value = devices
|
||||
mock.getMyProfile.return_value = profile
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.microbees.config_flow.MicroBees",
|
||||
return_value=mock,
|
||||
) as mock, patch(
|
||||
"homeassistant.components.microbees.MicroBees",
|
||||
return_value=mock,
|
||||
):
|
||||
yield mock
|
87
tests/components/microbees/fixtures/bees.json
Normal file
87
tests/components/microbees/fixtures/bees.json
Normal file
|
@ -0,0 +1,87 @@
|
|||
[
|
||||
{
|
||||
"id": 24907,
|
||||
"label": "Test this",
|
||||
"serial": "10521CB7C864",
|
||||
"gate_serial": "cde153cb-d55c-4230-be93-340eff8f53c2",
|
||||
"gate_id": 4466,
|
||||
"lastUpdate": 1707812698995,
|
||||
"name": "Test this",
|
||||
"active": true,
|
||||
"productID": 46,
|
||||
"prototypeName": "SocketBee Italy",
|
||||
"rssi": -67,
|
||||
"lastActivation": 1707768436222,
|
||||
"icon": "https://products.microbees.com/wp-content/uploads/2020/10/new-foto-socketbee-italia.png",
|
||||
"configuration": {},
|
||||
"sensors": [
|
||||
{
|
||||
"id": 59754,
|
||||
"name": "Sensore Assorbimento",
|
||||
"lastUpdate": 1707812700120,
|
||||
"deviceID": 462,
|
||||
"prototypeID": 223,
|
||||
"prototypeName": "Sensore Assorbimento",
|
||||
"device_type": 0,
|
||||
"dc_type": "Power",
|
||||
"unit": "Wh",
|
||||
"payload": [1, 1, 2, 0.5, 226, 0.013107268, 2, -67],
|
||||
"value": 1
|
||||
},
|
||||
{
|
||||
"id": 59755,
|
||||
"name": "Stato Interruttore Test this",
|
||||
"lastUpdate": 1707812700129,
|
||||
"deviceID": 463,
|
||||
"prototypeID": 224,
|
||||
"prototypeName": "Stato Interruttore",
|
||||
"device_type": 1,
|
||||
"dc_type": "Uptime",
|
||||
"unit": "",
|
||||
"payload": [1, 1, 2, 0.5, 226, 0.013107268, 2, -67],
|
||||
"value": 1
|
||||
}
|
||||
],
|
||||
"actuators": [
|
||||
{
|
||||
"id": 25497,
|
||||
"name": "Test this",
|
||||
"prototypeName": "Interruttore",
|
||||
"deviceID": 461,
|
||||
"configuration": {
|
||||
"actuator_type": "1",
|
||||
"icon": "power_button"
|
||||
},
|
||||
"starred": true,
|
||||
"uptime": 2812005,
|
||||
"sensorID": 59755,
|
||||
"payload": [1, 1, 2, 0.5, 226, 0.013107268, 2, -67],
|
||||
"value": 1,
|
||||
"rooms": []
|
||||
}
|
||||
],
|
||||
"rooms": [],
|
||||
"status_string": [
|
||||
{
|
||||
"name": "Seriale",
|
||||
"value": "10521CB7C864",
|
||||
"icon": "numeric"
|
||||
},
|
||||
{
|
||||
"name": "Ultimo Aggiornamento",
|
||||
"value": "09:25",
|
||||
"icon": "av-timer"
|
||||
},
|
||||
{
|
||||
"name": "Sensore Assorbimento",
|
||||
"value": "1W",
|
||||
"icon": "flash"
|
||||
},
|
||||
{
|
||||
"name": "Stato Interruttore Test This",
|
||||
"value": "on",
|
||||
"icon": "toggle-switch-on"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
9
tests/components/microbees/fixtures/profile.json
Normal file
9
tests/components/microbees/fixtures/profile.json
Normal file
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"id": 54321,
|
||||
"username": "test@microbees.com",
|
||||
"firstName": "Test",
|
||||
"lastName": "Microbees",
|
||||
"email": "test@microbees.com",
|
||||
"locale": "it",
|
||||
"timeZone": "Europe/Rome"
|
||||
}
|
365
tests/components/microbees/test_config_flow.py
Normal file
365
tests/components/microbees/test_config_flow.py
Normal file
|
@ -0,0 +1,365 @@
|
|||
"""Tests for config flow."""
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from microBeesPy.microbees import MicroBeesException
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.microbees.const import DOMAIN
|
||||
from homeassistant.config_entries import SOURCE_REAUTH, SOURCE_USER
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.data_entry_flow import FlowResultType
|
||||
from homeassistant.helpers import config_entry_oauth2_flow
|
||||
|
||||
from . import setup_integration
|
||||
from .conftest import CLIENT_ID, MICROBEES_AUTH_URI, MICROBEES_TOKEN_URI, SCOPES
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
from tests.test_util.aiohttp import AiohttpClientMocker
|
||||
from tests.typing import ClientSessionGenerator
|
||||
|
||||
|
||||
async def test_full_flow(
|
||||
hass: HomeAssistant,
|
||||
hass_client_no_auth: ClientSessionGenerator,
|
||||
current_request_with_host: None,
|
||||
aioclient_mock: AiohttpClientMocker,
|
||||
microbees: AsyncMock,
|
||||
) -> None:
|
||||
"""Check full flow."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": 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"] == FlowResultType.EXTERNAL_STEP
|
||||
assert result["url"] == (
|
||||
f"{MICROBEES_AUTH_URI}?"
|
||||
f"response_type=code&client_id={CLIENT_ID}&"
|
||||
"redirect_uri=https://example.com/auth/external/callback&"
|
||||
f"state={state}"
|
||||
f"&scope={'+'.join(SCOPES)}"
|
||||
)
|
||||
|
||||
client = await hass_client_no_auth()
|
||||
resp = await client.get(f"/auth/external/callback?code=abcd&state={state}")
|
||||
assert resp.status == 200
|
||||
assert resp.headers["content-type"] == "text/html; charset=utf-8"
|
||||
aioclient_mock.clear_requests()
|
||||
aioclient_mock.post(
|
||||
MICROBEES_TOKEN_URI,
|
||||
json={
|
||||
"access_token": "mock-access-token",
|
||||
"token_type": "bearer",
|
||||
"refresh_token": "mock-refresh-token",
|
||||
"expires_in": 99999,
|
||||
"scope": " ".join(SCOPES),
|
||||
"client_id": CLIENT_ID,
|
||||
},
|
||||
)
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.microbees.async_setup_entry", return_value=True
|
||||
) as mock_setup:
|
||||
result = await hass.config_entries.flow.async_configure(result["flow_id"])
|
||||
|
||||
assert len(hass.config_entries.async_entries(DOMAIN)) == 1
|
||||
assert len(mock_setup.mock_calls) == 1
|
||||
|
||||
assert result["type"] == FlowResultType.CREATE_ENTRY
|
||||
assert result["title"] == "test@microbees.com"
|
||||
assert "result" in result
|
||||
assert result["result"].unique_id == 54321
|
||||
assert "token" in result["result"].data
|
||||
assert result["result"].data["token"]["access_token"] == "mock-access-token"
|
||||
assert result["result"].data["token"]["refresh_token"] == "mock-refresh-token"
|
||||
|
||||
|
||||
async def test_config_non_unique_profile(
|
||||
hass: HomeAssistant,
|
||||
hass_client_no_auth: ClientSessionGenerator,
|
||||
current_request_with_host: None,
|
||||
microbees: AsyncMock,
|
||||
config_entry: MockConfigEntry,
|
||||
aioclient_mock: AiohttpClientMocker,
|
||||
) -> None:
|
||||
"""Test setup a non-unique profile."""
|
||||
await setup_integration(hass, config_entry)
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": 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"] == FlowResultType.EXTERNAL_STEP
|
||||
assert result["url"] == (
|
||||
f"{MICROBEES_AUTH_URI}?"
|
||||
f"response_type=code&client_id={CLIENT_ID}&"
|
||||
"redirect_uri=https://example.com/auth/external/callback&"
|
||||
f"state={state}"
|
||||
f"&scope={'+'.join(SCOPES)}"
|
||||
)
|
||||
|
||||
client = await hass_client_no_auth()
|
||||
resp = await client.get(f"/auth/external/callback?code=abcd&state={state}")
|
||||
assert resp.status == 200
|
||||
assert resp.headers["content-type"] == "text/html; charset=utf-8"
|
||||
|
||||
aioclient_mock.clear_requests()
|
||||
aioclient_mock.post(
|
||||
MICROBEES_TOKEN_URI,
|
||||
json={
|
||||
"access_token": "mock-access-token",
|
||||
"token_type": "bearer",
|
||||
"refresh_token": "mock-refresh-token",
|
||||
"expires_in": 99999,
|
||||
"scope": " ".join(SCOPES),
|
||||
"client_id": CLIENT_ID,
|
||||
},
|
||||
)
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(result["flow_id"])
|
||||
assert result["type"] == FlowResultType.ABORT
|
||||
assert result["reason"] == "already_configured"
|
||||
|
||||
|
||||
async def test_config_reauth_profile(
|
||||
hass: HomeAssistant,
|
||||
hass_client_no_auth: ClientSessionGenerator,
|
||||
aioclient_mock: AiohttpClientMocker,
|
||||
config_entry: MockConfigEntry,
|
||||
microbees: AsyncMock,
|
||||
current_request_with_host,
|
||||
) -> None:
|
||||
"""Test reauth an existing profile reauthenticates the config entry."""
|
||||
await setup_integration(hass, config_entry)
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={
|
||||
"source": SOURCE_REAUTH,
|
||||
"entry_id": config_entry.entry_id,
|
||||
},
|
||||
data=config_entry.data,
|
||||
)
|
||||
assert result["type"] == "form"
|
||||
assert result["step_id"] == "reauth_confirm"
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(result["flow_id"], {})
|
||||
state = config_entry_oauth2_flow._encode_jwt(
|
||||
hass,
|
||||
{
|
||||
"flow_id": result["flow_id"],
|
||||
"redirect_uri": "https://example.com/auth/external/callback",
|
||||
},
|
||||
)
|
||||
assert result["url"] == (
|
||||
f"{MICROBEES_AUTH_URI}?"
|
||||
f"response_type=code&client_id={CLIENT_ID}&"
|
||||
"redirect_uri=https://example.com/auth/external/callback&"
|
||||
f"state={state}"
|
||||
f"&scope={'+'.join(SCOPES)}"
|
||||
)
|
||||
client = await hass_client_no_auth()
|
||||
resp = await client.get(f"/auth/external/callback?code=abcd&state={state}")
|
||||
assert resp.status == 200
|
||||
assert resp.headers["content-type"] == "text/html; charset=utf-8"
|
||||
|
||||
aioclient_mock.clear_requests()
|
||||
aioclient_mock.post(
|
||||
MICROBEES_TOKEN_URI,
|
||||
json={
|
||||
"access_token": "mock-access-token",
|
||||
"token_type": "bearer",
|
||||
"refresh_token": "mock-refresh-token",
|
||||
"expires_in": 99999,
|
||||
"scope": " ".join(SCOPES),
|
||||
"client_id": CLIENT_ID,
|
||||
},
|
||||
)
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(result["flow_id"])
|
||||
assert result
|
||||
assert result["type"] == FlowResultType.ABORT
|
||||
assert result["reason"] == "reauth_successful"
|
||||
|
||||
|
||||
async def test_config_reauth_wrong_account(
|
||||
hass: HomeAssistant,
|
||||
hass_client_no_auth: ClientSessionGenerator,
|
||||
aioclient_mock: AiohttpClientMocker,
|
||||
config_entry: MockConfigEntry,
|
||||
microbees: AsyncMock,
|
||||
current_request_with_host,
|
||||
) -> None:
|
||||
"""Test reauth with wrong account."""
|
||||
await setup_integration(hass, config_entry)
|
||||
microbees.return_value.getMyProfile.return_value.id = 12345
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={
|
||||
"source": SOURCE_REAUTH,
|
||||
"entry_id": config_entry.entry_id,
|
||||
},
|
||||
data=config_entry.data,
|
||||
)
|
||||
assert result["type"] == "form"
|
||||
assert result["step_id"] == "reauth_confirm"
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(result["flow_id"], {})
|
||||
state = config_entry_oauth2_flow._encode_jwt(
|
||||
hass,
|
||||
{
|
||||
"flow_id": result["flow_id"],
|
||||
"redirect_uri": "https://example.com/auth/external/callback",
|
||||
},
|
||||
)
|
||||
assert result["url"] == (
|
||||
f"{MICROBEES_AUTH_URI}?"
|
||||
f"response_type=code&client_id={CLIENT_ID}&"
|
||||
"redirect_uri=https://example.com/auth/external/callback&"
|
||||
f"state={state}"
|
||||
f"&scope={'+'.join(SCOPES)}"
|
||||
)
|
||||
client = await hass_client_no_auth()
|
||||
resp = await client.get(f"/auth/external/callback?code=abcd&state={state}")
|
||||
assert resp.status == 200
|
||||
assert resp.headers["content-type"] == "text/html; charset=utf-8"
|
||||
|
||||
aioclient_mock.clear_requests()
|
||||
aioclient_mock.post(
|
||||
MICROBEES_TOKEN_URI,
|
||||
json={
|
||||
"access_token": "mock-access-token",
|
||||
"token_type": "bearer",
|
||||
"refresh_token": "mock-refresh-token",
|
||||
"expires_in": 99999,
|
||||
"scope": " ".join(SCOPES),
|
||||
"client_id": CLIENT_ID,
|
||||
},
|
||||
)
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(result["flow_id"])
|
||||
assert result
|
||||
assert result["type"] == FlowResultType.ABORT
|
||||
assert result["reason"] == "wrong_account"
|
||||
|
||||
|
||||
async def test_config_flow_with_invalid_credentials(
|
||||
hass: HomeAssistant,
|
||||
hass_client_no_auth: ClientSessionGenerator,
|
||||
aioclient_mock: AiohttpClientMocker,
|
||||
microbees: AsyncMock,
|
||||
current_request_with_host,
|
||||
) -> None:
|
||||
"""Test flow with invalid credentials."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": 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"] == FlowResultType.EXTERNAL_STEP
|
||||
assert result["url"] == (
|
||||
f"{MICROBEES_AUTH_URI}?"
|
||||
f"response_type=code&client_id={CLIENT_ID}&"
|
||||
"redirect_uri=https://example.com/auth/external/callback&"
|
||||
f"state={state}"
|
||||
f"&scope={'+'.join(SCOPES)}"
|
||||
)
|
||||
|
||||
client = await hass_client_no_auth()
|
||||
resp = await client.get(f"/auth/external/callback?code=abcd&state={state}")
|
||||
assert resp.status == 200
|
||||
assert resp.headers["content-type"] == "text/html; charset=utf-8"
|
||||
|
||||
aioclient_mock.clear_requests()
|
||||
aioclient_mock.post(
|
||||
MICROBEES_TOKEN_URI,
|
||||
json={
|
||||
"status": 401,
|
||||
"error": "Invalid Params: invalid client id/secret",
|
||||
},
|
||||
)
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(result["flow_id"])
|
||||
assert result
|
||||
assert result["type"] == FlowResultType.ABORT
|
||||
assert result["reason"] == "oauth_error"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("exception", "error"),
|
||||
[
|
||||
(MicroBeesException("Invalid auth"), "invalid_auth"),
|
||||
(Exception("Unexpected error"), "unknown"),
|
||||
],
|
||||
)
|
||||
async def test_unexpected_exceptions(
|
||||
hass: HomeAssistant,
|
||||
hass_client_no_auth: ClientSessionGenerator,
|
||||
aioclient_mock: AiohttpClientMocker,
|
||||
config_entry: MockConfigEntry,
|
||||
microbees: AsyncMock,
|
||||
exception: Exception,
|
||||
error: str,
|
||||
current_request_with_host,
|
||||
) -> None:
|
||||
"""Test unknown error from server."""
|
||||
await setup_integration(hass, config_entry)
|
||||
microbees.return_value.getMyProfile.side_effect = exception
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": 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"] == FlowResultType.EXTERNAL_STEP
|
||||
assert result["url"] == (
|
||||
f"{MICROBEES_AUTH_URI}?"
|
||||
f"response_type=code&client_id={CLIENT_ID}&"
|
||||
"redirect_uri=https://example.com/auth/external/callback&"
|
||||
f"state={state}"
|
||||
f"&scope={'+'.join(SCOPES)}"
|
||||
)
|
||||
|
||||
client = await hass_client_no_auth()
|
||||
resp = await client.get(f"/auth/external/callback?code=abcd&state={state}")
|
||||
assert resp.status == 200
|
||||
assert resp.headers["content-type"] == "text/html; charset=utf-8"
|
||||
aioclient_mock.clear_requests()
|
||||
aioclient_mock.post(
|
||||
MICROBEES_TOKEN_URI,
|
||||
json={
|
||||
"access_token": "mock-access-token",
|
||||
"token_type": "bearer",
|
||||
"refresh_token": "mock-refresh-token",
|
||||
"expires_in": 99999,
|
||||
"scope": " ".join(SCOPES),
|
||||
"client_id": CLIENT_ID,
|
||||
},
|
||||
)
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(result["flow_id"])
|
||||
assert result
|
||||
assert result["type"] == FlowResultType.ABORT
|
||||
assert result["reason"] == error
|
Loading…
Add table
Reference in a new issue