Add option to allow to use setpoint instead of override for legacy incomfort RF gateway (#135143)

* Add option to allow to use setpoint in stead of override for legacy incomfort RF gateway

* Add test to assert state with legacy_setpoint_status option

* Use selector

* Update homeassistant/components/incomfort/strings.json

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>

* Follow up on code review

* Rephrase data_description

* Rephrase

* Use async_schedule_reload helper

* Move option flow after config flow

---------

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
This commit is contained in:
Jan Bouwhuis 2025-01-13 19:50:06 +01:00 committed by GitHub
parent 1c053485a9
commit 984c380e13
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 278 additions and 20 deletions

View File

@ -25,7 +25,7 @@ INTEGRATION_TITLE = "Intergas InComfort/Intouch Lan2RF gateway"
type InComfortConfigEntry = ConfigEntry[InComfortDataCoordinator]
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_setup_entry(hass: HomeAssistant, entry: InComfortConfigEntry) -> bool:
"""Set up a config entry."""
try:
data = await async_connect_gateway(hass, dict(entry.data))

View File

@ -18,7 +18,7 @@ from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import InComfortConfigEntry
from .const import DOMAIN
from .const import CONF_LEGACY_SETPOINT_STATUS, DOMAIN
from .coordinator import InComfortDataCoordinator
from .entity import IncomfortEntity
@ -32,9 +32,12 @@ async def async_setup_entry(
) -> None:
"""Set up InComfort/InTouch climate devices."""
incomfort_coordinator = entry.runtime_data
legacy_setpoint_status = entry.options.get(CONF_LEGACY_SETPOINT_STATUS, False)
heaters = incomfort_coordinator.data.heaters
async_add_entities(
InComfortClimate(incomfort_coordinator, h, r) for h in heaters for r in h.rooms
InComfortClimate(incomfort_coordinator, h, r, legacy_setpoint_status)
for h in heaters
for r in h.rooms
)
@ -54,12 +57,14 @@ class InComfortClimate(IncomfortEntity, ClimateEntity):
coordinator: InComfortDataCoordinator,
heater: InComfortHeater,
room: InComfortRoom,
legacy_setpoint_status: bool,
) -> None:
"""Initialize the climate device."""
super().__init__(coordinator)
self._heater = heater
self._room = room
self._legacy_setpoint_status = legacy_setpoint_status
self._attr_unique_id = f"{heater.serial_no}_{room.room_no}"
self._attr_device_info = DeviceInfo(
@ -91,9 +96,11 @@ class InComfortClimate(IncomfortEntity, ClimateEntity):
As we set the override, we report back the override. The actual set point is
is returned at a later time.
Some older thermostats return 0.0 as override, in that case we fallback to
the actual setpoint.
Some older thermostats do not clear the override setting in that case, in that case
we fallback to the returning actual setpoint.
"""
if self._legacy_setpoint_status:
return self._room.setpoint
return self._room.override or self._room.setpoint
async def async_set_temperature(self, **kwargs: Any) -> None:

View File

@ -1,21 +1,30 @@
"""Config flow support for Intergas InComfort integration."""
from __future__ import annotations
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.config_entries import (
ConfigEntry,
ConfigFlow,
ConfigFlowResult,
OptionsFlow,
)
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.selector import (
BooleanSelector,
BooleanSelectorConfig,
TextSelector,
TextSelectorConfig,
TextSelectorType,
)
from .const import DOMAIN
from .const import CONF_LEGACY_SETPOINT_STATUS, DOMAIN
from .coordinator import async_connect_gateway
TITLE = "Intergas InComfort/Intouch Lan2RF gateway"
@ -34,6 +43,14 @@ CONFIG_SCHEMA = vol.Schema(
}
)
OPTIONS_SCHEMA = vol.Schema(
{
vol.Optional(CONF_LEGACY_SETPOINT_STATUS, default=False): BooleanSelector(
BooleanSelectorConfig()
)
}
)
ERROR_STATUS_MAPPING: dict[int, tuple[str, str]] = {
401: (CONF_PASSWORD, "auth_error"),
404: ("base", "not_found"),
@ -66,6 +83,14 @@ async def async_try_connect_gateway(
class InComfortConfigFlow(ConfigFlow, domain=DOMAIN):
"""Config flow to set up an Intergas InComfort boyler and thermostats."""
@staticmethod
@callback
def async_get_options_flow(
config_entry: ConfigEntry,
) -> InComfortOptionsFlowHandler:
"""Get the options flow for this handler."""
return InComfortOptionsFlowHandler()
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
@ -81,3 +106,29 @@ class InComfortConfigFlow(ConfigFlow, domain=DOMAIN):
return self.async_show_form(
step_id="user", data_schema=CONFIG_SCHEMA, errors=errors
)
class InComfortOptionsFlowHandler(OptionsFlow):
"""Handle InComfort Lan2RF gateway options."""
async def async_step_init(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Manage the options."""
errors: dict[str, str] | None = None
if user_input is not None:
new_options: dict[str, Any] = self.config_entry.options | user_input
self.hass.config_entries.async_update_entry(
self.config_entry, options=new_options
)
self.hass.config_entries.async_schedule_reload(self.config_entry.entry_id)
return self.async_create_entry(data=new_options)
data_schema = self.add_suggested_values_to_schema(
OPTIONS_SCHEMA, self.config_entry.options
)
return self.async_show_form(
step_id="init",
data_schema=data_schema,
errors=errors,
)

View File

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

View File

@ -31,6 +31,19 @@
"unknown": "[%key:component::incomfort::config::abort::unknown%]"
}
},
"options": {
"step": {
"init": {
"title": "Intergas InComfort Lan2RF Gateway options",
"data": {
"legacy_setpoint_status": "Legacy setpoint handling"
},
"data_description": {
"legacy_setpoint_status": "Some older gateway models with an older firmware versions might not update the thermostat setpoint and override settings correctly. Enable this option if you experience issues in updating the setpoint for your thermostat. It will use the actual setpoint of the thermostat instead of the override. As side effect is that it might take a few minutes before the setpoint is updated."
}
}
}
},
"issues": {
"deprecated_yaml_import_issue_unknown": {
"title": "YAML import failed with unknown error",

View File

@ -53,12 +53,22 @@ def mock_entry_data() -> dict[str, Any]:
return MOCK_CONFIG
@pytest.fixture
def mock_entry_options() -> dict[str, Any] | None:
"""Mock config entry options for fixture."""
return None
@pytest.fixture
def mock_config_entry(
hass: HomeAssistant, mock_entry_data: dict[str, Any]
hass: HomeAssistant,
mock_entry_data: dict[str, Any],
mock_entry_options: dict[str, Any],
) -> ConfigEntry:
"""Mock a config entry setup for incomfort integration."""
entry = MockConfigEntry(domain=DOMAIN, data=mock_entry_data)
entry = MockConfigEntry(
domain=DOMAIN, data=mock_entry_data, options=mock_entry_options
)
entry.add_to_hass(hass)
return entry

View File

@ -1,5 +1,5 @@
# serializer version: 1
# name: test_setup_platform[legacy_thermostat][climate.thermostat_1-entry]
# name: test_setup_platform[legacy-override][climate.thermostat_1-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
@ -38,7 +38,73 @@
'unit_of_measurement': None,
})
# ---
# name: test_setup_platform[legacy_thermostat][climate.thermostat_1-state]
# name: test_setup_platform[legacy-override][climate.thermostat_1-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'current_temperature': 21.4,
'friendly_name': 'Thermostat 1',
'hvac_action': <HVACAction.IDLE: 'idle'>,
'hvac_modes': list([
<HVACMode.HEAT: 'heat'>,
]),
'max_temp': 30.0,
'min_temp': 5.0,
'status': dict({
'override': 19.0,
'room_temp': 21.42,
'setpoint': 18.0,
}),
'supported_features': <ClimateEntityFeature: 1>,
'temperature': 18.0,
}),
'context': <ANY>,
'entity_id': 'climate.thermostat_1',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'heat',
})
# ---
# name: test_setup_platform[legacy-zero_override][climate.thermostat_1-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'hvac_modes': list([
<HVACMode.HEAT: 'heat'>,
]),
'max_temp': 30.0,
'min_temp': 5.0,
}),
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'climate',
'entity_category': None,
'entity_id': 'climate.thermostat_1',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': None,
'platform': 'incomfort',
'previous_unique_id': None,
'supported_features': <ClimateEntityFeature: 1>,
'translation_key': None,
'unique_id': 'c0ffeec0ffee_1',
'unit_of_measurement': None,
})
# ---
# name: test_setup_platform[legacy-zero_override][climate.thermostat_1-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'current_temperature': 21.4,
@ -65,7 +131,7 @@
'state': 'heat',
})
# ---
# name: test_setup_platform[new_thermostat][climate.thermostat_1-entry]
# name: test_setup_platform[modern-override][climate.thermostat_1-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
@ -104,7 +170,7 @@
'unit_of_measurement': None,
})
# ---
# name: test_setup_platform[new_thermostat][climate.thermostat_1-state]
# name: test_setup_platform[modern-override][climate.thermostat_1-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'current_temperature': 21.4,
@ -116,7 +182,73 @@
'max_temp': 30.0,
'min_temp': 5.0,
'status': dict({
'override': 18.0,
'override': 19.0,
'room_temp': 21.42,
'setpoint': 18.0,
}),
'supported_features': <ClimateEntityFeature: 1>,
'temperature': 19.0,
}),
'context': <ANY>,
'entity_id': 'climate.thermostat_1',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'heat',
})
# ---
# name: test_setup_platform[modern-zero_override][climate.thermostat_1-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'hvac_modes': list([
<HVACMode.HEAT: 'heat'>,
]),
'max_temp': 30.0,
'min_temp': 5.0,
}),
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'climate',
'entity_category': None,
'entity_id': 'climate.thermostat_1',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': None,
'platform': 'incomfort',
'previous_unique_id': None,
'supported_features': <ClimateEntityFeature: 1>,
'translation_key': None,
'unique_id': 'c0ffeec0ffee_1',
'unit_of_measurement': None,
})
# ---
# name: test_setup_platform[modern-zero_override][climate.thermostat_1-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'current_temperature': 21.4,
'friendly_name': 'Thermostat 1',
'hvac_action': <HVACAction.IDLE: 'idle'>,
'hvac_modes': list([
<HVACMode.HEAT: 'heat'>,
]),
'max_temp': 30.0,
'min_temp': 5.0,
'status': dict({
'override': 0.0,
'room_temp': 21.42,
'setpoint': 18.0,
}),

