Add SENZ OAuth2 integration (#61233)

This commit is contained in:
Milan Meulemans 2022-04-15 00:29:31 +02:00 committed by GitHub
parent c85387290a
commit c932407560
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 416 additions and 0 deletions

View File

@ -1028,6 +1028,9 @@ omit =
homeassistant/components/sensibo/number.py
homeassistant/components/sensibo/select.py
homeassistant/components/sensibo/sensor.py
homeassistant/components/senz/__init__.py
homeassistant/components/senz/api.py
homeassistant/components/senz/climate.py
homeassistant/components/serial/sensor.py
homeassistant/components/serial_pm/sensor.py
homeassistant/components/sesame/lock.py

View File

@ -199,6 +199,7 @@ homeassistant.components.scene.*
homeassistant.components.select.*
homeassistant.components.sensor.*
homeassistant.components.senseme.*
homeassistant.components.senz.*
homeassistant.components.shelly.*
homeassistant.components.simplisafe.*
homeassistant.components.slack.*

View File

@ -882,6 +882,8 @@ build.json @home-assistant/supervisor
/tests/components/sensor/ @home-assistant/core
/homeassistant/components/sentry/ @dcramer @frenck
/tests/components/sentry/ @dcramer @frenck
/homeassistant/components/senz/ @milanmeu
/tests/components/senz/ @milanmeu
/homeassistant/components/serial/ @fabaff
/homeassistant/components/seven_segments/ @fabaff
/homeassistant/components/sharkiq/ @JeffResc @funkybunch @AritroSaha10

View File

@ -0,0 +1,116 @@
"""The nVent RAYCHEM SENZ integration."""
from __future__ import annotations
from datetime import timedelta
import logging
from aiosenz import AUTHORIZATION_ENDPOINT, SENZAPI, TOKEN_ENDPOINT, Thermostat
from httpx import RequestError
import voluptuous as vol
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import (
config_entry_oauth2_flow,
config_validation as cv,
httpx_client,
)
from homeassistant.helpers.typing import ConfigType
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from . import config_flow
from .api import SENZConfigEntryAuth
from .const import DOMAIN
UPDATE_INTERVAL = timedelta(seconds=30)
_LOGGER = logging.getLogger(__name__)
CONFIG_SCHEMA = vol.Schema(
{
DOMAIN: vol.Schema(
{
vol.Required(CONF_CLIENT_ID): cv.string,
vol.Required(CONF_CLIENT_SECRET): cv.string,
}
)
},
extra=vol.ALLOW_EXTRA,
)
PLATFORMS = [Platform.CLIMATE]
SENZDataUpdateCoordinator = DataUpdateCoordinator[dict[str, Thermostat]]
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the SENZ OAuth2 configuration."""
hass.data[DOMAIN] = {}
if DOMAIN not in config:
return True
config_flow.OAuth2FlowHandler.async_register_implementation(
hass,
config_entry_oauth2_flow.LocalOAuth2Implementation(
hass,
DOMAIN,
config[DOMAIN][CONF_CLIENT_ID],
config[DOMAIN][CONF_CLIENT_SECRET],
AUTHORIZATION_ENDPOINT,
TOKEN_ENDPOINT,
),
)
return True
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up SENZ 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)
auth = SENZConfigEntryAuth(httpx_client.get_async_client(hass), session)
senz_api = SENZAPI(auth)
async def update_thermostats() -> dict[str, Thermostat]:
"""Fetch SENZ thermostats data."""
try:
thermostats = await senz_api.get_thermostats()
except RequestError as err:
raise UpdateFailed from err
return {thermostat.serial_number: thermostat for thermostat in thermostats}
try:
account = await senz_api.get_account()
except RequestError as err:
raise ConfigEntryNotReady from err
coordinator = SENZDataUpdateCoordinator(
hass,
_LOGGER,
name=account.username,
update_interval=UPDATE_INTERVAL,
update_method=update_thermostats,
)
await coordinator.async_config_entry_first_refresh()
hass.data[DOMAIN][entry.entry_id] = coordinator
hass.config_entries.async_setup_platforms(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
hass.data[DOMAIN].pop(entry.entry_id)
return unload_ok

View File

@ -0,0 +1,25 @@
"""API for nVent RAYCHEM SENZ bound to Home Assistant OAuth."""
from typing import cast
from aiosenz import AbstractSENZAuth
from httpx import AsyncClient
from homeassistant.helpers import config_entry_oauth2_flow
class SENZConfigEntryAuth(AbstractSENZAuth):
"""Provide nVent RAYCHEM SENZ authentication tied to an OAuth2 based config entry."""
def __init__(
self,
httpx_async_client: AsyncClient,
oauth_session: config_entry_oauth2_flow.OAuth2Session,
) -> None:
"""Initialize SENZ auth."""
super().__init__(httpx_async_client)
self._oauth_session = oauth_session
async def get_access_token(self) -> str:
"""Return a valid access token."""
await self._oauth_session.async_ensure_token_valid()
return cast(str, self._oauth_session.token["access_token"])

View File

@ -0,0 +1,104 @@
"""nVent RAYCHEM SENZ climate platform."""
from __future__ import annotations
from typing import Any
from aiosenz import MODE_AUTO, Thermostat
from homeassistant.components.climate import ClimateEntity
from homeassistant.components.climate.const import (
HVAC_MODE_AUTO,
HVAC_MODE_HEAT,
ClimateEntityFeature,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_TEMPERATURE, PRECISION_TENTHS, TEMP_CELSIUS
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from . import SENZDataUpdateCoordinator
from .const import DOMAIN
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the SENZ climate entities from a config entry."""
coordinator: SENZDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
async_add_entities(
SENZClimate(thermostat, coordinator) for thermostat in coordinator.data.values()
)
class SENZClimate(CoordinatorEntity, ClimateEntity):
"""Representation of a SENZ climate entity."""
_attr_temperature_unit = TEMP_CELSIUS
_attr_precision = PRECISION_TENTHS
_attr_hvac_modes = [HVAC_MODE_HEAT, HVAC_MODE_AUTO]
_attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE
_attr_max_temp = 35
_attr_min_temp = 5
def __init__(
self,
thermostat: Thermostat,
coordinator: SENZDataUpdateCoordinator,
) -> None:
"""Init SENZ climate."""
super().__init__(coordinator)
self._thermostat = thermostat
self._attr_name = thermostat.name
self._attr_unique_id = thermostat.serial_number
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, thermostat.serial_number)},
manufacturer="nVent Raychem",
model="SENZ WIFI",
name=thermostat.name,
)
@callback
def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator."""
self._thermostat = self.coordinator.data[self._thermostat.serial_number]
self.async_write_ha_state()
@property
def current_temperature(self) -> float:
"""Return the current temperature."""
return self._thermostat.current_temperatue
@property
def target_temperature(self) -> float:
"""Return the temperature we try to reach."""
return self._thermostat.setpoint_temperature
@property
def available(self) -> bool:
"""Return True if the thermostat is available."""
return self._thermostat.online
@property
def hvac_mode(self) -> str:
"""Return hvac operation ie. auto, heat mode."""
if self._thermostat.mode == MODE_AUTO:
return HVAC_MODE_AUTO
return HVAC_MODE_HEAT
async def async_set_hvac_mode(self, hvac_mode: str) -> None:
"""Set new target hvac mode."""
if hvac_mode == HVAC_MODE_AUTO:
await self._thermostat.auto()
else:
await self._thermostat.manual()
await self.coordinator.async_request_refresh()
async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set new target temperature."""
temp: float = kwargs[ATTR_TEMPERATURE]
await self._thermostat.manual(temp)
await self.coordinator.async_request_refresh()

