Add weheat core integration (#123057)

* Add empty weheat integration

* Add first sensor to weheat integration

* Add weheat entity to provide device information

* Fixed automatic selection for a single heat pump

* Replaced integration specific package and removed status sensor

* Update const.py

* Add reauthentication support for weheat integration

* Add test cases for the config flow of the weheat integration

* Changed API and OATH url to weheat production environment

* Add empty weheat integration

* Add first sensor to weheat integration

* Add weheat entity to provide device information

* Fixed automatic selection for a single heat pump

* Replaced integration specific package and removed status sensor

* Add reauthentication support for weheat integration

* Update const.py

* Add test cases for the config flow of the weheat integration

* Changed API and OATH url to weheat production environment

* Resolved merge conflict after adding weheat package

* Apply suggestions from code review

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>

* Added translation keys, more type info and version bump the weheat package

* Adding native property value for weheat sensor

* Removed reauth, added weheat sensor description and changed discovery of heat pumps

* Added unique ID of user to entity

* Replaced string by constants, added test case for duplicate unique id

* Removed duplicate constant

* Added offline scope

* Removed re-auth related code

* Simplified oath implementation

* Cleanup tests for weheat integration

* Added oath scope to tests

---------

Co-authored-by: kjell-van-straaten <kjell.van.straaten@wefabricate.com>
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
This commit is contained in:
jesperraemaekers 2024-09-06 11:58:01 +02:00 committed by GitHub
parent ff20131af1
commit dfcfe78732
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 632 additions and 0 deletions

View File

@ -1640,6 +1640,8 @@ build.json @home-assistant/supervisor
/tests/components/webostv/ @thecode
/homeassistant/components/websocket_api/ @home-assistant/core
/tests/components/websocket_api/ @home-assistant/core
/homeassistant/components/weheat/ @jesperraemaekers
/tests/components/weheat/ @jesperraemaekers
/homeassistant/components/wemo/ @esev
/tests/components/wemo/ @esev
/homeassistant/components/whirlpool/ @abmantis @mkmer

View File

@ -0,0 +1,49 @@
"""The Weheat integration."""
from __future__ import annotations
from weheat.abstractions.discovery import HeatPumpDiscovery
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_ACCESS_TOKEN, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers.config_entry_oauth2_flow import (
OAuth2Session,
async_get_config_entry_implementation,
)
from .const import API_URL, LOGGER
from .coordinator import WeheatDataUpdateCoordinator
PLATFORMS: list[Platform] = [Platform.SENSOR]
type WeheatConfigEntry = ConfigEntry[list[WeheatDataUpdateCoordinator]]
async def async_setup_entry(hass: HomeAssistant, entry: WeheatConfigEntry) -> bool:
"""Set up Weheat from a config entry."""
implementation = await async_get_config_entry_implementation(hass, entry)
session = OAuth2Session(hass, entry, implementation)
token = session.token[CONF_ACCESS_TOKEN]
entry.runtime_data = []
# fetch a list of the heat pumps the entry can access
for pump_info in await HeatPumpDiscovery.discover_active(API_URL, token):
LOGGER.debug("Adding %s", pump_info)
# for each pump, add a coordinator
new_coordinator = WeheatDataUpdateCoordinator(hass, session, pump_info)
await new_coordinator.async_config_entry_first_refresh()
entry.runtime_data.append(new_coordinator)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: WeheatConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)

View File

@ -0,0 +1,29 @@
"""API for Weheat bound to Home Assistant OAuth."""
from aiohttp import ClientSession
from weheat.abstractions import AbstractAuth
from homeassistant.const import CONF_ACCESS_TOKEN
from homeassistant.helpers.config_entry_oauth2_flow import OAuth2Session
from .const import API_URL
class AsyncConfigEntryAuth(AbstractAuth):
"""Provide Weheat authentication tied to an OAuth2 based config entry."""
def __init__(
self,
websession: ClientSession,
oauth_session: OAuth2Session,
) -> None:
"""Initialize Weheat auth."""
super().__init__(websession, host=API_URL)
self._oauth_session = oauth_session
async def async_get_access_token(self) -> str:
"""Return a valid access token."""
if not self._oauth_session.valid_token:
await self._oauth_session.async_ensure_token_valid()
return self._oauth_session.token[CONF_ACCESS_TOKEN]

