Add Huum integration (#106420)

* Add Huum integration

* Use DeviceInfo instead of name property for huum climate

* Simplify entry setup for huum climate entry

* Don’t take status as attribute for huum climate init

* Remove unused import

* Set unique id as entity id in huum init

* Remove unused import for huum climate

* Use entry ID as unique ID for device entity

* Remove extra newline in huum climate

* Upgrade pyhuum to 0.7.4

This version no longer users Pydantic

* Parameterize error huum tests

* Update all requirements after pyhuum upgrade

* Use Huum specific naming for ConfigFlow

* Use constants for username and password in huum config flow

* Use constants for temperature units

* Fix typing and pylint issues

* Update pyhuum to 0.7.5

* Use correct enums for data entry flow in Huum tests

* Remove test for non-thrown CannotConnect in huum flow tests

* Refactor failure config test to also test a successful flow after failure

* Fix ruff-format issues

* Move _status outside of __init__ and type it

* Type temperature argument for _turn_on in huum climate

* Use constants for auth in huum config flow test

* Refactor validate_into into a inline call in huum config flow

* Refactor current and target temperature to be able to return None values

* Remove unused huum exceptions

* Flip if-statment in async_step_user flow setup to simplify code

* Change current and target temperature to be more future proof

* Log exception instead of error

* Use custom pyhuum exceptions

* Add checks for duplicate entries

* Use min temp if no target temp has been fetched yet when heating huum

* Fix tests so that mock config entry also include username and password

* Fix ruff styling issues

I don’t know why it keeps doing this. I run `ruff` locally, and then it does not complain, but CI must be doing something else here.

* Remove unneded setting of unique id

* Update requirements

* Refactor temperature setting to support settings target temparature properly
This commit is contained in:
Frank Wickström 2024-01-25 13:55:55 +02:00 committed by GitHub
parent 909cdc2e5c
commit 6f81d21a35
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 428 additions and 0 deletions

View file

@ -550,6 +550,8 @@ omit =
homeassistant/components/hunterdouglas_powerview/shade_data.py
homeassistant/components/hunterdouglas_powerview/util.py
homeassistant/components/hvv_departures/__init__.py
homeassistant/components/huum/__init__.py
homeassistant/components/huum/climate.py
homeassistant/components/hvv_departures/binary_sensor.py
homeassistant/components/hvv_departures/sensor.py
homeassistant/components/ialarm/alarm_control_panel.py

View file

@ -579,6 +579,8 @@ build.json @home-assistant/supervisor
/tests/components/humidifier/ @home-assistant/core @Shulyaka
/homeassistant/components/hunterdouglas_powerview/ @bdraco @kingy444 @trullock
/tests/components/hunterdouglas_powerview/ @bdraco @kingy444 @trullock
/homeassistant/components/huum/ @frwickst
/tests/components/huum/ @frwickst
/homeassistant/components/hvv_departures/ @vigonotion
/tests/components/hvv_departures/ @vigonotion
/homeassistant/components/hydrawise/ @dknowles2 @ptcryan

View file

@ -0,0 +1,46 @@
"""The Huum integration."""
from __future__ import annotations
import logging
from huum.exceptions import Forbidden, NotAuthenticated
from huum.huum import Huum
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import DOMAIN, PLATFORMS
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Huum from a config entry."""
username = entry.data[CONF_USERNAME]
password = entry.data[CONF_PASSWORD]
huum = Huum(username, password, session=async_get_clientsession(hass))
try:
await huum.status()
except (Forbidden, NotAuthenticated) as err:
_LOGGER.error("Could not log in to Huum with given credentials")
raise ConfigEntryNotReady(
"Could not log in to Huum with given credentials"
) from err
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = huum
await hass.config_entries.async_forward_entry_setups(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

View file

@ -0,0 +1,128 @@
"""Support for Huum wifi-enabled sauna."""
from __future__ import annotations
import logging
from typing import Any
from huum.const import SaunaStatus
from huum.exceptions import SafetyException
from huum.huum import Huum
from huum.schemas import HuumStatusResponse
from homeassistant.components.climate import (
ClimateEntity,
ClimateEntityFeature,
HVACMode,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_TEMPERATURE, PRECISION_WHOLE, UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the Huum sauna with config flow."""
huum_handler = hass.data.setdefault(DOMAIN, {})[entry.entry_id]
async_add_entities([HuumDevice(huum_handler, entry.entry_id)], True)
class HuumDevice(ClimateEntity):
"""Representation of a heater."""
_attr_hvac_modes = [HVACMode.HEAT, HVACMode.OFF]
_attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE
_attr_target_temperature_step = PRECISION_WHOLE
_attr_temperature_unit = UnitOfTemperature.CELSIUS
_attr_max_temp = 110
_attr_min_temp = 40
_attr_has_entity_name = True
_attr_name = None
_target_temperature: int | None = None
_status: HuumStatusResponse | None = None
def __init__(self, huum_handler: Huum, unique_id: str) -> None:
"""Initialize the heater."""
self._attr_unique_id = unique_id
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, unique_id)},
name="Huum sauna",
manufacturer="Huum",
)
self._huum_handler = huum_handler
@property
def hvac_mode(self) -> HVACMode:
"""Return hvac operation ie. heat, cool mode."""
if self._status and self._status.status == SaunaStatus.ONLINE_HEATING:
return HVACMode.HEAT
return HVACMode.OFF
@property
def icon(self) -> str:
"""Return nice icon for heater."""
if self.hvac_mode == HVACMode.HEAT:
return "mdi:radiator"
return "mdi:radiator-off"
@property
def current_temperature(self) -> int | None:
"""Return the current temperature."""
if (status := self._status) is not None:
return status.temperature
return None
@property
def target_temperature(self) -> int:
"""Return the temperature we try to reach."""
return self._target_temperature or int(self.min_temp)
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
"""Set hvac mode."""
if hvac_mode == HVACMode.HEAT:
await self._turn_on(self.target_temperature)
elif hvac_mode == HVACMode.OFF:
await self._huum_handler.turn_off()
async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set new target temperature."""
temperature = kwargs.get(ATTR_TEMPERATURE)
if temperature is None:
return
self._target_temperature = temperature
if self.hvac_mode == HVACMode.HEAT:
await self._turn_on(temperature)
async def async_update(self) -> None:
"""Get the latest status data.
We get the latest status first from the status endpoints of the sauna.
If that data does not include the temperature, that means that the sauna
is off, we then call the off command which will in turn return the temperature.
This is a workaround for getting the temperature as the Huum API does not
return the target temperature of a sauna that is off, even if it can have
a target temperature at that time.
"""
self._status = await self._huum_handler.status_from_status_or_stop()
if self._target_temperature is None or self.hvac_mode == HVACMode.HEAT:
self._target_temperature = self._status.target_temperature
async def _turn_on(self, temperature: int) -> None:
try:
await self._huum_handler.turn_on(temperature)
except (ValueError, SafetyException) as err:
_LOGGER.error(str(err))
raise HomeAssistantError(f"Unable to turn on sauna: {err}") from err

View file

@ -0,0 +1,63 @@
"""Config flow for huum integration."""
from __future__ import annotations
import logging
from typing import Any
from huum.exceptions import Forbidden, NotAuthenticated
from huum.huum import Huum
import voluptuous as vol
from homeassistant import config_entries
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from homeassistant.data_entry_flow import FlowResult
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
STEP_USER_DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_USERNAME): str,
vol.Required(CONF_PASSWORD): str,
}
)
class HuumConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle a config flow for huum."""
VERSION = 1
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Handle the initial step."""
errors = {}
if user_input is not None:
try:
huum_handler = Huum(
user_input[CONF_USERNAME],
user_input[CONF_PASSWORD],
session=async_get_clientsession(self.hass),
)
await huum_handler.status()
except (Forbidden, NotAuthenticated):
# Most likely Forbidden as that is what is returned from `.status()` with bad creds
_LOGGER.error("Could not log in to Huum with given credentials")
errors["base"] = "invalid_auth"
except Exception: # pylint: disable=broad-except
_LOGGER.exception("Unknown error")
errors["base"] = "unknown"
else:
self._async_abort_entries_match(
{CONF_USERNAME: user_input[CONF_USERNAME]}
)
return self.async_create_entry(
title=user_input[CONF_USERNAME], data=user_input
)
return self.async_show_form(
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
)

View file

@ -0,0 +1,7 @@
"""Constants for the huum integration."""
from homeassistant.const import Platform
DOMAIN = "huum"
PLATFORMS = [Platform.CLIMATE]

View file

@ -0,0 +1,9 @@
{
"domain": "huum",
"name": "Huum",
"codeowners": ["@frwickst"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/huum",
"iot_class": "cloud_polling",
"requirements": ["huum==0.7.9"]
}

View file

@ -0,0 +1,22 @@
{
"config": {
"step": {
"user": {
"title": "Connect to the Huum",
"description": "Log in with the same username and password that is used in the Huum mobile app.",
"data": {
"username": "[%key:common::config_flow::data::username%]",
"password": "[%key:common::config_flow::data::password%]"
}
}
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
}
}
}

View file

@ -225,6 +225,7 @@ FLOWS = {
"hue",
"huisbaasje",
"hunterdouglas_powerview",
"huum",
"hvv_departures",
"hydrawise",
"hyperion",

View file

@ -2596,6 +2596,12 @@
"integration_type": "virtual",
"supported_by": "motion_blinds"
},
"huum": {
"name": "Huum",
"integration_type": "hub",
"config_flow": true,
"iot_class": "cloud_polling"
},
"hvv_departures": {
"name": "HVV Departures",
"integration_type": "hub",

View file

@ -1073,6 +1073,9 @@ httplib2==0.20.4
# homeassistant.components.huawei_lte
huawei-lte-api==1.7.3
# homeassistant.components.huum
huum==0.7.9
# homeassistant.components.hyperion
hyperion-py==0.7.5

View file

@ -863,6 +863,9 @@ httplib2==0.20.4
# homeassistant.components.huawei_lte
huawei-lte-api==1.7.3
# homeassistant.components.huum
huum==0.7.9
# homeassistant.components.hyperion
hyperion-py==0.7.5

View file

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

View file

@ -0,0 +1,135 @@
"""Test the huum config flow."""
from unittest.mock import patch
from huum.exceptions import Forbidden
import pytest
from homeassistant import config_entries
from homeassistant.components.huum.const import DOMAIN
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from tests.common import MockConfigEntry
TEST_USERNAME = "test-username"
TEST_PASSWORD = "test-password"
async def test_form(hass: HomeAssistant) -> None:
"""Test we get the form."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] == FlowResultType.FORM
assert result["errors"] == {}
with patch(
"homeassistant.components.huum.config_flow.Huum.status",
return_value=True,
), patch(
"homeassistant.components.huum.async_setup_entry",
return_value=True,
) as mock_setup_entry:
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_USERNAME: TEST_USERNAME,
CONF_PASSWORD: TEST_PASSWORD,
},
)
await hass.async_block_till_done()
assert result2["type"] == FlowResultType.CREATE_ENTRY
assert result2["title"] == TEST_USERNAME
assert result2["data"] == {
CONF_USERNAME: TEST_USERNAME,
CONF_PASSWORD: TEST_PASSWORD,
}
assert len(mock_setup_entry.mock_calls) == 1
async def test_signup_flow_already_set_up(hass: HomeAssistant) -> None:
"""Test that we handle already existing entities with same id."""
mock_config_entry = MockConfigEntry(
title="Huum Sauna",
domain=DOMAIN,
unique_id=TEST_USERNAME,
data={
CONF_USERNAME: TEST_USERNAME,
CONF_PASSWORD: TEST_PASSWORD,
},
)
mock_config_entry.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
with patch(
"homeassistant.components.huum.config_flow.Huum.status",
return_value=True,
), patch(
"homeassistant.components.huum.async_setup_entry",
return_value=True,
):
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_USERNAME: TEST_USERNAME,
CONF_PASSWORD: TEST_PASSWORD,
},
)
await hass.async_block_till_done()
assert result2["type"] == FlowResultType.ABORT
@pytest.mark.parametrize(
(
"raises",
"error_base",
),
[
(Exception, "unknown"),
(Forbidden, "invalid_auth"),
],
)
async def test_huum_errors(
hass: HomeAssistant, raises: Exception, error_base: str
) -> None:
"""Test we handle cannot connect error."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
with patch(
"homeassistant.components.huum.config_flow.Huum.status",
side_effect=raises,
):
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_USERNAME: TEST_USERNAME,
CONF_PASSWORD: TEST_PASSWORD,
},
)
assert result2["type"] == FlowResultType.FORM
assert result2["errors"] == {"base": error_base}
with patch(
"homeassistant.components.huum.config_flow.Huum.status",
return_value=True,
), patch(
"homeassistant.components.huum.async_setup_entry",
return_value=True,
):
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_USERNAME: TEST_USERNAME,
CONF_PASSWORD: TEST_PASSWORD,
},
)
assert result2["type"] == FlowResultType.CREATE_ENTRY