Skip to content

Commit c932407

Browse files
authored
Add SENZ OAuth2 integration (#61233)
1 parent c853872 commit c932407

File tree

17 files changed

+416
-0
lines changed

17 files changed

+416
-0
lines changed

.coveragerc

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1028,6 +1028,9 @@ omit =
10281028
homeassistant/components/sensibo/number.py
10291029
homeassistant/components/sensibo/select.py
10301030
homeassistant/components/sensibo/sensor.py
1031+
homeassistant/components/senz/__init__.py
1032+
homeassistant/components/senz/api.py
1033+
homeassistant/components/senz/climate.py
10311034
homeassistant/components/serial/sensor.py
10321035
homeassistant/components/serial_pm/sensor.py
10331036
homeassistant/components/sesame/lock.py

.strict-typing

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,7 @@ homeassistant.components.scene.*
199199
homeassistant.components.select.*
200200
homeassistant.components.sensor.*
201201
homeassistant.components.senseme.*
202+
homeassistant.components.senz.*
202203
homeassistant.components.shelly.*
203204
homeassistant.components.simplisafe.*
204205
homeassistant.components.slack.*

CODEOWNERS

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -882,6 +882,8 @@ build.json @home-assistant/supervisor
882882
/tests/components/sensor/ @home-assistant/core
883883
/homeassistant/components/sentry/ @dcramer @frenck
884884
/tests/components/sentry/ @dcramer @frenck
885+
/homeassistant/components/senz/ @milanmeu
886+
/tests/components/senz/ @milanmeu
885887
/homeassistant/components/serial/ @fabaff
886888
/homeassistant/components/seven_segments/ @fabaff
887889
/homeassistant/components/sharkiq/ @JeffResc @funkybunch @AritroSaha10
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
"""The nVent RAYCHEM SENZ integration."""
2+
from __future__ import annotations
3+
4+
from datetime import timedelta
5+
import logging
6+
7+
from aiosenz import AUTHORIZATION_ENDPOINT, SENZAPI, TOKEN_ENDPOINT, Thermostat
8+
from httpx import RequestError
9+
import voluptuous as vol
10+
11+
from homeassistant.config_entries import ConfigEntry
12+
from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET, Platform
13+
from homeassistant.core import HomeAssistant
14+
from homeassistant.exceptions import ConfigEntryNotReady
15+
from homeassistant.helpers import (
16+
config_entry_oauth2_flow,
17+
config_validation as cv,
18+
httpx_client,
19+
)
20+
from homeassistant.helpers.typing import ConfigType
21+
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
22+
23+
from . import config_flow
24+
from .api import SENZConfigEntryAuth
25+
from .const import DOMAIN
26+
27+
UPDATE_INTERVAL = timedelta(seconds=30)
28+
29+
_LOGGER = logging.getLogger(__name__)
30+
31+
CONFIG_SCHEMA = vol.Schema(
32+
{
33+
DOMAIN: vol.Schema(
34+
{
35+
vol.Required(CONF_CLIENT_ID): cv.string,
36+
vol.Required(CONF_CLIENT_SECRET): cv.string,
37+
}
38+
)
39+
},
40+
extra=vol.ALLOW_EXTRA,
41+
)
42+
43+
PLATFORMS = [Platform.CLIMATE]
44+
45+
SENZDataUpdateCoordinator = DataUpdateCoordinator[dict[str, Thermostat]]
46+
47+
48+
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
49+
"""Set up the SENZ OAuth2 configuration."""
50+
hass.data[DOMAIN] = {}
51+
52+
if DOMAIN not in config:
53+
return True
54+
55+
config_flow.OAuth2FlowHandler.async_register_implementation(
56+
hass,
57+
config_entry_oauth2_flow.LocalOAuth2Implementation(
58+
hass,
59+
DOMAIN,
60+
config[DOMAIN][CONF_CLIENT_ID],
61+
config[DOMAIN][CONF_CLIENT_SECRET],
62+
AUTHORIZATION_ENDPOINT,
63+
TOKEN_ENDPOINT,
64+
),
65+
)
66+
67+
return True
68+
69+
70+
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
71+
"""Set up SENZ from a config entry."""
72+
implementation = (
73+
await config_entry_oauth2_flow.async_get_config_entry_implementation(
74+
hass, entry
75+
)
76+
)
77+
session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation)
78+
auth = SENZConfigEntryAuth(httpx_client.get_async_client(hass), session)
79+
senz_api = SENZAPI(auth)
80+
81+
async def update_thermostats() -> dict[str, Thermostat]:
82+
"""Fetch SENZ thermostats data."""
83+
try:
84+
thermostats = await senz_api.get_thermostats()
85+
except RequestError as err:
86+
raise UpdateFailed from err
87+
return {thermostat.serial_number: thermostat for thermostat in thermostats}
88+
89+
try:
90+
account = await senz_api.get_account()
91+
except RequestError as err:
92+
raise ConfigEntryNotReady from err
93+
94+
coordinator = SENZDataUpdateCoordinator(
95+
hass,
96+
_LOGGER,
97+
name=account.username,
98+
update_interval=UPDATE_INTERVAL,
99+
update_method=update_thermostats,
100+
)
101+
102+
await coordinator.async_config_entry_first_refresh()
103+
104+
hass.data[DOMAIN][entry.entry_id] = coordinator
105+
106+
hass.config_entries.async_setup_platforms(entry, PLATFORMS)
107+
108+
return True
109+
110+
111+
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
112+
"""Unload a config entry."""
113+
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
114+
hass.data[DOMAIN].pop(entry.entry_id)
115+
116+
return unload_ok
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
"""API for nVent RAYCHEM SENZ bound to Home Assistant OAuth."""
2+
from typing import cast
3+
4+
from aiosenz import AbstractSENZAuth
5+
from httpx import AsyncClient
6+
7+
from homeassistant.helpers import config_entry_oauth2_flow
8+
9+
10+
class SENZConfigEntryAuth(AbstractSENZAuth):
11+
"""Provide nVent RAYCHEM SENZ authentication tied to an OAuth2 based config entry."""
12+
13+
def __init__(
14+
self,
15+
httpx_async_client: AsyncClient,
16+
oauth_session: config_entry_oauth2_flow.OAuth2Session,
17+
) -> None:
18+
"""Initialize SENZ auth."""
19+
super().__init__(httpx_async_client)
20+
self._oauth_session = oauth_session
21+
22+
async def get_access_token(self) -> str:
23+
"""Return a valid access token."""
24+
await self._oauth_session.async_ensure_token_valid()
25+
return cast(str, self._oauth_session.token["access_token"])
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
"""nVent RAYCHEM SENZ climate platform."""
2+
from __future__ import annotations
3+
4+
from typing import Any
5+
6+
from aiosenz import MODE_AUTO, Thermostat
7+
8+
from homeassistant.components.climate import ClimateEntity
9+
from homeassistant.components.climate.const import (
10+
HVAC_MODE_AUTO,
11+
HVAC_MODE_HEAT,
12+
ClimateEntityFeature,
13+
)
14+
from homeassistant.config_entries import ConfigEntry
15+
from homeassistant.const import ATTR_TEMPERATURE, PRECISION_TENTHS, TEMP_CELSIUS
16+
from homeassistant.core import HomeAssistant, callback
17+
from homeassistant.helpers.entity import DeviceInfo
18+
from homeassistant.helpers.entity_platform import AddEntitiesCallback
19+
from homeassistant.helpers.update_coordinator import CoordinatorEntity
20+
21+
from . import SENZDataUpdateCoordinator
22+
from .const import DOMAIN
23+
24+
25+
async def async_setup_entry(
26+
hass: HomeAssistant,
27+
entry: ConfigEntry,
28+
async_add_entities: AddEntitiesCallback,
29+
) -> None:
30+
"""Set up the SENZ climate entities from a config entry."""
31+
coordinator: SENZDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
32+
async_add_entities(
33+
SENZClimate(thermostat, coordinator) for thermostat in coordinator.data.values()
34+
)
35+
36+
37+
class SENZClimate(CoordinatorEntity, ClimateEntity):
38+
"""Representation of a SENZ climate entity."""
39+
40+
_attr_temperature_unit = TEMP_CELSIUS
41+
_attr_precision = PRECISION_TENTHS
42+
_attr_hvac_modes = [HVAC_MODE_HEAT, HVAC_MODE_AUTO]
43+
_attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE
44+
_attr_max_temp = 35
45+
_attr_min_temp = 5
46+
47+
def __init__(
48+
self,
49+
thermostat: Thermostat,
50+
coordinator: SENZDataUpdateCoordinator,
51+
) -> None:
52+
"""Init SENZ climate."""
53+
super().__init__(coordinator)
54+
self._thermostat = thermostat
55+
self._attr_name = thermostat.name
56+
self._attr_unique_id = thermostat.serial_number
57+
self._attr_device_info = DeviceInfo(
58+
identifiers={(DOMAIN, thermostat.serial_number)},
59+
manufacturer="nVent Raychem",
60+
model="SENZ WIFI",
61+
name=thermostat.name,
62+
)
63+
64+
@callback
65+
def _handle_coordinator_update(self) -> None:
66+
"""Handle updated data from the coordinator."""
67+
self._thermostat = self.coordinator.data[self._thermostat.serial_number]
68+
self.async_write_ha_state()
69+
70+
@property
71+
def current_temperature(self) -> float:
72+
"""Return the current temperature."""
73+
return self._thermostat.current_temperatue
74+
75+
@property
76+
def target_temperature(self) -> float:
77+
"""Return the temperature we try to reach."""
78+
return self._thermostat.setpoint_temperature
79+
80+
@property
81+
def available(self) -> bool:
82+
"""Return True if the thermostat is available."""
83+
return self._thermostat.online
84+
85+
@property
86+
def hvac_mode(self) -> str:
87+
"""Return hvac operation ie. auto, heat mode."""
88+
if self._thermostat.mode == MODE_AUTO:
89+
return HVAC_MODE_AUTO
90+
return HVAC_MODE_HEAT
91+
92+
async def async_set_hvac_mode(self, hvac_mode: str) -> None:
93+
"""Set new target hvac mode."""
94+
if hvac_mode == HVAC_MODE_AUTO:
95+
await self._thermostat.auto()
96+
else:
97+
await self._thermostat.manual()
98+
await self.coordinator.async_request_refresh()
99+
100+
async def async_set_temperature(self, **kwargs: Any) -> None:
101+
"""Set new target temperature."""
102+
temp: float = kwargs[ATTR_TEMPERATURE]
103+
await self._thermostat.manual(temp)
104+
await self.coordinator.async_request_refresh()
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
"""Config flow for nVent RAYCHEM SENZ."""
2+
import logging
3+
4+
from homeassistant.helpers import config_entry_oauth2_flow
5+
6+
from .const import DOMAIN
7+
8+
9+
class OAuth2FlowHandler(
10+
config_entry_oauth2_flow.AbstractOAuth2FlowHandler, domain=DOMAIN
11+
):
12+
"""Config flow to handle SENZ OAuth2 authentication."""
13+
14+
DOMAIN = DOMAIN
15+
16+
@property
17+
def logger(self) -> logging.Logger:
18+
"""Return logger."""
19+
return logging.getLogger(__name__)
20+
21+
@property
22+
def extra_authorize_data(self) -> dict:
23+
"""Extra data that needs to be appended to the authorize url."""
24+
return {"scope": "restapi offline_access"}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
"""Constants for the nVent RAYCHEM SENZ integration."""
2+
3+
DOMAIN = "senz"
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{
2+
"domain": "senz",
3+
"name": "nVent RAYCHEM SENZ",
4+
"config_flow": true,
5+
"documentation": "https://www.home-assistant.io/integrations/senz",
6+
"requirements": ["aiosenz==1.0.0"],
7+
"dependencies": ["auth"],
8+
"codeowners": ["@milanmeu"],
9+
"iot_class": "cloud_polling"
10+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
{
2+
"config": {
3+
"step": {
4+
"pick_implementation": {
5+
"title": "[%key:common::config_flow::title::oauth2_pick_implementation%]"
6+
}
7+
},
8+
"abort": {
9+
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
10+
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
11+
"oauth_error": "[%key:common::config_flow::abort::oauth2_error%]",
12+
"missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]",
13+
"authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]",
14+
"no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]"
15+
},
16+
"create_entry": {
17+
"default": "[%key:common::config_flow::create_entry::authenticated%]"
18+
}
19+
}
20+
}

0 commit comments

Comments
 (0)