Add SENZ OAuth2 integration (#61233)
This commit is contained in:
parent
c85387290a
commit
c932407560
17 changed files with 416 additions and 0 deletions
|
@ -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
|
||||
|
|
|
@ -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.*
|
||||
|
|
|
@ -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
|
||||
|
|
116
homeassistant/components/senz/__init__.py
Normal file
116
homeassistant/components/senz/__init__.py
Normal 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
|
25
homeassistant/components/senz/api.py
Normal file
25
homeassistant/components/senz/api.py
Normal 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"])
|
104
homeassistant/components/senz/climate.py
Normal file
104
homeassistant/components/senz/climate.py
Normal 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()
|
24
homeassistant/components/senz/config_flow.py
Normal file
24
homeassistant/components/senz/config_flow.py
Normal 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"}
|
3
homeassistant/components/senz/const.py
Normal file
3
homeassistant/components/senz/const.py
Normal file
|
@ -0,0 +1,3 @@
|
|||
"""Constants for the nVent RAYCHEM SENZ integration."""
|
||||
|
||||
DOMAIN = "senz"
|
10
homeassistant/components/senz/manifest.json
Normal file
10
homeassistant/components/senz/manifest.json
Normal 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"
|
||||
}
|
20
homeassistant/components/senz/strings.json
Normal file
20
homeassistant/components/senz/strings.json
Normal 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%]"
|
||||
}
|
||||
}
|
||||
}
|
20
homeassistant/components/senz/translations/en.json
Normal file
20
homeassistant/components/senz/translations/en.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -296,6 +296,7 @@ FLOWS = {
|
|||
"senseme",
|
||||
"sensibo",
|
||||
"sentry",
|
||||
"senz",
|
||||
"sharkiq",
|
||||
"shelly",
|
||||
"shopping_list",
|
||||
|
|
11
mypy.ini
11
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
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
1
tests/components/senz/__init__.py
Normal file
1
tests/components/senz/__init__.py
Normal file
|
@ -0,0 +1 @@
|
|||
"""Tests for the SENZ integration."""
|
69
tests/components/senz/test_config_flow.py
Normal file
69
tests/components/senz/test_config_flow.py
Normal 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
|
Loading…
Add table
Add a link
Reference in a new issue