Migrate Intergas InComfort/Intouch Lan2RF gateway YAML to config flow (#118642)

* Add config flow

* Make sure the device is polled - refactor

* Fix

* Add tests config flow

* Update test requirements

* Ensure dispatcher has a unique signal per heater

* Followup on review

* Follow up comments

* One more docstr

* Make specific try blocks and refactoring

* Handle import exceptions

* Restore removed lines

* Move initial heater update in try block

* Raise issue failed import

* Update test codeowners

* Remove entity device info

* Remove entity device info

* Appy suggestions from code review

* Remove broad exception handling from entry setup

* Test coverage
This commit is contained in:
Jan Bouwhuis 2024-06-03 20:37:48 +02:00 committed by GitHub
parent aac31059b0
commit dd1dd4c6a3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 621 additions and 94 deletions

View File

@ -596,7 +596,13 @@ omit =
homeassistant/components/ifttt/alarm_control_panel.py
homeassistant/components/iglo/light.py
homeassistant/components/ihc/*
homeassistant/components/incomfort/*
homeassistant/components/incomfort/__init__.py
homeassistant/components/incomfort/binary_sensor.py
homeassistant/components/incomfort/climate.py
homeassistant/components/incomfort/errors.py
homeassistant/components/incomfort/models.py
homeassistant/components/incomfort/sensor.py
homeassistant/components/incomfort/water_heater.py
homeassistant/components/insteon/binary_sensor.py
homeassistant/components/insteon/climate.py
homeassistant/components/insteon/cover.py

View File

@ -659,6 +659,7 @@ build.json @home-assistant/supervisor
/homeassistant/components/improv_ble/ @emontnemery
/tests/components/improv_ble/ @emontnemery
/homeassistant/components/incomfort/ @jbouwh
/tests/components/incomfort/ @jbouwh
/homeassistant/components/influxdb/ @mdegat01
/tests/components/influxdb/ @mdegat01
/homeassistant/components/inkbird/ @bdraco

View File

@ -2,24 +2,23 @@
from __future__ import annotations
import logging
from aiohttp import ClientResponseError
from incomfortclient import Gateway as InComfortGateway
from incomfortclient import IncomfortError, InvalidHeaterList
import voluptuous as vol
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, Platform
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.discovery import async_load_platform
from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant, callback
from homeassistant.data_entry_flow import FlowResultType
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers import config_validation as cv, issue_registry as ir
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity import Entity
from homeassistant.helpers.typing import ConfigType
_LOGGER = logging.getLogger(__name__)
DOMAIN = "incomfort"
from .const import DOMAIN
from .errors import InConfortTimeout, InConfortUnknownError, NoHeaters, NotFound
from .models import DATA_INCOMFORT, async_connect_gateway
CONFIG_SCHEMA = vol.Schema(
{
@ -41,35 +40,87 @@ PLATFORMS = (
Platform.CLIMATE,
)
INTEGRATION_TITLE = "Intergas InComfort/Intouch Lan2RF gateway"
async def _async_import(hass: HomeAssistant, config: ConfigType) -> None:
"""Import config entry from configuration.yaml."""
if not hass.config_entries.async_entries(DOMAIN):
# Start import flow
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_IMPORT}, data=config
)
if result["type"] == FlowResultType.ABORT:
ir.async_create_issue(
hass,
DOMAIN,
f"deprecated_yaml_import_issue_{result['reason']}",
breaks_in_ha_version="2025.1.0",
is_fixable=False,
issue_domain=DOMAIN,
severity=ir.IssueSeverity.WARNING,
translation_key=f"deprecated_yaml_import_issue_{result['reason']}",
translation_placeholders={
"domain": DOMAIN,
"integration_title": INTEGRATION_TITLE,
},
)
return
ir.async_create_issue(
hass,
HOMEASSISTANT_DOMAIN,
f"deprecated_yaml_{DOMAIN}",
breaks_in_ha_version="2025.1.0",
is_fixable=False,
issue_domain=DOMAIN,
severity=ir.IssueSeverity.WARNING,
translation_key="deprecated_yaml",
translation_placeholders={
"domain": DOMAIN,
"integration_title": INTEGRATION_TITLE,
},
)
async def async_setup(hass: HomeAssistant, hass_config: ConfigType) -> bool:
"""Create an Intergas InComfort/Intouch system."""
incomfort_data = hass.data[DOMAIN] = {}
credentials = dict(hass_config[DOMAIN])
hostname = credentials.pop(CONF_HOST)
client = incomfort_data["client"] = InComfortGateway(
hostname, **credentials, session=async_get_clientsession(hass)
)
try:
heaters = incomfort_data["heaters"] = list(await client.heaters())
except ClientResponseError as err:
_LOGGER.warning("Setup failed, check your configuration, message is: %s", err)
return False
for heater in heaters:
await heater.update()
for platform in PLATFORMS:
hass.async_create_task(
async_load_platform(hass, platform, DOMAIN, {}, hass_config)
)
if config := hass_config.get(DOMAIN):
hass.async_create_task(_async_import(hass, config))
return True
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up a config entry."""
try:
data = await async_connect_gateway(hass, dict(entry.data))
for heater in data.heaters:
await heater.update()
except InvalidHeaterList as exc:
raise NoHeaters from exc
except IncomfortError as exc:
if isinstance(exc.message, ClientResponseError):
if exc.message.status == 401:
raise ConfigEntryAuthFailed("Incorrect credentials") from exc
if exc.message.status == 404:
raise NotFound from exc
raise InConfortUnknownError from exc
except TimeoutError as exc:
raise InConfortTimeout from exc
hass.data.setdefault(DATA_INCOMFORT, {entry.entry_id: data})
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload config entry."""
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
del hass.data[DOMAIN][entry.entry_id]
return unload_ok
class IncomfortEntity(Entity):
"""Base class for all InComfort entities."""
@ -77,7 +128,11 @@ class IncomfortEntity(Entity):
async def async_added_to_hass(self) -> None:
"""Set up a listener when this entity is added to HA."""
self.async_on_remove(async_dispatcher_connect(self.hass, DOMAIN, self._refresh))
self.async_on_remove(
async_dispatcher_connect(
self.hass, f"{DOMAIN}_{self.unique_id}", self._refresh
)
)
@callback
def _refresh(self) -> None:

View File

@ -7,27 +7,23 @@ from typing import Any
from incomfortclient import Gateway as InComfortGateway, Heater as InComfortHeater
from homeassistant.components.binary_sensor import BinarySensorEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from . import DOMAIN, IncomfortEntity
from . import DATA_INCOMFORT, IncomfortEntity
async def async_setup_platform(
async def async_setup_entry(
hass: HomeAssistant,
config: ConfigType,
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
discovery_info: DiscoveryInfoType | None = None,
) -> None:
"""Set up an InComfort/InTouch binary_sensor device."""
if discovery_info is None:
return
client = hass.data[DOMAIN]["client"]
heaters = hass.data[DOMAIN]["heaters"]
async_add_entities([IncomfortFailed(client, h) for h in heaters])
"""Set up an InComfort/InTouch binary_sensor entity."""
incomfort_data = hass.data[DATA_INCOMFORT][entry.entry_id]
async_add_entities(
IncomfortFailed(incomfort_data.client, h) for h in incomfort_data.heaters
)
class IncomfortFailed(IncomfortEntity, BinarySensorEntity):

View File

@ -15,29 +15,25 @@ from homeassistant.components.climate import (
ClimateEntityFeature,
HVACMode,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from . import DOMAIN, IncomfortEntity
from . import DATA_INCOMFORT, IncomfortEntity
async def async_setup_platform(
async def async_setup_entry(
hass: HomeAssistant,
config: ConfigType,
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
discovery_info: DiscoveryInfoType | None = None,
) -> None:
"""Set up an InComfort/InTouch climate device."""
if discovery_info is None:
return
client = hass.data[DOMAIN]["client"]
heaters = hass.data[DOMAIN]["heaters"]
"""Set up InComfort/InTouch climate devices."""
incomfort_data = hass.data[DATA_INCOMFORT][entry.entry_id]
async_add_entities(
[InComfortClimate(client, h, r) for h in heaters for r in h.rooms]
InComfortClimate(incomfort_data.client, h, r)
for h in incomfort_data.heaters
for r in h.rooms
)

View File

@ -0,0 +1,91 @@
"""Config flow support for Intergas InComfort integration."""
from typing import Any
from aiohttp import ClientResponseError
from incomfortclient import IncomfortError, InvalidHeaterList
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant
from homeassistant.helpers.selector import (
TextSelector,
TextSelectorConfig,
TextSelectorType,
)
from .const import DOMAIN
from .models import async_connect_gateway
TITLE = "Intergas InComfort/Intouch Lan2RF gateway"
CONFIG_SCHEMA = vol.Schema(
{
vol.Required(CONF_HOST): TextSelector(
TextSelectorConfig(type=TextSelectorType.TEXT)
),
vol.Optional(CONF_USERNAME): TextSelector(
TextSelectorConfig(type=TextSelectorType.TEXT, autocomplete="admin")
),
vol.Optional(CONF_PASSWORD): TextSelector(
TextSelectorConfig(type=TextSelectorType.PASSWORD)
),
}
)
ERROR_STATUS_MAPPING: dict[int, tuple[str, str]] = {
401: (CONF_PASSWORD, "auth_error"),
404: ("base", "not_found"),
}
async def async_try_connect_gateway(
hass: HomeAssistant, config: dict[str, Any]
) -> dict[str, str] | None:
"""Try to connect to the Lan2RF gateway."""
try:
await async_connect_gateway(hass, config)
except InvalidHeaterList:
return {"base": "no_heaters"}
except IncomfortError as exc:
if isinstance(exc.message, ClientResponseError):
scope, error = ERROR_STATUS_MAPPING.get(
exc.message.status, ("base", "unknown")
)
return {scope: error}
return {"base": "unknown"}
except TimeoutError:
return {"base": "timeout_error"}
except Exception: # noqa: BLE001
return {"base": "unknown"}
return None
class InComfortConfigFlow(ConfigFlow, domain=DOMAIN):
"""Config flow to set up an Intergas InComfort boyler and thermostats."""
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
errors: dict[str, str] | None = None
if user_input is not None:
self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]})
if (
errors := await async_try_connect_gateway(self.hass, user_input)
) is None:
return self.async_create_entry(title=TITLE, data=user_input)
return self.async_show_form(
step_id="user", data_schema=CONFIG_SCHEMA, errors=errors
)
async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult:
"""Import `incomfort` config entry from configuration.yaml."""
errors: dict[str, str] | None = None
if (errors := await async_try_connect_gateway(self.hass, import_data)) is None:
return self.async_create_entry(title=TITLE, data=import_data)
reason = next(iter(errors.items()))[1]
return self.async_abort(reason=reason)

View File

@ -0,0 +1,3 @@
"""Constants for Intergas InComfort integration."""
DOMAIN = "incomfort"

View File

@ -0,0 +1,32 @@
"""Exceptions raised by Intergas InComfort integration."""
from homeassistant.core import DOMAIN
from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError
class NotFound(HomeAssistantError):
"""Raise exception if no Lan2RF Gateway was found."""
translation_domain = DOMAIN
translation_key = "not_found"
class NoHeaters(ConfigEntryNotReady):
"""Raise exception if no heaters are found."""
translation_domain = DOMAIN
translation_key = "no_heaters"
class InConfortTimeout(ConfigEntryNotReady):
"""Raise exception if no heaters are found."""
translation_domain = DOMAIN
translation_key = "timeout_error"
class InConfortUnknownError(ConfigEntryNotReady):
"""Raise exception if no heaters are found."""
translation_domain = DOMAIN
translation_key = "unknown"

View File

@ -2,6 +2,7 @@
"domain": "incomfort",
"name": "Intergas InComfort/Intouch Lan2RF gateway",
"codeowners": ["@jbouwh"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/incomfort",
"iot_class": "local_polling",
"loggers": ["incomfortclient"],

View File

@ -0,0 +1,40 @@
"""Models for Intergas InComfort integration."""
from dataclasses import dataclass, field
from typing import Any
from incomfortclient import Gateway as InComfortGateway, Heater as InComfortHeater
from homeassistant.const import CONF_HOST
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.util.hass_dict import HassKey
from .const import DOMAIN
@dataclass
class InComfortData:
"""Keep the Intergas InComfort entry data."""
client: InComfortGateway
heaters: list[InComfortHeater] = field(default_factory=list)
DATA_INCOMFORT: HassKey[dict[str, InComfortData]] = HassKey(DOMAIN)
async def async_connect_gateway(
hass: HomeAssistant,
entry_data: dict[str, Any],
) -> InComfortData:
"""Validate the configuration."""
credentials = dict(entry_data)
hostname = credentials.pop(CONF_HOST)
client = InComfortGateway(
hostname, **credentials, session=async_get_clientsession(hass)
)
heaters = await client.heaters()
return InComfortData(client=client, heaters=heaters)

View File

@ -12,13 +12,13 @@ from homeassistant.components.sensor import (
SensorEntity,
SensorEntityDescription,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import UnitOfPressure, UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.util import slugify
from . import DOMAIN, IncomfortEntity
from . import DATA_INCOMFORT, IncomfortEntity
INCOMFORT_HEATER_TEMP = "CV Temp"
INCOMFORT_PRESSURE = "CV Pressure"
@ -59,26 +59,18 @@ SENSOR_TYPES: tuple[IncomfortSensorEntityDescription, ...] = (
)
async def async_setup_platform(
async def async_setup_entry(
hass: HomeAssistant,
config: ConfigType,
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
discovery_info: DiscoveryInfoType | None = None,
) -> None:
"""Set up an InComfort/InTouch sensor device."""
if discovery_info is None:
return
client = hass.data[DOMAIN]["client"]
heaters = hass.data[DOMAIN]["heaters"]
entities = [
IncomfortSensor(client, heater, description)
for heater in heaters
"""Set up InComfort/InTouch sensor entities."""
incomfort_data = hass.data[DATA_INCOMFORT][entry.entry_id]
async_add_entities(
IncomfortSensor(incomfort_data.client, heater, description)
for heater in incomfort_data.heaters
for description in SENSOR_TYPES
]
async_add_entities(entities)
)
class IncomfortSensor(IncomfortEntity, SensorEntity):

View File

@ -0,0 +1,56 @@
{
"config": {
"step": {
"user": {
"description": "Set up new Intergas InComfort Lan2RF Gateway, some older systems might not need credentials to be set up. For newer devices authentication is required.",
"data": {
"host": "[%key:common::config_flow::data::host%]",
"username": "[%key:common::config_flow::data::username%]",
"password": "[%key:common::config_flow::data::password%]"
},
"data_description": {
"host": "Hostname or IP-address of the Intergas InComfort Lan2RF Gateway.",
"username": "The username to log into the gateway. This is `admin` in most cases.",
"password": "The password to log into the gateway, is printed at the bottom of the Lan2RF Gateway or is `intergas` for some older devices."
}
}
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]",
"auth_error": "Invalid credentials.",
"no_heaters": "No heaters found.",
"not_found": "No Lan2RF gateway found.",
"timeout_error": "Time out when connection to Lan2RF gateway.",
"unknown": "Unknown error when connection to Lan2RF gateway."
},
"error": {
"auth_error": "[%key:component::incomfort::config::abort::auth_error%]",
"no_heaters": "[%key:component::incomfort::config::abort::no_heaters%]",
"not_found": "[%key:component::incomfort::config::abort::not_found%]",
"timeout_error": "[%key:component::incomfort::config::abort::timeout_error%]",
"unknown": "[%key:component::incomfort::config::abort::unknown%]"
}
},
"issues": {
"deprecated_yaml_import_issue_unknown": {
"title": "YAML import failed with unknown error",
"description": "Configuring {integration_title} using YAML is being removed but there was an unknown error while importing your existing configuration.\nSetup will not proceed.\n\nVerify that your {integration_title} is operating correctly and restart Home Assistant to attempt the import again.\n\nAlternatively, you may remove the `{domain}` configuration from your configuration.yaml entirely, restart Home Assistant, and add the {integration_title} integration manually."
},
"deprecated_yaml_import_issue_auth_error": {
"title": "YAML import failed due to an authentication error",
"description": "Configuring {integration_title} using YAML is being removed but there was an authentication error while importing your existing configuration.\nSetup will not proceed.\n\nVerify that your {integration_title} is operating correctly and restart Home Assistant to attempt the import again.\n\nAlternatively, you may remove the `{domain}` configuration from your configuration.yaml entirely, restart Home Assistant, and add the {integration_title} integration manually."
},
"deprecated_yaml_import_issue_no_heaters": {
"title": "YAML import failed because no heaters were found",
"description": "Configuring {integration_title} using YAML is being removed but no heaters were found while importing your existing configuration.\nSetup will not proceed.\n\nVerify that your {integration_title} is operating correctly and restart Home Assistant to attempt the import again.\n\nAlternatively, you may remove the `{domain}` configuration from your configuration.yaml entirely, restart Home Assistant, and add the {integration_title} integration manually."
},
"deprecated_yaml_import_issue_not_found": {
"title": "YAML import failed because no gateway was found",
"description": "Configuring {integration_title} using YAML is being removed but no Lan2RF gateway was found while importing your existing configuration.\nSetup will not proceed.\n\nVerify that your {integration_title} is operating correctly and restart Home Assistant to attempt the import again.\n\nAlternatively, you may remove the `{domain}` configuration from your configuration.yaml entirely, restart Home Assistant, and add the {integration_title} integration manually."
},
"deprecated_yaml_import_issue_timeout_error": {
"title": "YAML import failed because of timeout issues",
"description": "Configuring {integration_title} using YAML is being removed but there was a timeout while connecting to your {integration_title} while importing your existing configuration.\nSetup will not proceed.\n\nVerify that your {integration_title} is operating correctly and restart Home Assistant to attempt the import again.\n\nAlternatively, you may remove the `{domain}` configuration from your configuration.yaml entirely, restart Home Assistant, and add the {integration_title} integration manually."
}
}
}

View File

@ -9,33 +9,29 @@ from aiohttp import ClientResponseError
from incomfortclient import Gateway as InComfortGateway, Heater as InComfortHeater
from homeassistant.components.water_heater import WaterHeaterEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from . import DOMAIN, IncomfortEntity
from . import DATA_INCOMFORT, DOMAIN, IncomfortEntity
_LOGGER = logging.getLogger(__name__)
HEATER_ATTRS = ["display_code", "display_text", "is_burning"]
async def async_setup_platform(
async def async_setup_entry(
hass: HomeAssistant,
config: ConfigType,
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
discovery_info: DiscoveryInfoType | None = None,
) -> None:
"""Set up an InComfort/Intouch water_heater device."""
if discovery_info is None:
return
client = hass.data[DOMAIN]["client"]
heaters = hass.data[DOMAIN]["heaters"]
async_add_entities([IncomfortWaterHeater(client, h) for h in heaters])
"""Set up an InComfort/InTouch water_heater device."""
incomfort_data = hass.data[DATA_INCOMFORT][entry.entry_id]
async_add_entities(
IncomfortWaterHeater(incomfort_data.client, h) for h in incomfort_data.heaters
)
class IncomfortWaterHeater(IncomfortEntity, WaterHeaterEntity):
@ -92,4 +88,4 @@ class IncomfortWaterHeater(IncomfortEntity, WaterHeaterEntity):
_LOGGER.warning("Update failed, message is: %s", err)
else:
async_dispatcher_send(self.hass, DOMAIN)
async_dispatcher_send(self.hass, f"{DOMAIN}_{self.unique_id}")

View File

@ -256,6 +256,7 @@ FLOWS = {
"imap",
"imgw_pib",
"improv_ble",
"incomfort",
"inkbird",
"insteon",
"intellifire",

View File

@ -2809,7 +2809,7 @@
"incomfort": {
"name": "Intergas InComfort/Intouch Lan2RF gateway",
"integration_type": "hub",
"config_flow": false,
"config_flow": true,
"iot_class": "local_polling"
},
"indianamichiganpower": {

View File

@ -935,6 +935,9 @@ ifaddr==0.2.0
# homeassistant.components.imgw_pib
imgw_pib==1.0.1
# homeassistant.components.incomfort
incomfort-client==0.5.0
# homeassistant.components.influxdb
influxdb-client==1.24.0

View File

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

View File

@ -0,0 +1,94 @@
"""Fixtures for Intergas InComfort integration."""
from collections.abc import Generator
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from homeassistant.core import HomeAssistant
@pytest.fixture
def mock_setup_entry() -> Generator[AsyncMock, None, None]:
"""Override async_setup_entry."""
with patch(
"homeassistant.components.incomfort.async_setup_entry",
return_value=True,
) as mock_setup_entry:
yield mock_setup_entry
@pytest.fixture
def mock_heater_status() -> dict[str, Any]:
"""Mock heater status."""
return {
"display_code": 126,
"display_text": "standby",
"fault_code": None,
"is_burning": False,
"is_failed": False,
"is_pumping": False,
"is_tapping": False,
"heater_temp": 35.34,
"tap_temp": 30.21,
"pressure": 1.86,
"serial_no": "2404c08648",
"nodenr": 249,
"rf_message_rssi": 30,
"rfstatus_cntr": 0,
}
@pytest.fixture
def mock_room_status() -> dict[str, Any]:
"""Mock room status."""
return {"room_temp": 21.42, "setpoint": 18.0, "override": 18.0}
@pytest.fixture
def mock_incomfort(
hass: HomeAssistant,
mock_heater_status: dict[str, Any],
mock_room_status: dict[str, Any],
) -> Generator[MagicMock, None]:
"""Mock the InComfort gateway client."""
class MockRoom:
"""Mocked InComfort room class."""
override: float
room_no: int
room_temp: float
setpoint: float
status: dict[str, Any]
def __init__(self) -> None:
"""Initialize mocked room."""
self.override = mock_room_status["override"]
self.room_no = 1
self.room_temp = mock_room_status["room_temp"]
self.setpoint = mock_room_status["setpoint"]
self.status = mock_room_status
class MockHeater:
"""Mocked InComfort heater class."""
serial_no: str
status: dict[str, Any]
rooms: list[MockRoom]
def __init__(self) -> None:
"""Initialize mocked heater."""
self.serial_no = "c0ffeec0ffee"
async def update(self) -> None:
self.status = mock_heater_status
self.rooms = [MockRoom]
with patch(
"homeassistant.components.incomfort.models.InComfortGateway", MagicMock()
) as patch_gateway:
patch_gateway().heaters = AsyncMock()
patch_gateway().heaters.return_value = [MockHeater()]
yield patch_gateway

View File

@ -0,0 +1,163 @@
"""Tests for the Intergas InComfort config flow."""
from unittest.mock import AsyncMock, MagicMock
from aiohttp import ClientResponseError
from incomfortclient import IncomfortError, InvalidHeaterList
import pytest
from homeassistant.components.incomfort import DOMAIN
from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER
from homeassistant.const import CONF_HOST, CONF_PASSWORD
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from tests.common import MockConfigEntry
MOCK_CONFIG = {
"host": "192.168.1.12",
"username": "admin",
"password": "verysecret",
}
async def test_form(
hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_incomfort: MagicMock
) -> None:
"""Test we get the full form."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
assert result["errors"] is None
result = await hass.config_entries.flow.async_configure(
result["flow_id"], MOCK_CONFIG
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "Intergas InComfort/Intouch Lan2RF gateway"
assert result["data"] == MOCK_CONFIG
assert len(mock_setup_entry.mock_calls) == 1
async def test_import(
hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_incomfort: MagicMock
) -> None:
"""Test we van import from YAML."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_IMPORT}, data=MOCK_CONFIG
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "Intergas InComfort/Intouch Lan2RF gateway"
assert result["data"] == MOCK_CONFIG
assert len(mock_setup_entry.mock_calls) == 1
@pytest.mark.parametrize(
("exc", "abort_reason"),
[
(IncomfortError(ClientResponseError(None, None, status=401)), "auth_error"),
(IncomfortError(ClientResponseError(None, None, status=404)), "not_found"),
(IncomfortError(ClientResponseError(None, None, status=500)), "unknown"),
(IncomfortError, "unknown"),
(InvalidHeaterList, "no_heaters"),
(ValueError, "unknown"),
(TimeoutError, "timeout_error"),
],
)
async def test_import_fails(
hass: HomeAssistant,
mock_setup_entry: AsyncMock,
mock_incomfort: MagicMock,
exc: Exception,
abort_reason: str,
) -> None:
"""Test YAML import fails."""
mock_incomfort().heaters.side_effect = exc
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_IMPORT}, data=MOCK_CONFIG
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == abort_reason
assert len(mock_setup_entry.mock_calls) == 0
async def test_entry_already_configured(hass: HomeAssistant) -> None:
"""Test aborting if the entry is already configured."""
entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG)
entry.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_HOST: MOCK_CONFIG[CONF_HOST],
},
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"
@pytest.mark.parametrize(
("exc", "error", "base"),
[
(
IncomfortError(ClientResponseError(None, None, status=401)),
"auth_error",
CONF_PASSWORD,
),
(
IncomfortError(ClientResponseError(None, None, status=404)),
"not_found",
"base",
),
(
IncomfortError(ClientResponseError(None, None, status=500)),
"unknown",
"base",
),
(IncomfortError, "unknown", "base"),
(ValueError, "unknown", "base"),
(TimeoutError, "timeout_error", "base"),
(InvalidHeaterList, "no_heaters", "base"),
],
)
async def test_form_validation(
hass: HomeAssistant,
mock_incomfort: MagicMock,
exc: Exception,
error: str,
base: str,
) -> None:
"""Test form validation."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
# Simulate issue and retry
mock_incomfort().heaters.side_effect = exc
result = await hass.config_entries.flow.async_configure(
result["flow_id"], MOCK_CONFIG
)
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {
base: error,
}
# Fix the issue and retry
mock_incomfort().heaters.side_effect = None
result = await hass.config_entries.flow.async_configure(
result["flow_id"], MOCK_CONFIG
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert "errors" not in result