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 <joostlek@outlook.com>
This commit is contained in:
Alex Tsernoh 2023-11-11 12:19:41 +02:00 committed by GitHub
parent 787fb3b954
commit f3cccf0a2b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 454 additions and 0 deletions

View file

@ -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

View file

@ -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

View file

@ -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)

View file

@ -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]

View file

@ -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)

View file

@ -0,0 +1,3 @@
"""Constants for the Komfovent integration."""
DOMAIN = "komfovent"

View file

@ -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"]
}

View file

@ -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%]"
}
}
}

View file

@ -244,6 +244,7 @@ FLOWS = {
"kmtronic",
"knx",
"kodi",
"komfovent",
"konnected",
"kostal_plenticore",
"kraken",

View file

@ -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",

View file

@ -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

View file

@ -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

View file

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

View file

@ -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

View file

@ -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