diff --git a/homeassistant/components/incomfort/__init__.py b/homeassistant/components/incomfort/__init__.py index 4b6a6a5fcc3..e6775f5baca 100644 --- a/homeassistant/components/incomfort/__init__.py +++ b/homeassistant/components/incomfort/__init__.py @@ -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)) diff --git a/homeassistant/components/incomfort/climate.py b/homeassistant/components/incomfort/climate.py index caebcfdb23b..545666a826f 100644 --- a/homeassistant/components/incomfort/climate.py +++ b/homeassistant/components/incomfort/climate.py @@ -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: diff --git a/homeassistant/components/incomfort/config_flow.py b/homeassistant/components/incomfort/config_flow.py index f4838a9771d..ffaee2a38a4 100644 --- a/homeassistant/components/incomfort/config_flow.py +++ b/homeassistant/components/incomfort/config_flow.py @@ -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, + ) diff --git a/homeassistant/components/incomfort/const.py b/homeassistant/components/incomfort/const.py index 721dd8591b0..b3b9312acd6 100644 --- a/homeassistant/components/incomfort/const.py +++ b/homeassistant/components/incomfort/const.py @@ -1,3 +1,5 @@ """Constants for Intergas InComfort integration.""" DOMAIN = "incomfort" + +CONF_LEGACY_SETPOINT_STATUS = "legacy_setpoint_status" diff --git a/homeassistant/components/incomfort/strings.json b/homeassistant/components/incomfort/strings.json index a2bb874142b..8687be19bb6 100644 --- a/homeassistant/components/incomfort/strings.json +++ b/homeassistant/components/incomfort/strings.json @@ -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", diff --git a/tests/components/incomfort/conftest.py b/tests/components/incomfort/conftest.py index b00e3a638c8..a6acd79764c 100644 --- a/tests/components/incomfort/conftest.py +++ b/tests/components/incomfort/conftest.py @@ -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 diff --git a/tests/components/incomfort/snapshots/test_climate.ambr b/tests/components/incomfort/snapshots/test_climate.ambr index 17adcbb3bab..bd940bbc2ce 100644 --- a/tests/components/incomfort/snapshots/test_climate.ambr +++ b/tests/components/incomfort/snapshots/test_climate.ambr @@ -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': , + 'hvac_modes': list([ + , + ]), + 'max_temp': 30.0, + 'min_temp': 5.0, + 'status': dict({ + 'override': 19.0, + 'room_temp': 21.42, + 'setpoint': 18.0, + }), + 'supported_features': , + 'temperature': 18.0, + }), + 'context': , + 'entity_id': 'climate.thermostat_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat', + }) +# --- +# name: test_setup_platform[legacy-zero_override][climate.thermostat_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + ]), + 'max_temp': 30.0, + 'min_temp': 5.0, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.thermostat_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'incomfort', + 'previous_unique_id': None, + 'supported_features': , + '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': , + 'temperature': 19.0, + }), + 'context': , + 'entity_id': 'climate.thermostat_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat', + }) +# --- +# name: test_setup_platform[modern-zero_override][climate.thermostat_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + ]), + 'max_temp': 30.0, + 'min_temp': 5.0, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.thermostat_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'incomfort', + 'previous_unique_id': None, + 'supported_features': , + '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': , + 'hvac_modes': list([ + , + ]), + 'max_temp': 30.0, + 'min_temp': 5.0, + 'status': dict({ + 'override': 0.0, 'room_temp': 21.42, 'setpoint': 18.0, }), diff --git a/tests/components/incomfort/test_climate.py b/tests/components/incomfort/test_climate.py index ae4c1cf31f7..06aa8fc056e 100644 --- a/tests/components/incomfort/test_climate.py +++ b/tests/components/incomfort/test_climate.py @@ -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) diff --git a/tests/components/incomfort/test_config_flow.py b/tests/components/incomfort/test_config_flow.py index 287fd85715f..ab24728874c 100644 --- a/tests/components/incomfort/test_config_flow.py +++ b/tests/components/incomfort/test_config_flow.py @@ -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