View File

@ -0,0 +1,11 @@
"""application_credentials platform the Weheat 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 authorization server."""
return AuthorizationServer(authorize_url=OAUTH2_AUTHORIZE, token_url=OAUTH2_TOKEN)

View File

@ -0,0 +1,40 @@
"""Config flow for Weheat."""
import logging
from weheat.abstractions.user import get_user_id_from_token
from homeassistant.config_entries import ConfigFlowResult
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN
from homeassistant.helpers.config_entry_oauth2_flow import AbstractOAuth2FlowHandler
from .const import API_URL, DOMAIN, ENTRY_TITLE, OAUTH2_SCOPES
class OAuth2FlowHandler(AbstractOAuth2FlowHandler, domain=DOMAIN):
"""Config flow to handle Weheat OAuth2 authentication."""
DOMAIN = DOMAIN
@property
def logger(self) -> logging.Logger:
"""Return logger."""
return logging.getLogger(__name__)
@property
def extra_authorize_data(self) -> dict[str, str]:
"""Extra data that needs to be appended to the authorize url."""
return {
"scope": " ".join(OAUTH2_SCOPES),
}
async def async_oauth_create_entry(self, data: dict) -> ConfigFlowResult:
"""Override the create entry method to change to the step to find the heat pumps."""
# get the user id and use that as unique id for this entry
user_id = await get_user_id_from_token(
API_URL, data[CONF_TOKEN][CONF_ACCESS_TOKEN]
)
await self.async_set_unique_id(user_id)
self._abort_if_unique_id_configured()
return self.async_create_entry(title=ENTRY_TITLE, data=data)

View File

@ -0,0 +1,25 @@
"""Constants for the Weheat integration."""
from logging import Logger, getLogger
DOMAIN = "weheat"
MANUFACTURER = "Weheat"
ENTRY_TITLE = "Weheat cloud"
ERROR_DESCRIPTION = "error_description"
OAUTH2_AUTHORIZE = (
"https://auth.weheat.nl/auth/realms/Weheat/protocol/openid-connect/auth/"
)
OAUTH2_TOKEN = (
"https://auth.weheat.nl/auth/realms/Weheat/protocol/openid-connect/token/"
)
API_URL = "https://api.weheat.nl"
OAUTH2_SCOPES = ["openid", "offline_access"]
UPDATE_INTERVAL = 30
LOGGER: Logger = getLogger(__package__)
DISPLAY_PRECISION_WATTS = 0
DISPLAY_PRECISION_COP = 1

View File

@ -0,0 +1,84 @@
"""Define a custom coordinator for the Weheat heatpump integration."""
from datetime import timedelta
from weheat.abstractions.discovery import HeatPumpDiscovery
from weheat.abstractions.heat_pump import HeatPump
from weheat.exceptions import (
ApiException,
BadRequestException,
ForbiddenException,
NotFoundException,
ServiceException,
UnauthorizedException,
)
from homeassistant.const import CONF_ACCESS_TOKEN
from homeassistant.core import HomeAssistant
from homeassistant.helpers.config_entry_oauth2_flow import OAuth2Session
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import API_URL, DOMAIN, LOGGER, UPDATE_INTERVAL
EXCEPTIONS = (
ServiceException,
NotFoundException,
ForbiddenException,
UnauthorizedException,
BadRequestException,
ApiException,
)
class WeheatDataUpdateCoordinator(DataUpdateCoordinator[HeatPump]):
"""A custom coordinator for the Weheat heatpump integration."""
def __init__(
self,
hass: HomeAssistant,
session: OAuth2Session,
heat_pump: HeatPumpDiscovery.HeatPumpInfo,
) -> None:
"""Initialize the data coordinator."""
super().__init__(
hass,
logger=LOGGER,
name=DOMAIN,
update_interval=timedelta(seconds=UPDATE_INTERVAL),
)
self._heat_pump_info = heat_pump
self._heat_pump_data = HeatPump(API_URL, self._heat_pump_info.uuid)
self.session = session
@property
def heatpump_id(self) -> str:
"""Return the heat pump id."""
return self._heat_pump_info.uuid
@property
def readable_name(self) -> str | None:
"""Return the readable name of the heat pump."""
if self._heat_pump_info.name:
return self._heat_pump_info.name
return self._heat_pump_info.model
@property
def model(self) -> str:
"""Return the model of the heat pump."""
return self._heat_pump_info.model
def fetch_data(self) -> HeatPump:
"""Get the data from the API."""
try:
self._heat_pump_data.get_status(self.session.token[CONF_ACCESS_TOKEN])
except EXCEPTIONS as error:
raise UpdateFailed(error) from error
return self._heat_pump_data
async def _async_update_data(self) -> HeatPump:
"""Fetch data from the API."""
await self.session.async_ensure_token_valid()
return await self.hass.async_add_executor_job(self.fetch_data)

