From f3cccf0a2bd6a322561de728a1ceadd9e93ebce3 Mon Sep 17 00:00:00 2001 From: Alex Tsernoh Date: Sat, 11 Nov 2023 12:19:41 +0200 Subject: [PATCH] Add Komfovent (#95722) * komfovent integration V1 * add dependency * integrate komfovent api * fix errors found in testing * tests for form handling * update deps * update coverage rc * add correct naming * minor feedback * pre-commit fixes * feedback fixes part 1 of 2 * feedback fixes part 2 of 2 * add hvac mode support * fix tests * address feedback * fix code coverage + PR feedback * PR feedback * use device name --------- Co-authored-by: Joost Lekkerkerker --- .coveragerc | 2 + CODEOWNERS | 2 + .../components/komfovent/__init__.py | 34 ++++ homeassistant/components/komfovent/climate.py | 91 +++++++++ .../components/komfovent/config_flow.py | 74 +++++++ homeassistant/components/komfovent/const.py | 3 + .../components/komfovent/manifest.json | 9 + .../components/komfovent/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/komfovent/__init__.py | 1 + tests/components/komfovent/conftest.py | 14 ++ .../components/komfovent/test_config_flow.py | 189 ++++++++++++++++++ 15 files changed, 454 insertions(+) create mode 100644 homeassistant/components/komfovent/__init__.py create mode 100644 homeassistant/components/komfovent/climate.py create mode 100644 homeassistant/components/komfovent/config_flow.py create mode 100644 homeassistant/components/komfovent/const.py create mode 100644 homeassistant/components/komfovent/manifest.json create mode 100644 homeassistant/components/komfovent/strings.json create mode 100644 tests/components/komfovent/__init__.py create mode 100644 tests/components/komfovent/conftest.py create mode 100644 tests/components/komfovent/test_config_flow.py diff --git a/.coveragerc b/.coveragerc index 7ea4a1f5501..ebec3974cbb 100644 --- a/.coveragerc +++ b/.coveragerc @@ -639,6 +639,8 @@ omit = homeassistant/components/kodi/browse_media.py homeassistant/components/kodi/media_player.py homeassistant/components/kodi/notify.py + homeassistant/components/komfovent/__init__.py + homeassistant/components/komfovent/climate.py homeassistant/components/konnected/__init__.py homeassistant/components/konnected/panel.py homeassistant/components/konnected/switch.py diff --git a/CODEOWNERS b/CODEOWNERS index 6fd15415ff8..f6737c2e044 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -664,6 +664,8 @@ build.json @home-assistant/supervisor /tests/components/knx/ @Julius2342 @farmio @marvin-w /homeassistant/components/kodi/ @OnFreund /tests/components/kodi/ @OnFreund +/homeassistant/components/komfovent/ @ProstoSanja +/tests/components/komfovent/ @ProstoSanja /homeassistant/components/konnected/ @heythisisnate /tests/components/konnected/ @heythisisnate /homeassistant/components/kostal_plenticore/ @stegm diff --git a/homeassistant/components/komfovent/__init__.py b/homeassistant/components/komfovent/__init__.py new file mode 100644 index 00000000000..0366a429b21 --- /dev/null +++ b/homeassistant/components/komfovent/__init__.py @@ -0,0 +1,34 @@ +"""The Komfovent integration.""" +from __future__ import annotations + +import komfovent_api + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady + +from .const import DOMAIN + +PLATFORMS: list[Platform] = [Platform.CLIMATE] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Komfovent from a config entry.""" + host = entry.data[CONF_HOST] + username = entry.data[CONF_USERNAME] + password = entry.data[CONF_PASSWORD] + _, credentials = komfovent_api.get_credentials(host, username, password) + result, settings = await komfovent_api.get_settings(credentials) + if result != komfovent_api.KomfoventConnectionResult.SUCCESS: + raise ConfigEntryNotReady(f"Unable to connect to {host}: {result}") + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = (credentials, settings) + + 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.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/komfovent/climate.py b/homeassistant/components/komfovent/climate.py new file mode 100644 index 00000000000..2e51fddf4f2 --- /dev/null +++ b/homeassistant/components/komfovent/climate.py @@ -0,0 +1,91 @@ +"""Ventilation Units from Komfovent integration.""" +from __future__ import annotations + +import komfovent_api + +from homeassistant.components.climate import ( + ClimateEntity, + ClimateEntityFeature, + HVACMode, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import UnitOfTemperature +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN + +HASS_TO_KOMFOVENT_MODES = { + HVACMode.COOL: komfovent_api.KomfoventModes.COOL, + HVACMode.HEAT_COOL: komfovent_api.KomfoventModes.HEAT_COOL, + HVACMode.OFF: komfovent_api.KomfoventModes.OFF, + HVACMode.AUTO: komfovent_api.KomfoventModes.AUTO, +} +KOMFOVENT_TO_HASS_MODES = {v: k for k, v in HASS_TO_KOMFOVENT_MODES.items()} + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Komfovent unit control.""" + credentials, settings = hass.data[DOMAIN][entry.entry_id] + async_add_entities([KomfoventDevice(credentials, settings)], True) + + +class KomfoventDevice(ClimateEntity): + """Representation of a ventilation unit.""" + + _attr_hvac_modes = list(HASS_TO_KOMFOVENT_MODES.keys()) + _attr_preset_modes = [mode.name for mode in komfovent_api.KomfoventPresets] + _attr_supported_features = ClimateEntityFeature.PRESET_MODE + _attr_temperature_unit = UnitOfTemperature.CELSIUS + _attr_has_entity_name = True + _attr_name = None + + def __init__( + self, + credentials: komfovent_api.KomfoventCredentials, + settings: komfovent_api.KomfoventSettings, + ) -> None: + """Initialize the ventilation unit.""" + self._komfovent_credentials = credentials + self._komfovent_settings = settings + + self._attr_unique_id = settings.serial_number + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, settings.serial_number)}, + model=settings.model, + name=settings.name, + serial_number=settings.serial_number, + sw_version=settings.version, + manufacturer="Komfovent", + ) + + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Set new target preset mode.""" + await komfovent_api.set_preset( + self._komfovent_credentials, + komfovent_api.KomfoventPresets[preset_mode], + ) + + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: + """Set new target hvac mode.""" + await komfovent_api.set_mode( + self._komfovent_credentials, HASS_TO_KOMFOVENT_MODES[hvac_mode] + ) + + async def async_update(self) -> None: + """Get the latest data.""" + result, status = await komfovent_api.get_unit_status( + self._komfovent_credentials + ) + if result != komfovent_api.KomfoventConnectionResult.SUCCESS or not status: + self._attr_available = False + return + self._attr_available = True + self._attr_preset_mode = status.preset + self._attr_current_temperature = status.temp_extract + self._attr_hvac_mode = KOMFOVENT_TO_HASS_MODES[status.mode] diff --git a/homeassistant/components/komfovent/config_flow.py b/homeassistant/components/komfovent/config_flow.py new file mode 100644 index 00000000000..fb5390a30c6 --- /dev/null +++ b/homeassistant/components/komfovent/config_flow.py @@ -0,0 +1,74 @@ +"""Config flow for Komfovent integration.""" +from __future__ import annotations + +import logging +from typing import Any + +import komfovent_api +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME +from homeassistant.data_entry_flow import FlowResult + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +STEP_USER = "user" +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_HOST): str, + vol.Optional(CONF_USERNAME, default="user"): str, + vol.Required(CONF_PASSWORD): str, + } +) + +ERRORS_MAP = { + komfovent_api.KomfoventConnectionResult.NOT_FOUND: "cannot_connect", + komfovent_api.KomfoventConnectionResult.UNAUTHORISED: "invalid_auth", + komfovent_api.KomfoventConnectionResult.INVALID_INPUT: "invalid_input", +} + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Komfovent.""" + + VERSION = 1 + + def __return_error( + self, result: komfovent_api.KomfoventConnectionResult + ) -> FlowResult: + return self.async_show_form( + step_id=STEP_USER, + data_schema=STEP_USER_DATA_SCHEMA, + errors={"base": ERRORS_MAP.get(result, "unknown")}, + ) + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + if user_input is None: + return self.async_show_form( + step_id=STEP_USER, data_schema=STEP_USER_DATA_SCHEMA + ) + + conf_host = user_input[CONF_HOST] + conf_username = user_input[CONF_USERNAME] + conf_password = user_input[CONF_PASSWORD] + + result, credentials = komfovent_api.get_credentials( + conf_host, conf_username, conf_password + ) + if result != komfovent_api.KomfoventConnectionResult.SUCCESS: + return self.__return_error(result) + + result, settings = await komfovent_api.get_settings(credentials) + if result != komfovent_api.KomfoventConnectionResult.SUCCESS: + return self.__return_error(result) + + await self.async_set_unique_id(settings.serial_number) + self._abort_if_unique_id_configured() + + return self.async_create_entry(title=settings.name, data=user_input) diff --git a/homeassistant/components/komfovent/const.py b/homeassistant/components/komfovent/const.py new file mode 100644 index 00000000000..a7881a58c41 --- /dev/null +++ b/homeassistant/components/komfovent/const.py @@ -0,0 +1,3 @@ +"""Constants for the Komfovent integration.""" + +DOMAIN = "komfovent" diff --git a/homeassistant/components/komfovent/manifest.json b/homeassistant/components/komfovent/manifest.json new file mode 100644 index 00000000000..cbe00ef8dc5 --- /dev/null +++ b/homeassistant/components/komfovent/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "komfovent", + "name": "Komfovent", + "codeowners": ["@ProstoSanja"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/komfovent", + "iot_class": "local_polling", + "requirements": ["komfovent-api==0.0.3"] +} diff --git a/homeassistant/components/komfovent/strings.json b/homeassistant/components/komfovent/strings.json new file mode 100644 index 00000000000..074754c1fe0 --- /dev/null +++ b/homeassistant/components/komfovent/strings.json @@ -0,0 +1,22 @@ +{ + "config": { + "step": { + "user": { + "data": { + "host": "[%key:common::config_flow::data::host%]", + "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%]", + "invalid_input": "Failed to parse provided hostname", + "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 b0327dbdc29..16f0e48e4ee 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -244,6 +244,7 @@ FLOWS = { "kmtronic", "knx", "kodi", + "komfovent", "konnected", "kostal_plenticore", "kraken", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index e6ceda10924..7680463cbd2 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -2881,6 +2881,12 @@ "config_flow": true, "iot_class": "local_push" }, + "komfovent": { + "name": "Komfovent", + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_polling" + }, "konnected": { "name": "Konnected.io", "integration_type": "hub", diff --git a/requirements_all.txt b/requirements_all.txt index 6624e9c2594..4f9d614f4c2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1127,6 +1127,9 @@ kiwiki-client==0.1.1 # homeassistant.components.knx knx-frontend==2023.6.23.191712 +# homeassistant.components.komfovent +komfovent-api==0.0.3 + # homeassistant.components.konnected konnected==1.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5f20e579e47..6b5cb6a53ed 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -886,6 +886,9 @@ kegtron-ble==0.4.0 # homeassistant.components.knx knx-frontend==2023.6.23.191712 +# homeassistant.components.komfovent +komfovent-api==0.0.3 + # homeassistant.components.konnected konnected==1.2.0 diff --git a/tests/components/komfovent/__init__.py b/tests/components/komfovent/__init__.py new file mode 100644 index 00000000000..e5492a52327 --- /dev/null +++ b/tests/components/komfovent/__init__.py @@ -0,0 +1 @@ +"""Tests for the Komfovent integration.""" diff --git a/tests/components/komfovent/conftest.py b/tests/components/komfovent/conftest.py new file mode 100644 index 00000000000..d9cb0950c74 --- /dev/null +++ b/tests/components/komfovent/conftest.py @@ -0,0 +1,14 @@ +"""Common fixtures for the Komfovent tests.""" +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.komfovent.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry diff --git a/tests/components/komfovent/test_config_flow.py b/tests/components/komfovent/test_config_flow.py new file mode 100644 index 00000000000..008d92e36a3 --- /dev/null +++ b/tests/components/komfovent/test_config_flow.py @@ -0,0 +1,189 @@ +"""Test the Komfovent config flow.""" +from unittest.mock import AsyncMock, patch + +import komfovent_api +import pytest + +from homeassistant import config_entries +from homeassistant.components.komfovent.const import DOMAIN +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResult, FlowResultType + +from tests.common import MockConfigEntry + + +async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: + """Test flow completes as expected.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["errors"] is None + + final_result = await __test_normal_flow(hass, result["flow_id"]) + assert final_result["type"] == FlowResultType.CREATE_ENTRY + assert final_result["title"] == "test-name" + assert final_result["data"] == { + CONF_HOST: "1.1.1.1", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + } + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("error", "expected_response"), + [ + (komfovent_api.KomfoventConnectionResult.NOT_FOUND, "cannot_connect"), + (komfovent_api.KomfoventConnectionResult.UNAUTHORISED, "invalid_auth"), + (komfovent_api.KomfoventConnectionResult.INVALID_INPUT, "invalid_input"), + ], +) +async def test_flow_error_authenticating( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + error: komfovent_api.KomfoventConnectionResult, + expected_response: str, +) -> None: + """Test errors during flow authentication step are handled and dont affect final result.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + with patch( + "homeassistant.components.komfovent.config_flow.komfovent_api.get_credentials", + return_value=( + error, + None, + ), + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "1.1.1.1", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + ) + + assert result2["type"] == FlowResultType.FORM + assert result2["errors"] == {"base": expected_response} + + final_result = await __test_normal_flow(hass, result2["flow_id"]) + assert final_result["type"] == FlowResultType.CREATE_ENTRY + assert final_result["title"] == "test-name" + assert final_result["data"] == { + CONF_HOST: "1.1.1.1", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + } + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("error", "expected_response"), + [ + (komfovent_api.KomfoventConnectionResult.NOT_FOUND, "cannot_connect"), + (komfovent_api.KomfoventConnectionResult.UNAUTHORISED, "invalid_auth"), + (komfovent_api.KomfoventConnectionResult.INVALID_INPUT, "invalid_input"), + ], +) +async def test_flow_error_device_info( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + error: komfovent_api.KomfoventConnectionResult, + expected_response: str, +) -> None: + """Test errors during flow device info download step are handled and dont affect final result.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + with patch( + "homeassistant.components.komfovent.config_flow.komfovent_api.get_credentials", + return_value=( + komfovent_api.KomfoventConnectionResult.SUCCESS, + komfovent_api.KomfoventCredentials("1.1.1.1", "user", "pass"), + ), + ), patch( + "homeassistant.components.komfovent.config_flow.komfovent_api.get_settings", + return_value=( + error, + None, + ), + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "1.1.1.1", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + ) + + assert result2["type"] == FlowResultType.FORM + assert result2["errors"] == {"base": expected_response} + + final_result = await __test_normal_flow(hass, result2["flow_id"]) + assert final_result["type"] == FlowResultType.CREATE_ENTRY + assert final_result["title"] == "test-name" + assert final_result["data"] == { + CONF_HOST: "1.1.1.1", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_device_already_exists( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: + """Test device is not added when it already exists.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "1.1.1.1", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + unique_id="test-uid", + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["errors"] is None + + final_result = await __test_normal_flow(hass, result["flow_id"]) + assert final_result["type"] == FlowResultType.ABORT + assert final_result["reason"] == "already_configured" + + +async def __test_normal_flow(hass: HomeAssistant, flow_id: str) -> FlowResult: + """Test flow completing as expected, no matter what happened before.""" + + with patch( + "homeassistant.components.komfovent.config_flow.komfovent_api.get_credentials", + return_value=( + komfovent_api.KomfoventConnectionResult.SUCCESS, + komfovent_api.KomfoventCredentials("1.1.1.1", "user", "pass"), + ), + ), patch( + "homeassistant.components.komfovent.config_flow.komfovent_api.get_settings", + return_value=( + komfovent_api.KomfoventConnectionResult.SUCCESS, + komfovent_api.KomfoventSettings("test-name", None, None, "test-uid"), + ), + ): + final_result = await hass.config_entries.flow.async_configure( + flow_id, + { + CONF_HOST: "1.1.1.1", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + ) + await hass.async_block_till_done() + + return final_result