From 6f81d21a35c9180b020bb806d6fcefb0cf29fbe6 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Frank=20Wickstr=C3=B6m?= <frank@zefort.com>
Date: Thu, 25 Jan 2024 13:55:55 +0200
Subject: [PATCH] Add Huum integration (#106420)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

* 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
---
 .coveragerc                                  |   2 +
 CODEOWNERS                                   |   2 +
 homeassistant/components/huum/__init__.py    |  46 +++++++
 homeassistant/components/huum/climate.py     | 128 ++++++++++++++++++
 homeassistant/components/huum/config_flow.py |  63 +++++++++
 homeassistant/components/huum/const.py       |   7 +
 homeassistant/components/huum/manifest.json  |   9 ++
 homeassistant/components/huum/strings.json   |  22 +++
 homeassistant/generated/config_flows.py      |   1 +
 homeassistant/generated/integrations.json    |   6 +
 requirements_all.txt                         |   3 +
 requirements_test_all.txt                    |   3 +
 tests/components/huum/__init__.py            |   1 +
 tests/components/huum/test_config_flow.py    | 135 +++++++++++++++++++
 14 files changed, 428 insertions(+)
 create mode 100644 homeassistant/components/huum/__init__.py
 create mode 100644 homeassistant/components/huum/climate.py
 create mode 100644 homeassistant/components/huum/config_flow.py
 create mode 100644 homeassistant/components/huum/const.py
 create mode 100644 homeassistant/components/huum/manifest.json
 create mode 100644 homeassistant/components/huum/strings.json
 create mode 100644 tests/components/huum/__init__.py
 create mode 100644 tests/components/huum/test_config_flow.py

diff --git a/.coveragerc b/.coveragerc
index 9dc665d9c3c..d026723d500 100644
--- a/.coveragerc
+++ b/.coveragerc
@@ -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
diff --git a/CODEOWNERS b/CODEOWNERS
index a423bbf8f76..9d1d2339d23 100644
--- a/CODEOWNERS
+++ b/CODEOWNERS
@@ -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
diff --git a/homeassistant/components/huum/__init__.py b/homeassistant/components/huum/__init__.py
new file mode 100644
index 00000000000..a5daf471a2d
--- /dev/null
+++ b/homeassistant/components/huum/__init__.py
@@ -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
diff --git a/homeassistant/components/huum/climate.py b/homeassistant/components/huum/climate.py
new file mode 100644
index 00000000000..dcf025082cc
--- /dev/null
+++ b/homeassistant/components/huum/climate.py
@@ -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
diff --git a/homeassistant/components/huum/config_flow.py b/homeassistant/components/huum/config_flow.py
new file mode 100644
index 00000000000..31f4c9a137c
--- /dev/null
+++ b/homeassistant/components/huum/config_flow.py
@@ -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
+        )
diff --git a/homeassistant/components/huum/const.py b/homeassistant/components/huum/const.py
new file mode 100644
index 00000000000..69dea45b218
--- /dev/null
+++ b/homeassistant/components/huum/const.py
@@ -0,0 +1,7 @@
+"""Constants for the huum integration."""
+
+from homeassistant.const import Platform
+
+DOMAIN = "huum"
+
+PLATFORMS = [Platform.CLIMATE]
diff --git a/homeassistant/components/huum/manifest.json b/homeassistant/components/huum/manifest.json
new file mode 100644
index 00000000000..46256d15347
--- /dev/null
+++ b/homeassistant/components/huum/manifest.json
@@ -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"]
+}
diff --git a/homeassistant/components/huum/strings.json b/homeassistant/components/huum/strings.json
new file mode 100644
index 00000000000..68ab1adde6f
--- /dev/null
+++ b/homeassistant/components/huum/strings.json
@@ -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%]"
+    }
+  }
+}
diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py
index 3f26b6f907b..d63bdc23b12 100644
--- a/homeassistant/generated/config_flows.py
+++ b/homeassistant/generated/config_flows.py
@@ -225,6 +225,7 @@ FLOWS = {
         "hue",
         "huisbaasje",
         "hunterdouglas_powerview",
+        "huum",
         "hvv_departures",
         "hydrawise",
         "hyperion",
diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json
index f188201f847..0e9b46ea152 100644
--- a/homeassistant/generated/integrations.json
+++ b/homeassistant/generated/integrations.json
@@ -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",
diff --git a/requirements_all.txt b/requirements_all.txt
index f70bd86f29c..bdc51d6c204 100644
--- a/requirements_all.txt
+++ b/requirements_all.txt
@@ -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
 
diff --git a/requirements_test_all.txt b/requirements_test_all.txt
index 479d84bf57a..cb817549232 100644
--- a/requirements_test_all.txt
+++ b/requirements_test_all.txt
@@ -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
 
diff --git a/tests/components/huum/__init__.py b/tests/components/huum/__init__.py
new file mode 100644
index 00000000000..443cbd52c36
--- /dev/null
+++ b/tests/components/huum/__init__.py
@@ -0,0 +1 @@
+"""Tests for the huum integration."""
diff --git a/tests/components/huum/test_config_flow.py b/tests/components/huum/test_config_flow.py
new file mode 100644
index 00000000000..7163521b446
--- /dev/null
+++ b/tests/components/huum/test_config_flow.py
@@ -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
-- 
GitLab