From c932407560d828eac683460daef4d5710c63ce76 Mon Sep 17 00:00:00 2001 From: Milan Meulemans Date: Fri, 15 Apr 2022 00:29:31 +0200 Subject: [PATCH] Add SENZ OAuth2 integration (#61233) --- .coveragerc | 3 + .strict-typing | 1 + CODEOWNERS | 2 + homeassistant/components/senz/__init__.py | 116 ++++++++++++++++++ homeassistant/components/senz/api.py | 25 ++++ homeassistant/components/senz/climate.py | 104 ++++++++++++++++ homeassistant/components/senz/config_flow.py | 24 ++++ homeassistant/components/senz/const.py | 3 + homeassistant/components/senz/manifest.json | 10 ++ homeassistant/components/senz/strings.json | 20 +++ .../components/senz/translations/en.json | 20 +++ homeassistant/generated/config_flows.py | 1 + mypy.ini | 11 ++ requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/senz/__init__.py | 1 + tests/components/senz/test_config_flow.py | 69 +++++++++++ 17 files changed, 416 insertions(+) create mode 100644 homeassistant/components/senz/__init__.py create mode 100644 homeassistant/components/senz/api.py create mode 100644 homeassistant/components/senz/climate.py create mode 100644 homeassistant/components/senz/config_flow.py create mode 100644 homeassistant/components/senz/const.py create mode 100644 homeassistant/components/senz/manifest.json create mode 100644 homeassistant/components/senz/strings.json create mode 100644 homeassistant/components/senz/translations/en.json create mode 100644 tests/components/senz/__init__.py create mode 100644 tests/components/senz/test_config_flow.py diff --git a/.coveragerc b/.coveragerc index 7d6b5fcd944..7f8d7902831 100644 --- a/.coveragerc +++ b/.coveragerc @@ -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 diff --git a/.strict-typing b/.strict-typing index fb71591ccaf..891662f3e80 100644 --- a/.strict-typing +++ b/.strict-typing @@ -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.* diff --git a/CODEOWNERS b/CODEOWNERS index 9cb2a12b1a8..614ed10c2bd 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -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 diff --git a/homeassistant/components/senz/__init__.py b/homeassistant/components/senz/__init__.py new file mode 100644 index 00000000000..b6fc2422888 --- /dev/null +++ b/homeassistant/components/senz/__init__.py @@ -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 diff --git a/homeassistant/components/senz/api.py b/homeassistant/components/senz/api.py new file mode 100644 index 00000000000..1f0ccee3c7c --- /dev/null +++ b/homeassistant/components/senz/api.py @@ -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"]) diff --git a/homeassistant/components/senz/climate.py b/homeassistant/components/senz/climate.py new file mode 100644 index 00000000000..e99e0550b90 --- /dev/null +++ b/homeassistant/components/senz/climate.py @@ -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() diff --git a/homeassistant/components/senz/config_flow.py b/homeassistant/components/senz/config_flow.py new file mode 100644 index 00000000000..1bc38321370 --- /dev/null +++ b/homeassistant/components/senz/config_flow.py @@ -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"} diff --git a/homeassistant/components/senz/const.py b/homeassistant/components/senz/const.py new file mode 100644 index 00000000000..664f2d91c6b --- /dev/null +++ b/homeassistant/components/senz/const.py @@ -0,0 +1,3 @@ +"""Constants for the nVent RAYCHEM SENZ integration.""" + +DOMAIN = "senz" diff --git a/homeassistant/components/senz/manifest.json b/homeassistant/components/senz/manifest.json new file mode 100644 index 00000000000..e9b6165cb26 --- /dev/null +++ b/homeassistant/components/senz/manifest.json @@ -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" +} diff --git a/homeassistant/components/senz/strings.json b/homeassistant/components/senz/strings.json new file mode 100644 index 00000000000..316f7234f9b --- /dev/null +++ b/homeassistant/components/senz/strings.json @@ -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%]" + } + } +} diff --git a/homeassistant/components/senz/translations/en.json b/homeassistant/components/senz/translations/en.json new file mode 100644 index 00000000000..bdf574691c5 --- /dev/null +++ b/homeassistant/components/senz/translations/en.json @@ -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" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index a7eced345b4..8fc33a593c4 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -296,6 +296,7 @@ FLOWS = { "senseme", "sensibo", "sentry", + "senz", "sharkiq", "shelly", "shopping_list", diff --git a/mypy.ini b/mypy.ini index 1bddbe4de9c..f36fb63d4a8 100644 --- a/mypy.ini +++ b/mypy.ini @@ -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 diff --git a/requirements_all.txt b/requirements_all.txt index 5d37a000bdb..ce467ff0c30 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -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 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 99d51ecacc6..391cb00eefb 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -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 diff --git a/tests/components/senz/__init__.py b/tests/components/senz/__init__.py new file mode 100644 index 00000000000..1428c18be94 --- /dev/null +++ b/tests/components/senz/__init__.py @@ -0,0 +1 @@ +"""Tests for the SENZ integration.""" diff --git a/tests/components/senz/test_config_flow.py b/tests/components/senz/test_config_flow.py new file mode 100644 index 00000000000..5143acea60a --- /dev/null +++ b/tests/components/senz/test_config_flow.py @@ -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