Skip to content

Commit

Permalink
Add weheat core integration (#123057)
Browse files Browse the repository at this point in the history
* 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 <[email protected]>

* 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 <[email protected]>
Co-authored-by: Joost Lekkerkerker <[email protected]>
  • Loading branch information
3 people authored Sep 6, 2024
1 parent ff20131 commit dfcfe78
Show file tree
Hide file tree
Showing 21 changed files with 632 additions and 0 deletions.
2 changes: 2 additions & 0 deletions CODEOWNERS
Validating CODEOWNERS rules …
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
49 changes: 49 additions & 0 deletions homeassistant/components/weheat/__init__.py
Original file line number Diff line number Diff line change
@@ -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)
29 changes: 29 additions & 0 deletions homeassistant/components/weheat/api.py
Original file line number Diff line number Diff line change
@@ -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]
11 changes: 11 additions & 0 deletions homeassistant/components/weheat/application_credentials.py
Original file line number Diff line number Diff line change
@@ -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)
40 changes: 40 additions & 0 deletions homeassistant/components/weheat/config_flow.py
Original file line number Diff line number Diff line change
@@ -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)
25 changes: 25 additions & 0 deletions homeassistant/components/weheat/const.py
Original file line number Diff line number Diff line change
@@ -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
84 changes: 84 additions & 0 deletions homeassistant/components/weheat/coordinator.py
Original file line number Diff line number Diff line change
@@ -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)
27 changes: 27 additions & 0 deletions homeassistant/components/weheat/entity.py
Original file line number Diff line number Diff line change
@@ -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,
)
15 changes: 15 additions & 0 deletions homeassistant/components/weheat/icons.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"entity": {
"sensor": {
"power_output": {
"default": "mdi:heat-wave"
},
"power_input": {
"default": "mdi:lightning-bolt"
},
"cop": {
"default": "mdi:speedometer"
}
}
}
}
10 changes: 10 additions & 0 deletions homeassistant/components/weheat/manifest.json
Original file line number Diff line number Diff line change
@@ -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"]
}
Loading

0 comments on commit dfcfe78

Please sign in to comment.