View File

@ -17,10 +17,15 @@ from tests.common import snapshot_platform
@pytest.mark.parametrize(
"mock_room_status",
[
{"room_temp": 21.42, "setpoint": 18.0, "override": 18.0},
{"room_temp": 21.42, "setpoint": 18.0, "override": 19.0},
{"room_temp": 21.42, "setpoint": 18.0, "override": 0.0},
],
ids=["new_thermostat", "legacy_thermostat"],
ids=["override", "zero_override"],
)
@pytest.mark.parametrize(
"mock_entry_options",
[None, {"legacy_setpoint_status": True}],
ids=["modern", "legacy"],
)
async def test_setup_platform(
hass: HomeAssistant,
@ -31,8 +36,9 @@ async def test_setup_platform(
) -> None:
"""Test the incomfort entities are set up correctly.
Legacy thermostats report 0.0 as override if no override is set,
but new thermostat sync the override with the actual setpoint instead.
Thermostats report 0.0 as override if no override is set
or when the setpoint has been changed manually,
Some older thermostats do not reset the override setpoint has been changed manually.
"""
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)

View File

@ -1,6 +1,7 @@
"""Tests for the Intergas InComfort config flow."""
from unittest.mock import AsyncMock, MagicMock
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch
from aiohttp import ClientResponseError
from incomfortclient import IncomfortError, InvalidHeaterList
@ -113,3 +114,39 @@ async def test_form_validation(
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert "errors" not in result
@pytest.mark.parametrize(
("user_input", "legacy_setpoint_status"),
[
({}, False),
({"legacy_setpoint_status": False}, False),
({"legacy_setpoint_status": True}, True),
],
)
async def test_options_flow(
hass: HomeAssistant,
mock_incomfort: MagicMock,
user_input: dict[str, Any],
legacy_setpoint_status: bool,
) -> None:
"""Test options flow."""
entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG)
entry.add_to_hass(hass)
await hass.config_entries.async_setup(entry.entry_id)
result = await hass.config_entries.options.async_init(entry.entry_id)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "init"
with patch("homeassistant.components.incomfort.async_setup_entry") as restart_mock:
result2 = await hass.config_entries.options.async_configure(
result["flow_id"], user_input
)
await hass.async_block_till_done(wait_background_tasks=True)
assert restart_mock.call_count == 1
assert result2["type"] is FlowResultType.CREATE_ENTRY
assert result2["data"] == {"legacy_setpoint_status": legacy_setpoint_status}
assert entry.options.get("legacy_setpoint_status", False) is legacy_setpoint_status