View File

@ -0,0 +1,24 @@
"""Config flow for nVent RAYCHEM SENZ."""
import logging
from homeassistant.helpers import config_entry_oauth2_flow
from .const import DOMAIN
class OAuth2FlowHandler(
config_entry_oauth2_flow.AbstractOAuth2FlowHandler, domain=DOMAIN
):
"""Config flow to handle SENZ OAuth2 authentication."""
DOMAIN = DOMAIN
@property
def logger(self) -> logging.Logger:
"""Return logger."""
return logging.getLogger(__name__)
@property
def extra_authorize_data(self) -> dict:
"""Extra data that needs to be appended to the authorize url."""
return {"scope": "restapi offline_access"}

View File

@ -0,0 +1,3 @@
"""Constants for the nVent RAYCHEM SENZ integration."""
DOMAIN = "senz"

View File

@ -0,0 +1,10 @@
{
"domain": "senz",
"name": "nVent RAYCHEM SENZ",
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/senz",
"requirements": ["aiosenz==1.0.0"],
"dependencies": ["auth"],
"codeowners": ["@milanmeu"],
"iot_class": "cloud_polling"
}

View File

@ -0,0 +1,20 @@
{
"config": {
"step": {
"pick_implementation": {
"title": "[%key:common::config_flow::title::oauth2_pick_implementation%]"
}
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
"oauth_error": "[%key:common::config_flow::abort::oauth2_error%]",
"missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]",
"authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]",
"no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]"
},
"create_entry": {
"default": "[%key:common::config_flow::create_entry::authenticated%]"
}
}
}

