From 3a4c6fc7f3dce9f4580fece11660e080c469b9db Mon Sep 17 00:00:00 2001 From: Marco Lettieri Date: Mon, 19 Feb 2024 15:12:03 +0100 Subject: [PATCH] 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 Co-authored-by: Federico D'Amico <48856240+FedDam@users.noreply.github.com> --- .coveragerc | 7 + CODEOWNERS | 2 + .../components/microbees/__init__.py | 64 +++ homeassistant/components/microbees/api.py | 28 ++ .../microbees/application_credentials.py | 14 + .../components/microbees/config_flow.py | 77 ++++ homeassistant/components/microbees/const.py | 9 + .../components/microbees/coordinator.py | 61 +++ homeassistant/components/microbees/entity.py | 52 +++ homeassistant/components/microbees/icons.json | 12 + .../components/microbees/manifest.json | 10 + .../components/microbees/strings.json | 28 ++ homeassistant/components/microbees/switch.py | 70 ++++ .../generated/application_credentials.py | 1 + homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/microbees/__init__.py | 10 + tests/components/microbees/conftest.py | 93 +++++ tests/components/microbees/fixtures/bees.json | 87 +++++ .../microbees/fixtures/profile.json | 9 + .../components/microbees/test_config_flow.py | 365 ++++++++++++++++++ 23 files changed, 1012 insertions(+) create mode 100644 homeassistant/components/microbees/__init__.py create mode 100644 homeassistant/components/microbees/api.py create mode 100644 homeassistant/components/microbees/application_credentials.py create mode 100644 homeassistant/components/microbees/config_flow.py create mode 100644 homeassistant/components/microbees/const.py create mode 100644 homeassistant/components/microbees/coordinator.py create mode 100644 homeassistant/components/microbees/entity.py create mode 100644 homeassistant/components/microbees/icons.json create mode 100644 homeassistant/components/microbees/manifest.json create mode 100644 homeassistant/components/microbees/strings.json create mode 100644 homeassistant/components/microbees/switch.py create mode 100644 tests/components/microbees/__init__.py create mode 100644 tests/components/microbees/conftest.py create mode 100644 tests/components/microbees/fixtures/bees.json create mode 100644 tests/components/microbees/fixtures/profile.json create mode 100644 tests/components/microbees/test_config_flow.py diff --git a/.coveragerc b/.coveragerc index ebff2fcf544..ca6c0e38471 100644 --- a/.coveragerc +++ b/.coveragerc @@ -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 diff --git a/CODEOWNERS b/CODEOWNERS index 47ad846b591..df117eac9a2 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -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 diff --git a/homeassistant/components/microbees/__init__.py b/homeassistant/components/microbees/__init__.py new file mode 100644 index 00000000000..007359e638f --- /dev/null +++ b/homeassistant/components/microbees/__init__.py @@ -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 diff --git a/homeassistant/components/microbees/api.py b/homeassistant/components/microbees/api.py new file mode 100644 index 00000000000..ec835169231 --- /dev/null +++ b/homeassistant/components/microbees/api.py @@ -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 diff --git a/homeassistant/components/microbees/application_credentials.py b/homeassistant/components/microbees/application_credentials.py new file mode 100644 index 00000000000..89b591c0f41 --- /dev/null +++ b/homeassistant/components/microbees/application_credentials.py @@ -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, + ) diff --git a/homeassistant/components/microbees/config_flow.py b/homeassistant/components/microbees/config_flow.py new file mode 100644 index 00000000000..4c8622c07fa --- /dev/null +++ b/homeassistant/components/microbees/config_flow.py @@ -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() diff --git a/homeassistant/components/microbees/const.py b/homeassistant/components/microbees/const.py new file mode 100644 index 00000000000..1cc21f115de --- /dev/null +++ b/homeassistant/components/microbees/const.py @@ -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, +] diff --git a/homeassistant/components/microbees/coordinator.py b/homeassistant/components/microbees/coordinator.py new file mode 100644 index 00000000000..b085028247c --- /dev/null +++ b/homeassistant/components/microbees/coordinator.py @@ -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) diff --git a/homeassistant/components/microbees/entity.py b/homeassistant/components/microbees/entity.py new file mode 100644 index 00000000000..e66d7b66f16 --- /dev/null +++ b/homeassistant/components/microbees/entity.py @@ -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] diff --git a/homeassistant/components/microbees/icons.json b/homeassistant/components/microbees/icons.json new file mode 100644 index 00000000000..34878389cb8 --- /dev/null +++ b/homeassistant/components/microbees/icons.json @@ -0,0 +1,12 @@ +{ + "entity": { + "switch": { + "socket_eu": { + "default": "mdi:power-socket-eu" + }, + "socket_it": { + "default": "mdi:power-socket-it" + } + } + } +} diff --git a/homeassistant/components/microbees/manifest.json b/homeassistant/components/microbees/manifest.json new file mode 100644 index 00000000000..bcc1c2e0cf5 --- /dev/null +++ b/homeassistant/components/microbees/manifest.json @@ -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"] +} diff --git a/homeassistant/components/microbees/strings.json b/homeassistant/components/microbees/strings.json new file mode 100644 index 00000000000..6f17a12834e --- /dev/null +++ b/homeassistant/components/microbees/strings.json @@ -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%]" + } + } +} diff --git a/homeassistant/components/microbees/switch.py b/homeassistant/components/microbees/switch.py new file mode 100644 index 00000000000..78a44f44ab3 --- /dev/null +++ b/homeassistant/components/microbees/switch.py @@ -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}") diff --git a/homeassistant/generated/application_credentials.py b/homeassistant/generated/application_credentials.py index 851474d8481..15ae2e369de 100644 --- a/homeassistant/generated/application_credentials.py +++ b/homeassistant/generated/application_credentials.py @@ -16,6 +16,7 @@ APPLICATION_CREDENTIALS = [ "husqvarna_automower", "lametric", "lyric", + "microbees", "myuplink", "neato", "nest", diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 4d909f40736..e485bd8dde9 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -309,6 +309,7 @@ FLOWS = { "meteo_france", "meteoclimatic", "metoffice", + "microbees", "mikrotik", "mill", "minecraft_server", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 093fffa3986..520344480b9 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -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": { diff --git a/requirements_all.txt b/requirements_all.txt index 00bc782ac84..fdfcddc37d4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -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 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c85b68a1295..1b52c1609d1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -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 diff --git a/tests/components/microbees/__init__.py b/tests/components/microbees/__init__.py new file mode 100644 index 00000000000..a33e1c59f5e --- /dev/null +++ b/tests/components/microbees/__init__.py @@ -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) diff --git a/tests/components/microbees/conftest.py b/tests/components/microbees/conftest.py new file mode 100644 index 00000000000..e5886a8dfec --- /dev/null +++ b/tests/components/microbees/conftest.py @@ -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 diff --git a/tests/components/microbees/fixtures/bees.json b/tests/components/microbees/fixtures/bees.json new file mode 100644 index 00000000000..86802218508 --- /dev/null +++ b/tests/components/microbees/fixtures/bees.json @@ -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" + } + ] + } +] diff --git a/tests/components/microbees/fixtures/profile.json b/tests/components/microbees/fixtures/profile.json new file mode 100644 index 00000000000..03e0f607d61 --- /dev/null +++ b/tests/components/microbees/fixtures/profile.json @@ -0,0 +1,9 @@ +{ + "id": 54321, + "username": "test@microbees.com", + "firstName": "Test", + "lastName": "Microbees", + "email": "test@microbees.com", + "locale": "it", + "timeZone": "Europe/Rome" +} diff --git a/tests/components/microbees/test_config_flow.py b/tests/components/microbees/test_config_flow.py new file mode 100644 index 00000000000..cb93660f743 --- /dev/null +++ b/tests/components/microbees/test_config_flow.py @@ -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