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:
Marco Lettieri 2024-02-19 15:12:03 +01:00 committed by GitHub
parent b349a466ba
commit 3a4c6fc7f3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 1012 additions and 0 deletions

View file

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

View file

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

View 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

View 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

View file

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

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

View 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,
]

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

View 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]

View file

@ -0,0 +1,12 @@
{
"entity": {
"switch": {
"socket_eu": {
"default": "mdi:power-socket-eu"
},
"socket_it": {
"default": "mdi:power-socket-it"
}
}
}
}

View 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"]
}

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

View 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}")

View file

@ -16,6 +16,7 @@ APPLICATION_CREDENTIALS = [
"husqvarna_automower",
"lametric",
"lyric",
"microbees",
"myuplink",
"neato",
"nest",

View file

@ -309,6 +309,7 @@ FLOWS = {
"meteo_france",
"meteoclimatic",
"metoffice",
"microbees",
"mikrotik",
"mill",
"minecraft_server",

View file

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

View file

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

View file

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

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

View 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

View 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"
}
]
}
]

View file

@ -0,0 +1,9 @@
{
"id": 54321,
"username": "test@microbees.com",
"firstName": "Test",
"lastName": "Microbees",
"email": "test@microbees.com",
"locale": "it",
"timeZone": "Europe/Rome"
}

View 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