mirror of
https://github.com/home-assistant/core.git
synced 2025-04-24 09:17:53 +00:00
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:
parent
ff20131af1
commit
dfcfe78732
@ -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
|
||||
|
49
homeassistant/components/weheat/__init__.py
Normal file
49
homeassistant/components/weheat/__init__.py
Normal 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)
|
29
homeassistant/components/weheat/api.py
Normal file
29
homeassistant/components/weheat/api.py
Normal 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]
|
11
homeassistant/components/weheat/application_credentials.py
Normal file
11
homeassistant/components/weheat/application_credentials.py
Normal 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)
|
40
homeassistant/components/weheat/config_flow.py
Normal file
40
homeassistant/components/weheat/config_flow.py
Normal 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)
|
25
homeassistant/components/weheat/const.py
Normal file
25
homeassistant/components/weheat/const.py
Normal 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
|
84
homeassistant/components/weheat/coordinator.py
Normal file
84
homeassistant/components/weheat/coordinator.py
Normal 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)
|
27
homeassistant/components/weheat/entity.py
Normal file
27
homeassistant/components/weheat/entity.py
Normal 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,
|
||||
)
|
15
homeassistant/components/weheat/icons.json
Normal file
15
homeassistant/components/weheat/icons.json
Normal file
@ -0,0 +1,15 @@
|
||||
{
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"power_output": {
|
||||
"default": "mdi:heat-wave"
|
||||
},
|
||||
"power_input": {
|
||||
"default": "mdi:lightning-bolt"
|
||||
},
|
||||
"cop": {
|
||||
"default": "mdi:speedometer"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
10
homeassistant/components/weheat/manifest.json
Normal file
10
homeassistant/components/weheat/manifest.json
Normal 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"]
|
||||
}
|
95
homeassistant/components/weheat/sensor.py
Normal file
95
homeassistant/components/weheat/sensor.py
Normal 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)
|
46
homeassistant/components/weheat/strings.json
Normal file
46
homeassistant/components/weheat/strings.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -28,6 +28,7 @@ APPLICATION_CREDENTIALS = [
|
||||
"spotify",
|
||||
"tesla_fleet",
|
||||
"twitch",
|
||||
"weheat",
|
||||
"withings",
|
||||
"xbox",
|
||||
"yale",
|
||||
|
@ -656,6 +656,7 @@ FLOWS = {
|
||||
"weatherkit",
|
||||
"webmin",
|
||||
"webostv",
|
||||
"weheat",
|
||||
"wemo",
|
||||
"whirlpool",
|
||||
"whois",
|
||||
|
@ -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",
|
||||
|
@ -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
|
||||
|
||||
|
@ -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
|
||||
|
||||
|
1
tests/components/weheat/__init__.py
Normal file
1
tests/components/weheat/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""Tests for the Weheat integration."""
|
36
tests/components/weheat/conftest.py
Normal file
36
tests/components/weheat/conftest.py
Normal 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
|
11
tests/components/weheat/const.py
Normal file
11
tests/components/weheat/const.py
Normal 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"
|
137
tests/components/weheat/test_config_flow.py
Normal file
137
tests/components/weheat/test_config_flow.py
Normal 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,
|
||||
},
|
||||
)
|
Loading…
x
Reference in New Issue
Block a user