View File

@ -0,0 +1,27 @@
"""Base entity for Weheat."""
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN, MANUFACTURER
from .coordinator import WeheatDataUpdateCoordinator
class WeheatEntity(CoordinatorEntity[WeheatDataUpdateCoordinator]):
"""Defines a base Weheat entity."""
_attr_has_entity_name = True
def __init__(
self,
coordinator: WeheatDataUpdateCoordinator,
) -> None:
"""Initialize the Weheat entity."""
super().__init__(coordinator)
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, coordinator.heatpump_id)},
name=coordinator.readable_name,
manufacturer=MANUFACTURER,
model=coordinator.model,
)

View File

@ -0,0 +1,15 @@
{
"entity": {
"sensor": {
"power_output": {
"default": "mdi:heat-wave"
},
"power_input": {
"default": "mdi:lightning-bolt"
},
"cop": {
"default": "mdi:speedometer"
}
}
}
}

View File

@ -0,0 +1,10 @@
{
"domain": "weheat",
"name": "Weheat",
"codeowners": ["@jesperraemaekers"],
"config_flow": true,
"dependencies": ["application_credentials"],
"documentation": "https://www.home-assistant.io/integrations/weheat",
"iot_class": "cloud_polling",
"requirements": ["weheat==2024.09.05"]
}

View File

