Repository: XKNX/xknx
Pull request #460 (2020-10-11 21:51:35)

Refactor climate modes in operation mode and controller mode.
<!--
  You are awesome! Thanks for contributing to our project!
  Please, DO NOT DELETE ANY TEXT from this template!.
-->
## Description
<!--
Please include a summary of the change and which issue is fixed.
Please also include relevant motivation and context.
List any dependencies that are required for this change.
-->

Fixes a bug for binary operation modes where AWAY would always be set initially no matter what value was sent.

Added "controller_modes" parameter to HA configuration.

Fixes https://github.com/home-assistant/core/issues/40768

## Type of change
<!--
Please tick the applicable options.
NOTE: Ticking multiple options most likely indicates
that your change is to big and it is suggested to split it into several smaller PRs.
-->

- [x] Bug fix (non-breaking change which fixes an issue)
- [ ] New feature (non-breaking change which adds functionality)
- [x] Breaking change (fix or feature that would cause existing functionality to not work as expected)
- [ ] This change requires a documentation update

## Checklist:

- [x] I have performed a self-review of my own code
- [x] The documentation has been adjusted accordingly
- [x] The changes generate no new warnings
- [x] Tests have been added that prove the fix is effective or that the feature works
- [x] The changes are documented in the changelog
- [x] The Homeassistant plugin has been adjusted in case of new config options



Patch:
diff --git a/changelog.md b/changelog.md
index cbbf8cb2..97c850a5 100644
--- a/changelog.md
+++ b/changelog.md
@@ -2,6 +2,10 @@
 
 ## Unreleased changes
 
+### Devices
+
+- ClimateMode: Refactor climate modes in operation_mode and controller_mode, also fixes a bug for binary operation modes where the mode would be set to AWAY no matter what value was sent to the bus.
+
 ### Internals
 
 - StateUpdater: Only request one GA at a time.
diff --git a/docs/climate.md b/docs/climate.md
index c8ab401c..74820746 100644
--- a/docs/climate.md
+++ b/docs/climate.md
@@ -89,6 +89,7 @@ groups:
 * **group_address_heat_cool** KNX address for switching heating / cooling mode. DPT 1
 * **group_address_heat_cool_state** KNX address for reading heating / cooling mode. DPT 1
 * **operation_modes** Overrides the supported operation modes.
+* **controller_modes** Overrides the supported controller modes.
 
 **Note:** `group_address_operation_mode_protection` / `group_address_operation_mode_night` / `group_address_operation_mode_comfort` / `group_address_operation_mode_standby` are not necessary if `group_address_operation_mode` was specified. When one of these is set `True`, the others will be set `False`. When one of these is set "Standby", "Comfort", "Frost_Protection" and "Night" will be set as supported. If `group_address_operation_mode_standby` is omitted, "Standby" is set when the other 3 are set to `False`.
 If only a subset of operation modes shall be used a list of supported modes may be passed to `operation_modes`.