View File

@ -0,0 +1,20 @@
{
"config": {
"abort": {
"already_configured": "Account is already configured",
"already_in_progress": "Configuration flow is already in progress",
"authorize_url_timeout": "Timeout generating authorize URL.",
"missing_configuration": "The component is not configured. Please follow the documentation.",
"no_url_available": "No URL available. For information about this error, [check the help section]({docs_url})",
"oauth_error": "Received invalid token data."
},
"create_entry": {
"default": "Successfully authenticated"
},
"step": {
"pick_implementation": {
"title": "Pick Authentication Method"
}
}
}
}

View File

@ -296,6 +296,7 @@ FLOWS = {
"senseme",
"sensibo",
"sentry",
"senz",
"sharkiq",
"shelly",
"shopping_list",

View File

@ -1991,6 +1991,17 @@ no_implicit_optional = true
warn_return_any = true
warn_unreachable = true
[mypy-homeassistant.components.senz.*]
check_untyped_defs = true
disallow_incomplete_defs = true
disallow_subclassing_any = true
disallow_untyped_calls = true
disallow_untyped_decorators = true
disallow_untyped_defs = true
no_implicit_optional = true
warn_return_any = true
warn_unreachable = true
[mypy-homeassistant.components.shelly.*]
check_untyped_defs = true
disallow_incomplete_defs = true

View File

@ -234,6 +234,9 @@ aioridwell==2022.03.0
# homeassistant.components.senseme
aiosenseme==0.6.1
# homeassistant.components.senz
aiosenz==1.0.0
# homeassistant.components.shelly
aioshelly==2.0.0

View File

@ -200,6 +200,9 @@ aioridwell==2022.03.0
# homeassistant.components.senseme
aiosenseme==0.6.1
# homeassistant.components.senz
aiosenz==1.0.0
# homeassistant.components.shelly
aioshelly==2.0.0

View File

@ -0,0 +1 @@
"""Tests for the SENZ integration."""

View File

@ -0,0 +1,69 @@
"""Test the SENZ config flow."""
from unittest.mock import patch
from aiosenz import AUTHORIZATION_ENDPOINT, TOKEN_ENDPOINT
from homeassistant import config_entries, setup
from homeassistant.components.senz.const import DOMAIN
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_entry_oauth2_flow
CLIENT_ID = "1234"
CLIENT_SECRET = "5678"
async def test_full_flow(
hass: HomeAssistant,
hass_client_no_auth,
aioclient_mock,
current_request_with_host,
) -> None:
"""Check full flow."""
assert await setup.async_setup_component(
hass,
"senz",
{
"senz": {"client_id": CLIENT_ID, "client_secret": CLIENT_SECRET},
"http": {"base_url": "https://example.com"},
},
)
result = await hass.config_entries.flow.async_init(
"senz", context={"source": config_entries.SOURCE_USER}
)
state = config_entry_oauth2_flow._encode_jwt(
hass,
{
"flow_id": result["flow_id"],
"redirect_uri": "https://example.com/auth/external/callback",
},
)
assert result["url"] == (
f"{AUTHORIZATION_ENDPOINT}?response_type=code&client_id={CLIENT_ID}"
"&redirect_uri=https://example.com/auth/external/callback"
f"&state={state}&scope=restapi+offline_access"
)
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.post(
TOKEN_ENDPOINT,
json={
"refresh_token": "mock-refresh-token",
"access_token": "mock-access-token",
"type": "Bearer",
"expires_in": 60,
},
)
with patch(
"homeassistant.components.senz.async_setup_entry", return_value=True
) as mock_setup:
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