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:
parent
787fb3b954
commit
f3cccf0a2b
15 changed files with 454 additions and 0 deletions
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
34
homeassistant/components/komfovent/__init__.py
Normal file
34
homeassistant/components/komfovent/__init__.py
Normal 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)
|
91
homeassistant/components/komfovent/climate.py
Normal file
91
homeassistant/components/komfovent/climate.py
Normal 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]
|
74
homeassistant/components/komfovent/config_flow.py
Normal file
74
homeassistant/components/komfovent/config_flow.py
Normal 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)
|
3
homeassistant/components/komfovent/const.py
Normal file
3
homeassistant/components/komfovent/const.py
Normal file
|
@ -0,0 +1,3 @@
|
|||
"""Constants for the Komfovent integration."""
|
||||
|
||||
DOMAIN = "komfovent"
|
9
homeassistant/components/komfovent/manifest.json
Normal file
9
homeassistant/components/komfovent/manifest.json
Normal 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"]
|
||||
}
|
22
homeassistant/components/komfovent/strings.json
Normal file
22
homeassistant/components/komfovent/strings.json
Normal 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%]"
|
||||
}
|
||||
}
|
||||
}
|
|
@ -244,6 +244,7 @@ FLOWS = {
|
|||
"kmtronic",
|
||||
"knx",
|
||||
"kodi",
|
||||
"komfovent",
|
||||
"konnected",
|
||||
"kostal_plenticore",
|
||||
"kraken",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
1
tests/components/komfovent/__init__.py
Normal file
1
tests/components/komfovent/__init__.py
Normal file
|
@ -0,0 +1 @@
|
|||
"""Tests for the Komfovent integration."""
|
14
tests/components/komfovent/conftest.py
Normal file
14
tests/components/komfovent/conftest.py
Normal 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
|
189
tests/components/komfovent/test_config_flow.py
Normal file
189
tests/components/komfovent/test_config_flow.py
Normal 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
|
Loading…
Add table
Reference in a new issue