@ -0,0 +1,95 @@
"""Platform for sensor integration."""
from collections.abc import Callable
from dataclasses import dataclass
from weheat.abstractions.heat_pump import HeatPump
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.const import UnitOfPower
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import StateType
from . import WeheatConfigEntry
from .const import DISPLAY_PRECISION_COP, DISPLAY_PRECISION_WATTS
from .coordinator import WeheatDataUpdateCoordinator
from .entity import WeheatEntity
@dataclass(frozen=True, kw_only=True)
class WeHeatSensorEntityDescription(SensorEntityDescription):
"""Describes Weheat sensor entity."""
value_fn: Callable[[HeatPump], StateType]
SENSORS = [
WeHeatSensorEntityDescription(
translation_key="power_output",
key="power_output",
native_unit_of_measurement=UnitOfPower.WATT,
device_class=SensorDeviceClass.POWER,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=DISPLAY_PRECISION_WATTS,
value_fn=lambda status: status.power_output,
),
WeHeatSensorEntityDescription(
translation_key="power_input",
key="power_input",
native_unit_of_measurement=UnitOfPower.WATT,
device_class=SensorDeviceClass.POWER,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=DISPLAY_PRECISION_WATTS,
value_fn=lambda status: status.power_input,
),
WeHeatSensorEntityDescription(
translation_key="cop",
key="cop",
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=DISPLAY_PRECISION_COP,
value_fn=lambda status: status.cop,
),
]
async def async_setup_entry(
hass: HomeAssistant,
entry: WeheatConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the sensors for weheat heat pump."""
async_add_entities(
WeheatHeatPumpSensor(coordinator, entity_description)
for entity_description in SENSORS
for coordinator in entry.runtime_data
)
class WeheatHeatPumpSensor(WeheatEntity, SensorEntity):
"""Defines a Weheat heat pump sensor."""
coordinator: WeheatDataUpdateCoordinator
entity_description: WeHeatSensorEntityDescription
def __init__(
self,
coordinator: WeheatDataUpdateCoordinator,
entity_description: WeHeatSensorEntityDescription,
) -> None:
"""Pass coordinator to CoordinatorEntity."""
super().__init__(coordinator)
self.entity_description = entity_description
self._attr_unique_id = f"{coordinator.heatpump_id}_{entity_description.key}"
@property
def native_value(self) -> StateType:
"""Return the state of the sensor."""
return self.entity_description.value_fn(self.coordinator.data)

View File

@ -0,0 +1,46 @@
{
"config": {
"step": {
"pick_implementation": {
"title": "[%key:common::config_flow::title::oauth2_pick_implementation%]"
},
"find_devices": {
"title": "Select your heat pump"
},
"reauth_confirm": {
"title": "Re-authenticate with WeHeat",
"description": "You need to re-authenticate with WeHeat to continue"
}
},
"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%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"no_devices_found": "Could not find any heat pumps on this account"
},
"create_entry": {
"default": "[%key:common::config_flow::create_entry::authenticated%]"
}
},
"entity": {
"sensor": {
"power_output": {
"name": "Output power"
},
"power_input": {
"name": "Input power"
},
"cop": {
"name": "COP"
}
}
}
}

View File

@ -28,6 +28,7 @@ APPLICATION_CREDENTIALS = [
"spotify",
"tesla_fleet",
"twitch",
"weheat",
"withings",
"xbox",
"yale",

View File

@ -656,6 +656,7 @@ FLOWS = {
"weatherkit",
"webmin",
"webostv",
"weheat",
"wemo",
"whirlpool",
"whois",

View File

@ -6854,6 +6854,12 @@
"config_flow": true,
"iot_class": "local_polling"
},
"weheat": {
"name": "Weheat",
"integration_type": "hub",
"config_flow": true,
"iot_class": "cloud_polling"
},
"wemo": {
"name": "Belkin WeMo",
"integration_type": "hub",

View File

@ -2947,6 +2947,9 @@ weatherflow4py==0.2.23
# homeassistant.components.webmin
webmin-xmlrpc==0.0.2
# homeassistant.components.weheat
weheat==2024.09.05
# homeassistant.components.whirlpool
whirlpool-sixth-sense==0.18.8

View File

@ -2333,6 +2333,9 @@ weatherflow4py==0.2.23
# homeassistant.components.webmin
webmin-xmlrpc==0.0.2
# homeassistant.components.weheat
weheat==2024.09.05
# homeassistant.components.whirlpool
whirlpool-sixth-sense==0.18.8

View File

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

View File

@ -0,0 +1,36 @@
"""Fixtures for Weheat tests."""
from unittest.mock import patch
import pytest
from homeassistant.components.application_credentials import (
DOMAIN as APPLICATION_CREDENTIALS,
ClientCredential,
async_import_client_credential,
)
from homeassistant.components.weheat.const import DOMAIN
from homeassistant.core import HomeAssistant
from homeassistant.setup import async_setup_component
from .const import CLIENT_ID, CLIENT_SECRET
@pytest.fixture(autouse=True)
async def setup_credentials(hass: HomeAssistant) -> None:
"""Fixture to setup credentials."""
assert await async_setup_component(hass, APPLICATION_CREDENTIALS, {})
await async_import_client_credential(
hass,
DOMAIN,
ClientCredential(CLIENT_ID, CLIENT_SECRET),
)
@pytest.fixture
def mock_setup_entry():
"""Mock a successful setup."""
with patch(
"homeassistant.components.weheat.async_setup_entry", return_value=True
) as mock_setup:
yield mock_setup

View File

@ -0,0 +1,11 @@
"""Constants for weheat tests."""
CLIENT_ID = "1234"
CLIENT_SECRET = "5678"
USER_UUID_1 = "0000-1111-2222-3333"
CONF_REFRESH_TOKEN = "refresh_token"
CONF_AUTH_IMPLEMENTATION = "auth_implementation"
MOCK_REFRESH_TOKEN = "mock_refresh_token"
MOCK_ACCESS_TOKEN = "mock_access_token"

View File

@ -0,0 +1,137 @@
"""Test the Weheat config flow."""
from unittest.mock import patch
import pytest
from homeassistant.components.weheat.const import (
DOMAIN,
ENTRY_TITLE,
OAUTH2_AUTHORIZE,
OAUTH2_TOKEN,
)
from homeassistant.config_entries import SOURCE_USER, ConfigFlowResult
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_SOURCE, CONF_TOKEN
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from homeassistant.helpers import config_entry_oauth2_flow
from .const import (
CLIENT_ID,
CONF_AUTH_IMPLEMENTATION,
CONF_REFRESH_TOKEN,
MOCK_ACCESS_TOKEN,
MOCK_REFRESH_TOKEN,
USER_UUID_1,
)
from tests.common import MockConfigEntry
from tests.test_util.aiohttp import AiohttpClientMocker
from tests.typing import ClientSessionGenerator
@pytest.mark.usefixtures("current_request_with_host")
async def test_full_flow(
hass: HomeAssistant,
hass_client_no_auth: ClientSessionGenerator,
aioclient_mock: AiohttpClientMocker,
mock_setup_entry,
) -> None:
"""Check full of adding a single heat pump."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={CONF_SOURCE: SOURCE_USER}
)
await handle_oauth(hass, hass_client_no_auth, aioclient_mock, result)
with (
patch(
"homeassistant.components.weheat.config_flow.get_user_id_from_token",
return_value=USER_UUID_1,
) as mock_weheat,
):
result = await hass.config_entries.flow.async_configure(result["flow_id"])
assert len(hass.config_entries.async_entries(DOMAIN)) == 1
assert len(mock_setup_entry.mock_calls) == 1
assert len(mock_weheat.mock_calls) == 1
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["result"].unique_id == USER_UUID_1
assert result["result"].title == ENTRY_TITLE
assert result["data"][CONF_TOKEN][CONF_REFRESH_TOKEN] == MOCK_REFRESH_TOKEN
assert result["data"][CONF_TOKEN][CONF_ACCESS_TOKEN] == MOCK_ACCESS_TOKEN
assert result["data"][CONF_AUTH_IMPLEMENTATION] == DOMAIN
@pytest.mark.usefixtures("current_request_with_host")
async def test_duplicate_unique_id(
hass: HomeAssistant,
hass_client_no_auth: ClientSessionGenerator,
aioclient_mock: AiohttpClientMocker,
mock_setup_entry,
) -> None:
"""Check that the config flow is aborted when an entry with the same ID exists."""
first_entry = MockConfigEntry(
domain=DOMAIN,
data={},
unique_id=USER_UUID_1,
)
first_entry.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN, context={CONF_SOURCE: SOURCE_USER}
)
await handle_oauth(hass, hass_client_no_auth, aioclient_mock, result)
with (
patch(
"homeassistant.components.weheat.config_flow.get_user_id_from_token",
return_value=USER_UUID_1,
),
):
result = await hass.config_entries.flow.async_configure(result["flow_id"])
# only care that the config flow is aborted
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"
async def handle_oauth(
hass: HomeAssistant,
hass_client_no_auth: ClientSessionGenerator,
aioclient_mock: AiohttpClientMocker,
result: ConfigFlowResult,
) -> None:
"""Handle the Oauth2 part of the flow."""
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"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}"
"&redirect_uri=https://example.com/auth/external/callback"
f"&state={state}"
"&scope=openid+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(
OAUTH2_TOKEN,
json={
"refresh_token": MOCK_REFRESH_TOKEN,
"access_token": MOCK_ACCESS_TOKEN,
"type": "Bearer",
"expires_in": 60,
},
)