@@ -106,6 +107,7 @@ climate_mode = ClimateMode(xknx,
                  group_address_controller_mode=None,
                  group_address_controller_mode_state=None,
                  operation_modes=None,
+                 controller_modes=None,
                  device_updated_cb=None)
 
 climate = Climate(
diff --git a/home-assistant-plugin/custom_components/xknx/climate.py b/home-assistant-plugin/custom_components/xknx/climate.py
index 1960627a..5d530faf 100644
--- a/home-assistant-plugin/custom_components/xknx/climate.py
+++ b/home-assistant-plugin/custom_components/xknx/climate.py
@@ -2,7 +2,7 @@
 from typing import List, Optional
 
 from xknx.devices import Climate as XknxClimate
-from xknx.dpt import HVACOperationMode
+from xknx.dpt import HVACControllerMode, HVACOperationMode
 
 from homeassistant.components.climate import ClimateEntity
 from homeassistant.components.climate.const import (
@@ -14,10 +14,10 @@ from homeassistant.components.climate.const import (
 )
 from homeassistant.const import ATTR_TEMPERATURE, TEMP_CELSIUS
 
-from .const import DOMAIN, OPERATION_MODES, PRESET_MODES
+from .const import CONTROLLER_MODES, DOMAIN, PRESET_MODES
 from .knx_entity import KnxEntity
 
-OPERATION_MODES_INV = dict(reversed(item) for item in OPERATION_MODES.items())
+CONTROLLER_MODES_INV = dict(reversed(item) for item in CONTROLLER_MODES.items())
 PRESET_MODES_INV = dict(reversed(item) for item in PRESET_MODES.items())
 
 
@@ -92,27 +92,27 @@ class KNXClimate(KnxEntity, ClimateEntity):
         """Return current operation ie. heat, cool, idle."""
         if self._device.supports_on_off and not self._device.is_on:
             return HVAC_MODE_OFF
-        if self._device.mode.supports_operation_mode:
-            return OPERATION_MODES.get(
-                self._device.mode.operation_mode.value, HVAC_MODE_HEAT
+        if self._device.mode.supports_controller_mode:
+            return CONTROLLER_MODES.get(
+                self._device.mode.controller_mode.value, HVAC_MODE_HEAT
             )
         # default to "heat"
         return HVAC_MODE_HEAT
 
     @property
     def hvac_modes(self) -> Optional[List[str]]:
-        """Return the list of available operation modes."""
-        _operations = [
-            OPERATION_MODES.get(operation_mode.value)
-            for operation_mode in self._device.mode.operation_modes
+        """Return the list of available operation/controller modes."""
+        _controller_modes = [
+            CONTROLLER_MODES.get(controller_mode.value)
+            for controller_mode in self._device.mode.controller_modes
         ]
 
         if self._device.supports_on_off:
-            if not _operations:
-                _operations.append(HVAC_MODE_HEAT)
-            _operations.append(HVAC_MODE_OFF)
+            if not _controller_modes:
+                _controller_modes.append(HVAC_MODE_HEAT)
+            _controller_modes.append(HVAC_MODE_OFF)
 
-        _modes = list(set(filter(None, _operations)))
+        _modes = list(set(filter(None, _controller_modes)))
         # default to ["heat"]
         return _modes if _modes else [HVAC_MODE_HEAT]
 
@@ -123,11 +123,11 @@ class KNXClimate(KnxEntity, ClimateEntity):
         else:
             if self._device.supports_on_off and not self._device.is_on:
                 await self._device.turn_on()
-            if self._device.mode.supports_operation_mode:
-                knx_operation_mode = HVACOperationMode(
-                    OPERATION_MODES_INV.get(hvac_mode)
+            if self._device.mode.supports_controller_mode:
+                knx_controller_mode = HVACControllerMode(
+                    CONTROLLER_MODES_INV.get(hvac_mode)
                 )
-                await self._device.mode.set_operation_mode(knx_operation_mode)
+                await self._device.mode.set_controller_mode(knx_controller_mode)
         self.async_write_ha_state()
 
     @property
diff --git a/home-assistant-plugin/custom_components/xknx/const.py b/home-assistant-plugin/custom_components/xknx/const.py
index b5bdd447..457694be 100644
--- a/home-assistant-plugin/custom_components/xknx/const.py
+++ b/home-assistant-plugin/custom_components/xknx/const.py
@@ -11,6 +11,7 @@ from homeassistant.components.climate.const import (
     PRESET_AWAY,
     PRESET_COMFORT,
     PRESET_ECO,
+    PRESET_NONE,
     PRESET_SLEEP,
 )
 
@@ -42,8 +43,8 @@ class SupportedPlatforms(Enum):
     weather = "weather"
 
 
-# Map KNX operation modes to HA modes. This list might not be complete.
-OPERATION_MODES = {
+# Map KNX controller modes to HA modes. This list might not be complete.
+CONTROLLER_MODES = {
     # Map DPT 20.105 HVAC control modes
     "Auto": HVAC_MODE_AUTO,
     "Heat": HVAC_MODE_HEAT,
@@ -55,6 +56,7 @@ OPERATION_MODES = {
 
 PRESET_MODES = {
     # Map DPT 20.102 HVAC operating modes to HA presets
+    "Auto": PRESET_NONE,
     "Frost Protection": PRESET_ECO,
     "Night": PRESET_SLEEP,
     "Standby": PRESET_AWAY,
diff --git a/home-assistant-plugin/custom_components/xknx/factory.py b/home-assistant-plugin/custom_components/xknx/factory.py
index 9e721817..de552ff2 100644
--- a/home-assistant-plugin/custom_components/xknx/factory.py
+++ b/home-assistant-plugin/custom_components/xknx/factory.py
@@ -164,6 +164,7 @@ def _create_climate(knx_module: XKNX, config: ConfigType) -> XknxClimate:
             ClimateSchema.CONF_HEAT_COOL_STATE_ADDRESS
         ),
         operation_modes=config.get(ClimateSchema.CONF_OPERATION_MODES),
+        controller_modes=config.get(ClimateSchema.CONF_CONTROLLER_MODES),
     )
 
     return XknxClimate(
diff --git a/home-assistant-plugin/custom_components/xknx/schema.py b/home-assistant-plugin/custom_components/xknx/schema.py
index ce6ae759..9c628bed 100644
--- a/home-assistant-plugin/custom_components/xknx/schema.py
+++ b/home-assistant-plugin/custom_components/xknx/schema.py
@@ -18,7 +18,7 @@ from .const import (
     CONF_RESET_AFTER,
     CONF_STATE_ADDRESS,
     CONF_SYNC_STATE,
-    OPERATION_MODES,
+    CONTROLLER_MODES,
     PRESET_MODES,
     ColorTempModes,
 )
@@ -188,6 +188,7 @@ class ClimateSchema:
     CONF_OPERATION_MODE_COMFORT_ADDRESS = "operation_mode_comfort_address"
     CONF_OPERATION_MODE_STANDBY_ADDRESS = "operation_mode_standby_address"
     CONF_OPERATION_MODES = "operation_modes"
+    CONF_CONTROLLER_MODES = "controller_modes"
     CONF_ON_OFF_ADDRESS = "on_off_address"
     CONF_ON_OFF_STATE_ADDRESS = "on_off_state_address"
     CONF_ON_OFF_INVERT = "on_off_invert"
@@ -241,7 +242,10 @@ class ClimateSchema:
                     CONF_ON_OFF_INVERT, default=DEFAULT_ON_OFF_INVERT
                 ): cv.boolean,
                 vol.Optional(CONF_OPERATION_MODES): vol.All(
-                    cv.ensure_list, [vol.In({**OPERATION_MODES, **PRESET_MODES})]
+                    cv.ensure_list, [vol.In({**PRESET_MODES})]
+                ),
+                vol.Optional(CONF_CONTROLLER_MODES): vol.All(
+                    cv.ensure_list, [vol.In({**CONTROLLER_MODES})]
                 ),
                 vol.Optional(CONF_MIN_TEMP): vol.Coerce(float),
                 vol.Optional(CONF_MAX_TEMP): vol.Coerce(float),
diff --git a/xknx/devices/climate_mode.py b/xknx/devices/climate_mode.py
index 23b9d959..86062ce2 100644
--- a/xknx/devices/climate_mode.py
+++ b/xknx/devices/climate_mode.py
@@ -1,9 +1,12 @@
 """
-Module for managing the climate mode.
+Module for managing operation and controller modes.
 
-Climate modes can be 'auto', 'comfort', 'standby', 'economy' or 'protection'.
+Operation modes can be 'auto', 'comfort', 'standby', 'economy', 'protection' and use either a binary DPT or DPT 20.102.
+Controller modes use DPT 20.105.
 """
-from xknx.dpt import HVACOperationMode
+from itertools import chain
+
+from xknx.dpt import HVACControllerMode, HVACOperationMode
 from xknx.exceptions import DeviceIllegalValue
 from xknx.remote_value import (
     RemoteValueBinaryHeatCool,
@@ -36,6 +39,7 @@ class ClimateMode(Device):
         group_address_heat_cool=None,
         group_address_heat_cool_state=None,
         operation_modes=None,
+        controller_modes=None,
         device_updated_cb=None,
     ):
         """Initialize ClimateMode class."""
@@ -120,35 +124,48 @@ class ClimateMode(Device):
             sync_state=True,
             device_name=name,
             feature_name="Heat/Cool",
-            operation_mode=HVACOperationMode.HEAT,
+            controller_mode=HVACControllerMode.HEAT,
             after_update_cb=None,
         )
 
         self.operation_mode = HVACOperationMode.STANDBY
+        self.controller_mode = HVACControllerMode.HEAT
 
-        self.operation_modes_ = []
+        self._operation_modes = []
         if operation_modes is None:
-            self.operation_modes_ = self.gather_operation_modes()
+            self._operation_modes = self.gather_operation_modes()
         else:
             for mode in operation_modes:
                 if isinstance(mode, str):
-                    self.operation_modes_.append(HVACOperationMode(mode))
+                    self._operation_modes.append(HVACOperationMode(mode))
                 elif isinstance(mode, HVACOperationMode):
-                    self.operation_modes_.append(mode)
-
-        self.supports_operation_mode = (
-            group_address_operation_mode is not None
-            or group_address_operation_mode_state is not None
-            or group_address_operation_mode_protection is not None
-            or group_address_operation_mode_night is not None
-            or group_address_operation_mode_comfort is not None
-            or group_address_operation_mode_standby is not None
-            or group_address_controller_status is not None
-            or group_address_controller_status_state is not None
-            or group_address_controller_mode is not None
-            or group_address_controller_mode_state is not None
-            or group_address_heat_cool is not None
-            or group_address_heat_cool_state is not None
+                    self._operation_modes.append(mode)
+
+        self._controller_modes = []
+        if controller_modes is None:
+            self._controller_modes = self.gather_controller_modes()
+        else:
+            for mode in controller_modes:
+                if isinstance(mode, str):
+                    self._controller_modes.append(HVACControllerMode(mode))
+                elif isinstance(mode, HVACControllerMode):
+                    self._controller_modes.append(mode)
+
+        self.supports_operation_mode = any(
+            operation_mode.initialized
+            for operation_mode in self._iter_byte_operation_modes()
+        ) or any(
+            operation_mode.initialized
+            for operation_mode in self._iter_binary_operation_modes()
+        )
+        self.supports_controller_mode = any(
+            operation_mode.initialized
+            for operation_mode in self._iter_controller_remote_values()
+        )
+
+        self._use_binary_operation_modes = any(
+            operation_mode.initialized
+            for operation_mode in self._iter_binary_operation_modes()
         )
 
     @classmethod
@@ -201,15 +218,33 @@ class ClimateMode(Device):
 
     def _iter_remote_values(self):
         """Iterate climate mode RemoteValue classes."""
+        return chain(
+            self._iter_byte_operation_modes(),
+            self._iter_controller_remote_values(),
+            self._iter_binary_operation_modes(),
+        )
+
+    def _iter_byte_operation_modes(self):
+        """Iterate normal DPT 20.102 operation mode remote values."""
         yield from (
-            self.remote_value_controller_mode,
-            self.remote_value_controller_status,
             self.remote_value_operation_mode,
+            self.remote_value_controller_status,
+        )
+
+    def _iter_controller_remote_values(self):
+        """Iterate DPT 20.105 controller remote values."""
+        yield from (
+            self.remote_value_controller_mode,
+            self.remote_value_heat_cool,
+        )
+
+    def _iter_binary_operation_modes(self):
+        """Iterate DPT 1 binary operation modes."""
+        yield from (
             self.remote_value_operation_mode_comfort,
             self.remote_value_operation_mode_night,
             self.remote_value_operation_mode_protection,
             self.remote_value_operation_mode_standby,
-            self.remote_value_heat_cool,
         )
 
     async def _set_internal_operation_mode(self, operation_mode):
@@ -218,56 +253,97 @@ class ClimateMode(Device):
             self.operation_mode = operation_mode
             await self.after_update()
 
+    async def _set_internal_controller_mode(self, controller_mode):
+        """Set internal value of controller mode. Call hooks if controller mode was changed."""
+        if controller_mode != self.controller_mode:
+            self.controller_mode = controller_mode
+            await self.after_update()
+
     async def set_operation_mode(self, operation_mode):
         """Set the operation mode of a thermostat. Send new operation_mode to BUS and update internal state."""
         if (
             not self.supports_operation_mode
-            or operation_mode not in self.operation_modes_
+            or operation_mode not in self._operation_modes
         ):
-            raise DeviceIllegalValue("operation mode not supported", operation_mode)
+            raise DeviceIllegalValue(
+                "operation (preset) mode not supported", operation_mode
+            )
 
-        for rv in self._iter_remote_values():
+        for rv in chain(
+            self._iter_byte_operation_modes(), self._iter_binary_operation_modes()
+        ):
             if rv.writable and operation_mode in rv.supported_operation_modes():
                 await rv.set(operation_mode)
 
         await self._set_internal_operation_mode(operation_mode)
 
+    async def set_controller_mode(self, controller_mode):
+        """Set the controller mode of a thermostat. Send new controller mode to the bus and update internal state."""
+        if (
+            not self.supports_controller_mode
+            or controller_mode not in self._controller_modes
+        ):
+            raise DeviceIllegalValue(
+                "controller (HVAC) mode not supported", controller_mode
+            )
+
+        for rv in self._iter_controller_remote_values():
+            if rv.writable and controller_mode in rv.supported_operation_modes():
+                await rv.set(controller_mode)
+
+        await self._set_internal_controller_mode(controller_mode)
+
     @property
     def operation_modes(self):
         """Return all configured operation modes."""
         if not self.supports_operation_mode:
             return []
-        return self.operation_modes_
+        return self._operation_modes
+
+    @property
+    def controller_modes(self):
+        """Return all configured controller modes."""
+        if not self.supports_controller_mode:
+            return []
+        return self._controller_modes
 
     def gather_operation_modes(self):
         """Gather operation modes from RemoteValues."""
         operation_modes = []
-        for rv in self._iter_remote_values():
+        for rv in chain(
+            self._iter_binary_operation_modes(), self._iter_byte_operation_modes()
+        ):
             if rv.writable:
                 operation_modes.extend(rv.supported_operation_modes())
+
+        # remove duplicates
+        return list(set(operation_modes))
+
+    def gather_controller_modes(self):
+        """Gather controller modes from RemoteValues."""
+        operation_modes = []
+        for rv in self._iter_controller_remote_values():
+            if rv.writable:
+                operation_modes.extend(rv.supported_operation_modes())
+
         # remove duplicates
         return list(set(operation_modes))
 
     async def process_group_write(self, telegram):
         """Process incoming and outgoing GROUP WRITE telegram."""
         if self.supports_operation_mode:
-            processed = False
             for rv in self._iter_remote_values():
                 if await rv.process(telegram):
-                    # don't set when binary climate mode rv is False
+                    #  ignore inactive RemoteValueBinaryOperationMode
                     if rv.value:
                         await self._set_internal_operation_mode(rv.value)
                         return
-                    processed = True
-            # if no operation mode has been set and all binary operation modes are False
-            if processed:
-                await self._set_internal_operation_mode(HVACOperationMode.STANDBY)
 
-    async def sync(self, wait_for_result=False):
-        """Read states of device from KNX bus."""
-        if self.supports_operation_mode:
-            for rv in self._iter_remote_values():
-                await rv.read_state(wait_for_result=wait_for_result)
+        if self.supports_controller_mode:
+            for rv in self._iter_controller_remote_values():
+                if await rv.process(telegram):
+                    await self._set_internal_controller_mode(rv.value)
+                    return
 
     def __str__(self):
         """Return object as readable string."""
diff --git a/xknx/dpt/__init__.py b/xknx/dpt/__init__.py
index 8dc38991..b5bfff82 100644
--- a/xknx/dpt/__init__.py
+++ b/xknx/dpt/__init__.py
@@ -164,6 +164,7 @@ from .dpt_hvac_mode import (
     DPTControllerStatus,
     DPTHVACContrMode,
     DPTHVACMode,
+    HVACControllerMode,
     HVACOperationMode,
 )
 from .dpt_scaling import DPTAngle, DPTScaling
diff --git a/xknx/dpt/dpt_hvac_mode.py b/xknx/dpt/dpt_hvac_mode.py
index c23c16c4..1b0052c9 100644
--- a/xknx/dpt/dpt_hvac_mode.py
+++ b/xknx/dpt/dpt_hvac_mode.py
@@ -15,6 +15,12 @@ class HVACOperationMode(Enum):
     STANDBY = "Standby"
     NIGHT = "Night"
     FROST_PROTECTION = "Frost Protection"
+
+
+class HVACControllerMode(Enum):
+    """Enum for the different KNX HVAC controller modes."""
+
+    AUTO = "Auto"
     HEAT = "Heat"
     MORNING_WARMUP = "Morning Warmup"
     COOL = "Cool"
@@ -62,20 +68,20 @@ class DPTHVACContrMode(_DPTClimateMode):
     """
 
     SUPPORTED_MODES = {
-        0: HVACOperationMode.AUTO,
-        1: HVACOperationMode.HEAT,
-        2: HVACOperationMode.MORNING_WARMUP,
-        3: HVACOperationMode.COOL,
-        4: HVACOperationMode.NIGHT_PURGE,
-        5: HVACOperationMode.PRECOOL,
-        6: HVACOperationMode.OFF,
-        7: HVACOperationMode.TEST,
-        8: HVACOperationMode.EMERGENCY_HEAT,
-        9: HVACOperationMode.FAN_ONLY,
-        10: HVACOperationMode.FREE_COOL,
-        11: HVACOperationMode.ICE,
-        14: HVACOperationMode.DRY,
-        20: HVACOperationMode.NODEM,
+        0: HVACControllerMode.AUTO,
+        1: HVACControllerMode.HEAT,
+        2: HVACControllerMode.MORNING_WARMUP,
+        3: HVACControllerMode.COOL,
+        4: HVACControllerMode.NIGHT_PURGE,
+        5: HVACControllerMode.PRECOOL,
+        6: HVACControllerMode.OFF,
+        7: HVACControllerMode.TEST,
+        8: HVACControllerMode.EMERGENCY_HEAT,
+        9: HVACControllerMode.FAN_ONLY,
+        10: HVACControllerMode.FREE_COOL,
+        11: HVACControllerMode.ICE,
+        14: HVACControllerMode.DRY,
+        20: HVACControllerMode.NODEM,
     }
 
 
diff --git a/xknx/remote_value/remote_value_climate_mode.py b/xknx/remote_value/remote_value_climate_mode.py
index 751615b1..6b9868c3 100644
--- a/xknx/remote_value/remote_value_climate_mode.py
+++ b/xknx/remote_value/remote_value_climate_mode.py
@@ -16,6 +16,7 @@ from xknx.dpt import (
 )
 from xknx.exceptions import ConversionError, CouldNotParseTelegram
 
+from ..dpt.dpt_hvac_mode import HVACControllerMode
 from .remote_value import RemoteValue
 
 
@@ -169,25 +170,79 @@ class RemoteValueBinaryOperationMode(_RemoteValueBinaryClimateMode):
         )
 
 
-class RemoteValueBinaryHeatCool(_RemoteValueBinaryClimateMode):
+class RemoteValueBinaryHeatCool(RemoteValue):
     """Abstraction for remote value of heat/cool controller mode."""
 
+    def __init__(
+        self,
+        xknx,
+        group_address=None,
+        group_address_state=None,
+        sync_state=True,
+        device_name=None,
+        feature_name="Controller Mode Heat/Cool",
+        after_update_cb=None,
+        controller_mode=None,
+    ):
+        """Initialize remote value of KNX DPT 1 representing a climate controller mode."""
+        # pylint: disable=too-many-arguments
+        if not isinstance(controller_mode, HVACControllerMode):
+            raise ConversionError(
+                "Invalid controller mode type",
+                controller_mode=controller_mode,
+                device_name=device_name,
+                feature_name=feature_name,
+            )
+        if controller_mode not in self.supported_operation_modes():
+            raise ConversionError(
+                "Controller mode not supported for binary mode object",
+                controller_mode=controller_mode,
+                device_name=device_name,
+                feature_name=feature_name,
+            )
+        self.controller_mode = controller_mode
+        super().__init__(
+            xknx,
+            group_address=group_address,
+            group_address_state=group_address_state,
+            sync_state=True,
+            device_name=device_name,
+            feature_name=feature_name,
+            after_update_cb=after_update_cb,
+        )
+
+    def payload_valid(self, payload):
+        """Test if telegram payload may be parsed."""
+        return isinstance(payload, DPTBinary)
+
     @staticmethod
     def supported_operation_modes():
         """Return a list of the configured operation mode."""
-        return [HVACOperationMode.HEAT, HVACOperationMode.COOL]
+        return [HVACControllerMode.HEAT, HVACControllerMode.COOL]
+
+    def to_knx(self, value):
+        """Convert value to payload."""
+        if isinstance(value, HVACControllerMode):
+            # foreign operation modes will set the RemoteValue to False
+            return DPTBinary(value == self.controller_mode)
+        raise ConversionError(
+            "value invalid",
+            value=value,
+            device_name=self.device_name,
+            feature_name=self.feature_name,
+        )
 
     def from_knx(self, payload):
         """Convert current payload to value."""
         if payload == DPTBinary(1):
-            return self.operation_mode
+            return self.controller_mode
         if payload == DPTBinary(0):
             # return the other operation mode
             return next(
                 (
                     _op
                     for _op in self.supported_operation_modes()
-                    if _op is not self.operation_mode
+                    if _op is not self.controller_mode
                 ),
                 None,
             )


Test patch:
diff --git a/test/devices_tests/climate_test.py b/test/devices_tests/climate_test.py
index 5435cb15..d9fb3e30 100644
--- a/test/devices_tests/climate_test.py
+++ b/test/devices_tests/climate_test.py
@@ -17,6 +17,7 @@ from xknx.dpt import (
     DPTValue1Count,
     HVACOperationMode,
 )
+from xknx.dpt.dpt_hvac_mode import HVACControllerMode
 from xknx.exceptions import CouldNotParseTelegram, DeviceIllegalValue
 from xknx.telegram import GroupAddress, Telegram, TelegramType
 
@@ -308,9 +309,9 @@ class TestClimate(unittest.TestCase):
             xknx, "TestClimate", group_address_controller_mode="1/2/4"
         )
 
-        for _, operation_mode in DPTHVACContrMode.SUPPORTED_MODES.items():
+        for _, controller_mode in DPTHVACContrMode.SUPPORTED_MODES.items():
             self.loop.run_until_complete(
-                climate_mode.set_operation_mode(operation_mode)
+                climate_mode.set_controller_mode(controller_mode)
             )
 
             self.assertEqual(xknx.telegrams.qsize(), 1)
@@ -319,7 +320,7 @@ class TestClimate(unittest.TestCase):
                 telegram,
                 Telegram(
                     GroupAddress("1/2/4"),
-                    payload=DPTArray(DPTHVACContrMode.to_knx(operation_mode)),
+                    payload=DPTArray(DPTHVACContrMode.to_knx(controller_mode)),
                 ),
             )
 
@@ -332,6 +333,15 @@ class TestClimate(unittest.TestCase):
                 climate_mode.set_operation_mode(HVACOperationMode.AUTO)
             )
 
+    def test_set_controller_mode_not_supported(self):
+        """Test set_controller_mode but not supported."""
+        xknx = XKNX()
+        climate_mode = ClimateMode(xknx, "TestClimate")
+        with self.assertRaises(DeviceIllegalValue):
+            self.loop.run_until_complete(
+                climate_mode.set_controller_mode(HVACControllerMode.HEAT)
+            )
+
     def test_set_operation_mode_with_controller_status(self):
         """Test set_operation_mode with controller status adddressedefined."""
         xknx = XKNX()
@@ -387,7 +397,7 @@ class TestClimate(unittest.TestCase):
         self.assertSetEqual(set(telegrams), set(test_telegrams))
 
     def test_set_heat_cool_binary(self):
-        """Test set_operation_mode with binary heat/cool group adddresses defined."""
+        """Test set_operation_mode with binary heat/cool group addresses defined."""
         xknx = XKNX()
         climate_mode = ClimateMode(
             xknx,
@@ -397,7 +407,7 @@ class TestClimate(unittest.TestCase):
         )
 
         self.loop.run_until_complete(
-            climate_mode.set_operation_mode(HVACOperationMode.HEAT)
+            climate_mode.set_controller_mode(HVACControllerMode.HEAT)
         )
         self.assertEqual(xknx.telegrams.qsize(), 1)
         telegram = xknx.telegrams.get_nowait()
@@ -405,7 +415,7 @@ class TestClimate(unittest.TestCase):
             telegram, Telegram(GroupAddress("1/2/14"), payload=DPTBinary(True))
         )
         self.loop.run_until_complete(
-            climate_mode.set_operation_mode(HVACOperationMode.COOL)
+            climate_mode.set_controller_mode(HVACControllerMode.COOL)
         )
         self.assertEqual(xknx.telegrams.qsize(), 1)
         telegram = xknx.telegrams.get_nowait()
@@ -1012,70 +1022,17 @@ class TestClimate(unittest.TestCase):
             self.loop.run_until_complete(climate_mode.process(telegram))
             self.assertEqual(climate_mode.operation_mode, operation_mode)
 
-    def test_process_binary_operation_mode(self):
-        """Test process / reading telegrams from telegram queue. Test binary operation mode is processed correctly."""
-        xknx = XKNX()
-        climate_mode = ClimateMode(
-            xknx,
-            "TestClimate",
-            group_address_operation_mode_protection="1/2/5",
-            group_address_operation_mode_night="1/2/6",
-            group_address_operation_mode_comfort="1/2/7",
-        )
-        # Note: disabling of inactive binary modes should be done by the
-        #       KNX actuator when a new mode is set on the bus.
-        self.assertIsNone(climate_mode.remote_value_operation_mode_protection.payload)
-        self.assertIsNone(climate_mode.remote_value_operation_mode_night.payload)
-        self.assertIsNone(climate_mode.remote_value_operation_mode_comfort.payload)
-        # Protection
-        telegram = Telegram(GroupAddress("1/2/5"))
-        telegram.payload = DPTBinary(True)
-        self.loop.run_until_complete(climate_mode.process(telegram))
-        self.assertEqual(
-            climate_mode.operation_mode, HVACOperationMode.FROST_PROTECTION
-        )
-        # disable Protection -> Standby
-        telegram = Telegram(GroupAddress("1/2/5"))
-        telegram.payload = DPTBinary(False)
-        self.loop.run_until_complete(climate_mode.process(telegram))
-        self.assertEqual(climate_mode.operation_mode, HVACOperationMode.STANDBY)
-        # Night
-        telegram = Telegram(GroupAddress("1/2/6"))
-        telegram.payload = DPTBinary(True)
-        self.loop.run_until_complete(climate_mode.process(telegram))
-        self.assertEqual(climate_mode.operation_mode, HVACOperationMode.NIGHT)
-        # disable Night -> Standby
-        telegram = Telegram(GroupAddress("1/2/6"))
-        telegram.payload = DPTBinary(False)
-        self.loop.run_until_complete(climate_mode.process(telegram))
-        self.assertEqual(climate_mode.operation_mode, HVACOperationMode.STANDBY)
-        # Comfort
-        telegram = Telegram(GroupAddress("1/2/7"))
-        telegram.payload = DPTBinary(True)
-        self.loop.run_until_complete(climate_mode.process(telegram))
-        self.assertEqual(climate_mode.operation_mode, HVACOperationMode.COMFORT)
-        # process unused group address -> no reaction
-        telegram = Telegram(GroupAddress("0/0/1"))
-        telegram.payload = DPTBinary(True)
-        self.loop.run_until_complete(climate_mode.process(telegram))
-        self.assertEqual(climate_mode.operation_mode, HVACOperationMode.COMFORT)
-        # disable Comfort -> Standby
-        telegram = Telegram(GroupAddress("1/2/7"))
-        telegram.payload = DPTBinary(False)
-        self.loop.run_until_complete(climate_mode.process(telegram))
-        self.assertEqual(climate_mode.operation_mode, HVACOperationMode.STANDBY)
-
     def test_process_controller_mode(self):
         """Test process / reading telegrams from telegram queue. Test if DPT20.105 controller mode is set correctly."""
         xknx = XKNX()
         climate_mode = ClimateMode(
             xknx, "TestClimate", group_address_controller_mode="1/2/5"
         )
-        for _, operation_mode in DPTHVACContrMode.SUPPORTED_MODES.items():
+        for _, controller_mode in DPTHVACContrMode.SUPPORTED_MODES.items():
             telegram = Telegram(GroupAddress("1/2/5"))
-            telegram.payload = DPTArray(DPTHVACContrMode.to_knx(operation_mode))
+            telegram.payload = DPTArray(DPTHVACContrMode.to_knx(controller_mode))
             self.loop.run_until_complete(climate_mode.process(telegram))
-            self.assertEqual(climate_mode.operation_mode, operation_mode)
+            self.assertEqual(climate_mode.controller_mode, controller_mode)
 
     def test_process_controller_status_wrong_payload(self):
         """Test process wrong telegram for controller status (wrong payload type)."""
@@ -1161,12 +1118,12 @@ class TestClimate(unittest.TestCase):
         telegram = Telegram(GroupAddress("1/2/14"))
         telegram.payload = DPTBinary(False)
         self.loop.run_until_complete(climate_mode.process(telegram))
-        self.assertEqual(climate_mode.operation_mode, HVACOperationMode.COOL)
+        self.assertEqual(climate_mode.controller_mode, HVACControllerMode.COOL)
 
         telegram = Telegram(GroupAddress("1/2/14"))
         telegram.payload = DPTBinary(True)
         self.loop.run_until_complete(climate_mode.process(telegram))
-        self.assertEqual(climate_mode.operation_mode, HVACOperationMode.HEAT)
+        self.assertEqual(climate_mode.controller_mode, HVACControllerMode.HEAT)
 
     #
     # SUPPORTED OPERATION MODES
@@ -1258,8 +1215,8 @@ class TestClimate(unittest.TestCase):
             group_address_heat_cool_state="1/2/15",
         )
         self.assertEqual(
-            set(climate_mode.operation_modes),
-            {HVACOperationMode.HEAT, HVACOperationMode.COOL},
+            set(climate_mode.controller_modes),
+            {HVACControllerMode.HEAT, HVACControllerMode.COOL},
         )
 
     def test_custom_supported_operation_modes(self):
@@ -1287,6 +1244,35 @@ class TestClimate(unittest.TestCase):
         )
         self.assertEqual(climate_mode.operation_modes, modes)
 
+    def test_custom_supported_controller_modes_as_str(self):
+        """Test get_supported_operation_modes with custom mode as str list."""
+        str_modes = ["Heat", "Cool", HVACControllerMode.NODEM]
+        modes = [
+            HVACControllerMode.HEAT,
+            HVACControllerMode.COOL,
+            HVACControllerMode.NODEM,
+        ]
+        xknx = XKNX()
+        climate_mode = ClimateMode(
+            xknx,
+            "TestClimate",
+            group_address_controller_mode="1/2/7",
+            controller_modes=str_modes,
+        )
+        self.assertEqual(climate_mode.controller_modes, modes)
+
+    def test_custom_supported_controller_modes_when_controller_mode_unsupported(self):
+        """Test get_supported_operation_modes with custom mode as str list."""
+        str_modes = ["Heat", "Cool"]
+        modes = []
+        xknx = XKNX()
+        climate_mode = ClimateMode(
+            xknx,
+            "TestClimate",
+            controller_modes=str_modes,
+        )
+        self.assertEqual(climate_mode.controller_modes, modes)
+
     def test_process_power_status(self):
         """Test process / reading telegrams from telegram queue. Test if DPT20.105 controller mode is set correctly."""
         xknx = XKNX()
diff --git a/test/remote_value_tests/remote_value_climate_mode_test.py b/test/remote_value_tests/remote_value_climate_mode_test.py
index 57fdae6c..923130ac 100644
--- a/test/remote_value_tests/remote_value_climate_mode_test.py
+++ b/test/remote_value_tests/remote_value_climate_mode_test.py
@@ -4,6 +4,7 @@ import unittest
 
 from xknx import XKNX
 from xknx.dpt import DPTArray, DPTBinary, HVACOperationMode
+from xknx.dpt.dpt_hvac_mode import HVACControllerMode
 from xknx.exceptions import ConversionError, CouldNotParseTelegram
 from xknx.remote_value import (
     RemoteValueBinaryHeatCool,
@@ -60,10 +61,19 @@ class TestRemoteValueDptValue1Ucount(unittest.TestCase):
         """Test to_knx function with normal operation."""
         xknx = XKNX()
         remote_value = RemoteValueBinaryHeatCool(
-            xknx, operation_mode=HVACOperationMode.HEAT
+            xknx, controller_mode=HVACControllerMode.HEAT
         )
-        self.assertEqual(remote_value.to_knx(HVACOperationMode.HEAT), DPTBinary(True))
-        self.assertEqual(remote_value.to_knx(HVACOperationMode.COOL), DPTBinary(False))
+        self.assertEqual(remote_value.to_knx(HVACControllerMode.HEAT), DPTBinary(True))
+        self.assertEqual(remote_value.to_knx(HVACControllerMode.COOL), DPTBinary(False))
+
+    def test_to_knx_heat_cool_error(self):
+        """Test to_knx function with wrong controller mode."""
+        xknx = XKNX()
+        remote_value = RemoteValueBinaryHeatCool(
+            xknx, controller_mode=HVACControllerMode.HEAT
+        )
+        with self.assertRaises(ConversionError):
+            remote_value.to_knx(HVACOperationMode.STANDBY)
 
     def test_from_knx_operation_mode(self):
         """Test from_knx function with normal operation."""
@@ -79,7 +89,7 @@ class TestRemoteValueDptValue1Ucount(unittest.TestCase):
         """Test from_knx function with invalid payload."""
         xknx = XKNX()
         remote_value = RemoteValueBinaryHeatCool(
-            xknx, operation_mode=HVACOperationMode.HEAT
+            xknx, controller_mode=HVACControllerMode.HEAT
         )
         with self.assertRaises(CouldNotParseTelegram):
             remote_value.from_knx(DPTArray((0x9, 0xF)))
@@ -105,24 +115,26 @@ class TestRemoteValueDptValue1Ucount(unittest.TestCase):
         """Test from_knx function with normal operation."""
         xknx = XKNX()
         remote_value = RemoteValueBinaryHeatCool(
-            xknx, operation_mode=HVACOperationMode.HEAT
+            xknx, controller_mode=HVACControllerMode.HEAT
+        )
+        self.assertEqual(
+            remote_value.from_knx(DPTBinary(True)), HVACControllerMode.HEAT
         )
-        self.assertEqual(remote_value.from_knx(DPTBinary(True)), HVACOperationMode.HEAT)
         self.assertEqual(
-            remote_value.from_knx(DPTBinary(False)), HVACOperationMode.COOL
+            remote_value.from_knx(DPTBinary(False)), HVACControllerMode.COOL
         )
 
     def test_from_knx_unsupported_operation_mode(self):
         """Test from_knx function with unsupported operation."""
         xknx = XKNX()
         with self.assertRaises(ConversionError):
-            RemoteValueBinaryHeatCool(xknx, operation_mode=HVACOperationMode.NODEM)
+            RemoteValueBinaryHeatCool(xknx, controller_mode=HVACControllerMode.NODEM)
 
     def test_from_knx_unknown_operation_mode(self):
         """Test from_knx function with unsupported operation."""
         xknx = XKNX()
         with self.assertRaises(ConversionError):
-            RemoteValueBinaryHeatCool(xknx, operation_mode=None)
+            RemoteValueBinaryHeatCool(xknx, controller_mode=None)
 
     def test_supported_operation_modes_not_implemented(self):
         """Test from_knx function with unsupported operation."""
@@ -257,7 +269,7 @@ class TestRemoteValueDptValue1Ucount(unittest.TestCase):
         remote_value = RemoteValueBinaryHeatCool(
             xknx,
             group_address=GroupAddress("1/2/3"),
-            operation_mode=HVACOperationMode.COOL,
+            controller_mode=HVACControllerMode.COOL,
         )
         with self.assertRaises(CouldNotParseTelegram):
             telegram = Telegram(