Skip to content

Commit

Permalink
Add microBees integration (home-assistant#99573)
Browse files Browse the repository at this point in the history
* 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>
  • Loading branch information
3 people authored Feb 19, 2024
1 parent b349a46 commit 3a4c6fc
Show file tree
Hide file tree
Showing 23 changed files with 1,012 additions and 0 deletions.
7 changes: 7 additions & 0 deletions .coveragerc
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions CODEOWNERS
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
64 changes: 64 additions & 0 deletions homeassistant/components/microbees/__init__.py
Original file line number Diff line number Diff line change
@@ -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 changes: 28 additions & 0 deletions homeassistant/components/microbees/api.py
Original file line number Diff line number Diff line change
@@ -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
14 changes: 14 additions & 0 deletions homeassistant/components/microbees/application_credentials.py
Original file line number Diff line number Diff line change
@@ -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 changes: 77 additions & 0 deletions homeassistant/components/microbees/config_flow.py
Original file line number Diff line number Diff line change
@@ -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 changes: 9 additions & 0 deletions homeassistant/components/microbees/const.py
Original file line number Diff line number Diff line change
@@ -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 changes: 61 additions & 0 deletions homeassistant/components/microbees/coordinator.py
Original file line number Diff line number Diff line change
@@ -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 changes: 52 additions & 0 deletions homeassistant/components/microbees/entity.py
Original file line number Diff line number Diff line change
@@ -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 changes: 12 additions & 0 deletions homeassistant/components/microbees/icons.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"entity": {
"switch": {
"socket_eu": {
"default": "mdi:power-socket-eu"
},
"socket_it": {
"default": "mdi:power-socket-it"
}
}
}
}
10 changes: 10 additions & 0 deletions homeassistant/components/microbees/manifest.json
Original file line number Diff line number Diff line change
@@ -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 changes: 28 additions & 0 deletions homeassistant/components/microbees/strings.json
Original file line number Diff line number Diff line change
@@ -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%]"
}
}
}
Loading

0 comments on commit 3a4c6fc

Please sign in to comment.