Repository: XKNX/xknx
Pull request #1518 (2024-07-18 05:09:43)

Add DPT 1.100 definition and refactor DPTEnum
<!--
  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.
-->
- Use Enum values directly as raw knx value instead of extra value map
- Verify DPTBinary binary payload length
- Add DPT 1.100 HeatCool

Fixes # (issue)

## 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.
-->

- [ ] Bug fix (non-breaking change which fixes an issue)
- [x] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
- [ ] Code quality improvements to existing code or addition of tests

## Checklist

- [ ] The documentation has been adjusted accordingly
- [x] Tests have been added that prove the fix is effective or that the feature works
- [x] The changes are documented in the changelog (docs/changelog.md)

codecov[bot]: ## [Codecov](https://app.codecov.io/gh/XKNX/xknx/pull/1518?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=XKNX) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Project coverage is 96.67%. Comparing base [(`caea518`)](https://app.codecov.io/gh/XKNX/xknx/commit/caea5184aa6bff488dbc0e96804f4fefb10999dd?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=XKNX) to head [(`b0deb93`)](https://app.codecov.io/gh/XKNX/xknx/commit/b0deb93b86622f70e71588c9c110e02f625c1e36?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=XKNX).
> Report is 3 commits behind head on main.

<details><summary>Additional details and impacted files</summary>


[![Impacted file tree graph](https://app.codecov.io/gh/XKNX/xknx/pull/1518/graphs/tree.svg?width=650&height=150&src=pr&token=irWbIygS84&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=XKNX)](https://app.codecov.io/gh/XKNX/xknx/pull/1518?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=XKNX)

```diff
@@           Coverage Diff           @@
##             main    #1518   +/-   ##
=======================================
  Coverage   96.67%   96.67%           
=======================================
  Files         156      157    +1     
  Lines       10127    10149   +22     
=======================================
+ Hits         9790     9812   +22     
  Misses        337      337           
```

| [Files](https://app.codecov.io/gh/XKNX/xknx/pull/1518?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=XKNX) | Coverage Δ | |
|---|---|---|
| [xknx/devices/climate\_mode.py](https://app.codecov.io/gh/XKNX/xknx/pull/1518?src=pr&el=tree&filepath=xknx%2Fdevices%2Fclimate_mode.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=XKNX#diff-eGtueC9kZXZpY2VzL2NsaW1hdGVfbW9kZS5weQ==) | `99.08% <100.00%> (+0.01%)` | :arrow_up: |
| [xknx/dpt/\_\_init\_\_.py](https://app.codecov.io/gh/XKNX/xknx/pull/1518?src=pr&el=tree&filepath=xknx%2Fdpt%2F__init__.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=XKNX#diff-eGtueC9kcHQvX19pbml0X18ucHk=) | `100.00% <100.00%> (ø)` | |
| [xknx/dpt/dpt.py](https://app.codecov.io/gh/XKNX/xknx/pull/1518?src=pr&el=tree&filepath=xknx%2Fdpt%2Fdpt.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=XKNX#diff-eGtueC9kcHQvZHB0LnB5) | `100.00% <100.00%> (ø)` | |
| [xknx/dpt/dpt\_1.py](https://app.codecov.io/gh/XKNX/xknx/pull/1518?src=pr&el=tree&filepath=xknx%2Fdpt%2Fdpt_1.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=XKNX#diff-eGtueC9kcHQvZHB0XzEucHk=) | `100.00% <100.00%> (ø)` | |
| [xknx/dpt/dpt\_20.py](https://app.codecov.io/gh/XKNX/xknx/pull/1518?src=pr&el=tree&filepath=xknx%2Fdpt%2Fdpt_20.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=XKNX#diff-eGtueC9kcHQvZHB0XzIwLnB5) | `99.14% <100.00%> (-0.01%)` | :arrow_down: |
| [xknx/dpt/dpt\_3.py](https://app.codecov.io/gh/XKNX/xknx/pull/1518?src=pr&el=tree&filepath=xknx%2Fdpt%2Fdpt_3.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=XKNX#diff-eGtueC9kcHQvZHB0XzMucHk=) | `100.00% <100.00%> (ø)` | |
| [xknx/remote\_value/remote\_value\_climate\_mode.py](https://app.codecov.io/gh/XKNX/xknx/pull/1518?src=pr&el=tree&filepath=xknx%2Fremote_value%2Fremote_value_climate_mode.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=XKNX#diff-eGtueC9yZW1vdGVfdmFsdWUvcmVtb3RlX3ZhbHVlX2NsaW1hdGVfbW9kZS5weQ==) | `94.35% <100.00%> (+0.04%)` | :arrow_up: |

</details>

Patch:
diff --git a/docs/changelog.md b/docs/changelog.md
index f38818b7..e4bac2cb 100644
--- a/docs/changelog.md
+++ b/docs/changelog.md
@@ -46,12 +46,14 @@ nav_order: 2
   - 251.600 - DPTColorRGBW - RGBWColor
   - 20.60102 - DPTHVACStatus - HVACStatus (removed DPTControllerStatus in favour of this)
 - DPTEnum: Common interface for DPT transcoders with enumueration values. Transcoders accept Enum or string values for encoding.
+  - 1.100 - DPTHeatCool - HeatCool
   - 20.102 - DPTHVACMode - HVACOperationMode
     - rename "NIGHT" to "ECONOMY" and "FROST_PROTECTION" to "BUILDING_PROTECTION" according to KNX specifications
   - 20.105 - DPTHVACContrMode - HVACControllerMode
     - rename "DRY" to "DEHUMIDIFICATION" and add some values according to KNX specifications
 - Change DPT number of Enthalpy from 9.999 to 9.60000 (manufacturer specific range)
 - Support dict values with "main" and "sub" keys for `DPTBase.parse_transcoder()`
+- Verify DPTBinary max payload bitsize when decoding by transcoders `payload_length`
 
 ### Address
 
diff --git a/xknx/devices/climate_mode.py b/xknx/devices/climate_mode.py
index b41181e0..2e106468 100644
--- a/xknx/devices/climate_mode.py
+++ b/xknx/devices/climate_mode.py
@@ -10,6 +10,7 @@ from __future__ import annotations
 from collections.abc import Iterator
 from typing import TYPE_CHECKING, Any
 
+from xknx.dpt.dpt_1 import HeatCool
 from xknx.dpt.dpt_20 import HVACControllerMode, HVACOperationMode, HVACStatus
 from xknx.exceptions import DeviceIllegalValue
 from xknx.remote_value import GroupAddressesType
@@ -144,7 +145,9 @@ class ClimateMode(Device):
         else:
             for op_mode in operation_modes:
                 if isinstance(op_mode, str):
-                    self._operation_modes.append(HVACOperationMode(op_mode))
+                    self._operation_modes.append(
+                        HVACOperationMode[op_mode.replace(" ", "_").upper()]
+                    )
                 elif isinstance(op_mode, HVACOperationMode):
                     self._operation_modes.append(op_mode)
 
@@ -154,7 +157,9 @@ class ClimateMode(Device):
         else:
             for ct_mode in controller_modes:
                 if isinstance(ct_mode, str):
-                    self._controller_modes.append(HVACControllerMode(ct_mode))
+                    self._controller_modes.append(
+                        HVACControllerMode[ct_mode.replace(" ", "_").upper()]
+                    )
                 elif isinstance(ct_mode, HVACControllerMode):
                     self._controller_modes.append(ct_mode)
 
@@ -196,8 +201,13 @@ class ClimateMode(Device):
         if status.mode != self.operation_mode:
             self.operation_mode = status.mode
             updated = True
-        if status.heat_cool != self.controller_mode:
-            self.controller_mode = status.heat_cool
+        contr_mode_heat_cool = (
+            HVACControllerMode.HEAT
+            if status.heat_cool is HeatCool.HEAT
+            else HVACControllerMode.COOL
+        )
+        if contr_mode_heat_cool != self.controller_mode:
+            self.controller_mode = contr_mode_heat_cool
             updated = True
         if updated:
             self.after_update()
diff --git a/xknx/dpt/__init__.py b/xknx/dpt/__init__.py
index 44686d8a..88a1fe46 100644
--- a/xknx/dpt/__init__.py
+++ b/xknx/dpt/__init__.py
@@ -7,6 +7,7 @@ Module for encoding and decoding KNX datatypes.
 
 # ruff: noqa: F401
 from .dpt import DPTBase, DPTComplex, DPTComplexData, DPTEnum, DPTNumeric
+from .dpt_1 import DPTHeatCool
 from .dpt_3 import (
     DPTControlStartStop,
     DPTControlStartStopBlinds,
diff --git a/xknx/dpt/dpt.py b/xknx/dpt/dpt.py
index 435d5ff5..fb94da61 100644
--- a/xknx/dpt/dpt.py
+++ b/xknx/dpt/dpt.py
@@ -62,7 +62,7 @@ class DPTBase(ABC):
     """
 
     payload_type: type[DPTArray | DPTBinary]
-    payload_length: int = cast(int, None)  # only used for DPTArray
+    payload_length: int = cast(int, None)  # DPTArray: byte length; DPTBinary bit length
     dpt_main_number: int | None = None
     dpt_sub_number: int | None = None
     value_type: str | None = None
@@ -99,6 +99,13 @@ class DPTBase(ABC):
             )
 
         if cls.payload_type is DPTBinary and isinstance(payload, DPTBinary):
+            if payload.value >= 2**cls.payload_length:
+                # >= 0 is already checked by DPTBinary
+                raise CouldNotParseTelegram(
+                    f"Invalid payload bitlength for {cls.__name__}",
+                    payload=payload,
+                    expected_length=cls.payload_length,
+                )
             # wrap in tuple for consistent return signature
             return (payload.value,)
 
@@ -225,61 +232,64 @@ EnumT = TypeVar("EnumT", bound=Enum)
 
 
 class DPTEnum(DPTBase, Generic[EnumT]):
-    """Abstraction for KNX DPT Enum."""
+    """
+    Abstraction for KNX DPT Enum.
 
-    payload_type = DPTArray
+    Expects Enum values to be integers representing the raw KNX value.
+    """
+
+    payload_type: type[DPTArray | DPTBinary] = DPTArray
     payload_length = 1
 
     data_type: type[EnumT]
 
-    @classmethod
-    @abstractmethod
-    def get_value_map(cls) -> Mapping[int, EnumT]:
-        """Return mapping of raw KNX values to Enum members."""
-        # At least one abstract method is needed for our parse_transcoder lookup to
-        # ignore the DPTEnum base class and only find concrete base classes.
-        # Therefore this is not a ClassVar (which wouldn't support TypeVar parameters).
-
     @classmethod
     def from_knx(cls, payload: DPTArray | DPTBinary) -> EnumT:
         """Parse/deserialize from KNX/IP raw data."""
         raw = cls.validate_payload(payload)
         try:
-            return cls.get_value_map()[raw[0]]
-        except KeyError:
+            return cls.data_type(raw[0])  # type: ignore[no-any-return]
+        except ValueError:
             raise ConversionError(
                 f"Payload not supported for {cls.__name__}", raw=raw
             ) from None
 
     @classmethod
-    def to_knx(cls, value: EnumT | str) -> DPTArray:
+    def to_knx(cls, value: EnumT | str | int) -> DPTArray | DPTBinary:
         """Serialize to KNX/IP raw data."""
         if isinstance(value, str):
             try:
                 # snake_cased name may be used as translation key or serializable value
                 value = cls.data_type[value.upper()]
             except KeyError:
-                try:
-                    value = cls.data_type(value)
-                except ValueError:
-                    raise ConversionError(
-                        f"Invalid value for {cls.data_type.__name__} in {cls.__name__}",
-                        value=value,
-                        valid_values=cls.get_valid_values(),
-                    ) from None
-        for knx_raw_value, enum_member in cls.get_value_map().items():
-            if enum_member is value:
-                return DPTArray(knx_raw_value)
+                pass  # raise ConversionError below
+        elif isinstance(value, int):
+            try:
+                value = cls.data_type(value)
+            except ValueError:
+                pass  # raise ConversionError below
+
+        if isinstance(value, cls.data_type):
+            return cls._to_knx(value)
+
         raise ConversionError(
             f"Value not supported for {cls.data_type.__name__} in {cls.__name__}",
             value=value,
             valid_values=cls.get_valid_values(),
         )
 
+    @classmethod
+    @abstractmethod
+    def _to_knx(cls, value: EnumT) -> DPTArray | DPTBinary:
+        """Return the raw KNX value for an Enum member."""
+        # At least one abstract method is needed for our parse_transcoder lookup to
+        # ignore the DPTEnum base class and only find concrete base classes.
+        # `return DPTArray(value.value)` can be used typesafely if the Enum values are integers.
+
     @classmethod
     def get_valid_values(cls) -> list[EnumT]:
         """Return list of valid values."""
-        return list(cls.get_value_map().values())
+        return list(cls.data_type)  # type: ignore[call-overload, no-any-return]
 
 
 @dataclass(slots=True)
diff --git a/xknx/dpt/dpt_1.py b/xknx/dpt/dpt_1.py
new file mode 100644
index 00000000..7426a58f
--- /dev/null
+++ b/xknx/dpt/dpt_1.py
@@ -0,0 +1,41 @@
+"""Implementation of KNX 1-Bit KNX values."""
+
+from __future__ import annotations
+
+from enum import Enum
+
+from .dpt import DPTEnum, EnumT
+from .payload import DPTBinary
+
+
+class DPT1BitEnum(DPTEnum[EnumT]):
+    """Base class for KNX 1-Bit values encoded as Enums."""
+
+    payload_type = DPTBinary
+    payload_length = 1
+    dpt_main_number = 1
+
+
+class HeatCool(Enum):
+    """Enum for heat/cool."""
+
+    COOL = False
+    HEAT = True
+
+
+class DPTHeatCool(DPT1BitEnum[HeatCool]):
+    """
+    Abstraction for KNX 1-Bit heat/cool value.
+
+    DPT 1.100
+    """
+
+    dpt_main_number = 1
+    dpt_sub_number = 100
+    value_type = "heat_cool"
+    data_type = HeatCool
+
+    @classmethod
+    def _to_knx(cls, value: HeatCool) -> DPTBinary:
+        """Return the raw KNX value for an Enum member."""
+        return DPTBinary(value.value)
diff --git a/xknx/dpt/dpt_20.py b/xknx/dpt/dpt_20.py
index c8b3631d..b3376e4e 100644
--- a/xknx/dpt/dpt_20.py
+++ b/xknx/dpt/dpt_20.py
@@ -5,9 +5,10 @@ from __future__ import annotations
 from collections.abc import Mapping
 from dataclasses import dataclass
 from enum import Enum
-from typing import Any, Literal
+from typing import Any
 
 from .dpt import DPTComplex, DPTComplexData, DPTEnum
+from .dpt_1 import HeatCool
 from .payload import DPTArray, DPTBinary
 
 
@@ -15,11 +16,11 @@ from .payload import DPTArray, DPTBinary
 class HVACOperationMode(Enum):
     """Enum for the different KNX HVAC operation modes."""
 
-    AUTO = "Auto"
-    COMFORT = "Comfort"
-    STANDBY = "Standby"
-    ECONOMY = "Economy"
-    BUILDING_PROTECTION = "Building Protection"
+    AUTO = 0
+    COMFORT = 1
+    STANDBY = 2
+    ECONOMY = 3
+    BUILDING_PROTECTION = 4
 
 
 class DPTHVACMode(DPTEnum[HVACOperationMode]):
@@ -35,39 +36,33 @@ class DPTHVACMode(DPTEnum[HVACOperationMode]):
     data_type = HVACOperationMode
 
     @classmethod
-    def get_value_map(cls) -> Mapping[int, HVACOperationMode]:
-        """Return mapping of raw KNX values to Enum members."""
-        return {
-            0: HVACOperationMode.AUTO,
-            1: HVACOperationMode.COMFORT,
-            2: HVACOperationMode.STANDBY,
-            3: HVACOperationMode.ECONOMY,
-            4: HVACOperationMode.BUILDING_PROTECTION,
-        }
+    def _to_knx(cls, value: HVACOperationMode) -> DPTArray:
+        """Return the raw KNX value for an Enum member."""
+        return DPTArray(value.value)
 
 
 class HVACControllerMode(Enum):
     """Enum for the different KNX HVAC controller modes."""
 
-    AUTO = "Auto"
-    HEAT = "Heat"
-    MORNING_WARMUP = "Morning Warmup"
-    COOL = "Cool"
-    NIGHT_PURGE = "Night Purge"
-    PRECOOL = "Precool"
-    OFF = "Off"
-    TEST = "Test"
-    EMERGENCY_HEAT = "Emergency Heat"
-    FAN_ONLY = "Fan only"
-    FREE_COOL = "Free Cool"
-    ICE = "Ice"
-    MAXIMUM_HEATING_MODE = "Maximum Heating Mode"
-    ECONOMIC_HEAT_COOL_MODE = "Economic Heat/Cool Mode"
-    DEHUMIDIFICATION = "Dehumidification"
-    CALIBRATION_MODE = "Calibration Mode"
-    EMERGENCY_COOL_MODE = "Emergency Cool Mode"
-    EMERGENCY_STEAM_MODE = "Emergency Steam Mode"
-    NODEM = "NoDem"
+    AUTO = 0
+    HEAT = 1
+    MORNING_WARMUP = 2
+    COOL = 3
+    NIGHT_PURGE = 4
+    PRECOOL = 5
+    OFF = 6
+    TEST = 7
+    EMERGENCY_HEAT = 8
+    FAN_ONLY = 9
+    FREE_COOL = 10
+    ICE = 11
+    MAXIMUM_HEATING_MODE = 12
+    ECONOMIC_HEAT_COOL_MODE = 13
+    DEHUMIDIFICATION = 14
+    CALIBRATION_MODE = 15
+    EMERGENCY_COOL_MODE = 16
+    EMERGENCY_STEAM_MODE = 17
+    NODEM = 20
 
 
 class DPTHVACContrMode(DPTEnum[HVACControllerMode]):
@@ -83,29 +78,9 @@ class DPTHVACContrMode(DPTEnum[HVACControllerMode]):
     data_type = HVACControllerMode
 
     @classmethod
-    def get_value_map(cls) -> Mapping[int, HVACControllerMode]:
-        """Return mapping of raw KNX values to Enum members."""
-        return {
-            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,
-            12: HVACControllerMode.MAXIMUM_HEATING_MODE,
-            13: HVACControllerMode.ECONOMIC_HEAT_COOL_MODE,
-            14: HVACControllerMode.DEHUMIDIFICATION,
-            15: HVACControllerMode.CALIBRATION_MODE,
-            16: HVACControllerMode.ECONOMIC_HEAT_COOL_MODE,
-            17: HVACControllerMode.EMERGENCY_STEAM_MODE,
-            20: HVACControllerMode.NODEM,
-        }
+    def _to_knx(cls, value: HVACControllerMode) -> DPTArray:
+        """Return the raw KNX value for an Enum member."""
+        return DPTArray(value.value)
 
 
 @dataclass(slots=True)
@@ -114,7 +89,7 @@ class HVACStatus(DPTComplexData):
 
     mode: HVACOperationMode
     dew_point: bool
-    heat_cool: Literal[HVACControllerMode.HEAT, HVACControllerMode.COOL]
+    heat_cool: HeatCool
     inactive: bool
     frost_alarm: bool
 
@@ -126,13 +101,11 @@ class HVACStatus(DPTComplexData):
             _heat_cool = data["heat_cool"]
             try:
                 mode = HVACOperationMode[_mode.upper()]
-                heat_cool = HVACControllerMode[_heat_cool.upper()]
+                heat_cool = HeatCool[_heat_cool.upper()]
             except AttributeError as err:
                 raise ValueError(
                     f"Invalid type for HVAC mode or heat_cool: {err}"
                 ) from err
-            if heat_cool not in (HVACControllerMode.HEAT, HVACControllerMode.COOL):
-                raise ValueError(f"Invalid value for HVAC heat_cool: {heat_cool=}")
             dew_point = data["dew_point"]
             inactive = data["inactive"]
             frost_alarm = data["frost_alarm"]
@@ -151,7 +124,7 @@ class HVACStatus(DPTComplexData):
         return cls(
             mode=mode,
             dew_point=dew_point,
-            heat_cool=heat_cool,  # type: ignore[arg-type]  # checked by `not in (HVACControllerMode.HEAT, HVACControllerMode.COOL)` above
+            heat_cool=heat_cool,
             inactive=inactive,
             frost_alarm=frost_alarm,
         )
@@ -201,9 +174,7 @@ class DPTHVACStatus(DPTComplex[HVACStatus]):
         return HVACStatus(
             mode=mode,
             dew_point=bool(raw & 0b00001000),
-            heat_cool=HVACControllerMode.HEAT
-            if raw & 0b00000100
-            else HVACControllerMode.COOL,
+            heat_cool=HeatCool.HEAT if raw & 0b00000100 else HeatCool.COOL,
             inactive=bool(raw & 0b00000010),
             frost_alarm=bool(raw & 0b00000001),
         )
@@ -222,7 +193,7 @@ class DPTHVACStatus(DPTComplex[HVACStatus]):
             raw |= 0b00010000
         if value.dew_point:
             raw |= 0b00001000
-        if value.heat_cool is HVACControllerMode.HEAT:
+        if value.heat_cool is HeatCool.HEAT:
             raw |= 0b00000100
         if value.inactive:
             raw |= 0b00000010
diff --git a/xknx/dpt/dpt_3.py b/xknx/dpt/dpt_3.py
index 7d063a95..adb8ae6c 100644
--- a/xknx/dpt/dpt_3.py
+++ b/xknx/dpt/dpt_3.py
@@ -31,10 +31,9 @@ class DPTControlStepCode(DPTBase, ABC):
     # APCI (application layer control information)
     APCI_CONTROLMASK = 0x08
     APCI_STEPCODEMASK = 0x07
-    APCI_MAX_VALUE = APCI_CONTROLMASK | APCI_STEPCODEMASK
 
     payload_type = DPTBinary
-    payload_length = 1
+    payload_length = 4
 
     @classmethod
     def _encode(cls, control: bool, step_code: int) -> int:
@@ -82,8 +81,6 @@ class DPTControlStepCode(DPTBase, ABC):
     def from_knx(cls, payload: DPTArray | DPTBinary) -> Any:
         """Parse/deserialize from KNX/IP raw data."""
         raw = cls.validate_payload(payload)[0]
-        if raw > cls.APCI_MAX_VALUE:
-            raise ConversionError(f"Can't parse {cls.__name__}", raw=raw)
 
         control, step_code = cls._decode(raw)
         return {"control": control, "step_code": step_code}
diff --git a/xknx/remote_value/remote_value_climate_mode.py b/xknx/remote_value/remote_value_climate_mode.py
index 0bb60676..e4a9ce18 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 (
     DPTHVACMode,
     DPTHVACStatus,
 )
+from xknx.dpt.dpt_1 import HeatCool
 from xknx.dpt.dpt_20 import HVACControllerMode, HVACOperationMode, HVACStatus
 from xknx.exceptions import ConversionError, CouldNotParseTelegram
 
@@ -217,7 +218,9 @@ class RemoteValueHVACStatus(RemoteValueClimateModeBase[HVACStatus]):
         new_status = HVACStatus(
             mode=self._value.mode,
             dew_point=self._value.dew_point,
-            heat_cool=mode,  # type: ignore[arg-type]
+            heat_cool=HeatCool.HEAT
+            if mode == HVACControllerMode.HEAT
+            else HeatCool.COOL,
             inactive=self._value.inactive,
             frost_alarm=self._value.frost_alarm,
         )


Test patch:
diff --git a/test/dpt_tests/dpt_1_test.py b/test/dpt_tests/dpt_1_test.py
new file mode 100644
index 00000000..7665a9aa
--- /dev/null
+++ b/test/dpt_tests/dpt_1_test.py
@@ -0,0 +1,45 @@
+"""Unit test for KNX DPT 1."""
+
+import pytest
+
+from xknx.dpt import DPTArray, DPTBinary
+from xknx.dpt.dpt_1 import DPTHeatCool, HeatCool
+from xknx.exceptions import ConversionError, CouldNotParseTelegram
+
+
+class TestDPTHeatCool:
+    """Test class for KNX DPT HVAC Operation modes."""
+
+    def test_to_knx(self):
+        """Test parsing to KNX."""
+        assert DPTHeatCool.to_knx(HeatCool.HEAT) == DPTBinary(1)
+        assert DPTHeatCool.to_knx(HeatCool.COOL) == DPTBinary(0)
+
+    def test_to_knx_by_string(self):
+        """Test parsing string values to KNX."""
+        assert DPTHeatCool.to_knx("heat") == DPTBinary(1)
+        assert DPTHeatCool.to_knx("cool") == DPTBinary(0)
+
+    def test_to_knx_by_value(self):
+        """Test parsing string values to KNX."""
+        assert DPTHeatCool.to_knx(True) == DPTBinary(1)
+        assert DPTHeatCool.to_knx(1) == DPTBinary(1)
+        assert DPTHeatCool.to_knx(False) == DPTBinary(0)
+        assert DPTHeatCool.to_knx(0) == DPTBinary(0)
+
+    def test_to_knx_wrong_value(self):
+        """Test serializing to KNX with wrong value."""
+        with pytest.raises(ConversionError):
+            DPTHeatCool.to_knx(2)
+
+    def test_mode_from_knx(self):
+        """Test parsing DPTHVACMode from KNX."""
+        assert DPTHeatCool.from_knx(DPTBinary(0)) == HeatCool.COOL
+        assert DPTHeatCool.from_knx(DPTBinary(1)) == HeatCool.HEAT
+
+    def test_mode_from_knx_wrong_value(self):
+        """Test parsing of DPTHVACMode with wrong value)."""
+        with pytest.raises(CouldNotParseTelegram):
+            DPTHeatCool.from_knx(DPTArray((1,)))
+        with pytest.raises(CouldNotParseTelegram):
+            DPTHeatCool.from_knx(DPTBinary(2))
diff --git a/test/dpt_tests/dpt_20_hvac_mode_test.py b/test/dpt_tests/dpt_20_hvac_mode_test.py
index 98ceafd3..f7e9dba1 100644
--- a/test/dpt_tests/dpt_20_hvac_mode_test.py
+++ b/test/dpt_tests/dpt_20_hvac_mode_test.py
@@ -2,8 +2,8 @@
 
 import pytest
 
-from xknx.dpt import DPTArray, DPTHVACMode, DPTHVACStatus
-from xknx.dpt.dpt_20 import HVACControllerMode, HVACOperationMode, HVACStatus
+from xknx.dpt import DPTArray, DPTHVACContrMode, DPTHVACMode, DPTHVACStatus
+from xknx.dpt.dpt_20 import HeatCool, HVACControllerMode, HVACOperationMode, HVACStatus
 from xknx.exceptions import ConversionError, CouldNotParseTelegram
 
 
@@ -26,9 +26,7 @@ class TestDPTHVACMode:
         assert DPTHVACMode.to_knx("Comfort") == DPTArray((0x01,))
         assert DPTHVACMode.to_knx("standby") == DPTArray((0x02,))
         assert DPTHVACMode.to_knx("ECONOMY") == DPTArray((0x03,))
-        assert DPTHVACMode.to_knx(
-            "Building Protection"  # Enum value, not name
-        ) == DPTArray((0x04,))
+        assert DPTHVACMode.to_knx("Building_Protection") == DPTArray((0x04,))
 
     def test_mode_to_knx_wrong_value(self):
         """Test serializing DPTHVACMode to KNX with wrong value."""
@@ -54,6 +52,58 @@ class TestDPTHVACMode:
             DPTHVACMode.from_knx(DPTArray((0x05,)))
 
 
+class TestDPTHVACControllerMode:
+    """Test class for KNX DPT HVAC Controller modes."""
+
+    def test_mode_to_knx(self):
+        """Test parsing DPTHVACContrMode to KNX."""
+        assert DPTHVACContrMode.to_knx(HVACControllerMode.AUTO) == DPTArray((0x00,))
+        assert DPTHVACContrMode.to_knx(HVACControllerMode.HEAT) == DPTArray((0x01,))
+        assert DPTHVACContrMode.to_knx(HVACControllerMode.COOL) == DPTArray((0x03,))
+        assert DPTHVACContrMode.to_knx(HVACControllerMode.OFF) == DPTArray((0x06,))
+        assert DPTHVACContrMode.to_knx(HVACControllerMode.DEHUMIDIFICATION) == DPTArray(
+            (0x0E,)
+        )
+
+    def test_mode_to_knx_by_string(self):
+        """Test parsing DPTHVACMode string values to KNX."""
+        assert DPTHVACContrMode.to_knx("morning_warmup") == DPTArray((0x02,))
+        assert DPTHVACContrMode.to_knx("NIGHT_PURGE") == DPTArray((0x04,))
+        assert DPTHVACContrMode.to_knx("precool") == DPTArray((0x05,))
+        assert DPTHVACContrMode.to_knx("Test") == DPTArray((0x07,))
+        assert DPTHVACContrMode.to_knx("NODEM") == DPTArray((0x14,))
+
+    def test_mode_to_knx_wrong_value(self):
+        """Test serializing DPTHVACMode to KNX with wrong value."""
+        with pytest.raises(ConversionError):
+            DPTHVACContrMode.to_knx(18)
+        with pytest.raises(ConversionError):
+            DPTHVACContrMode.to_knx(19)
+        with pytest.raises(ConversionError):
+            DPTHVACContrMode.to_knx(21)
+
+    @pytest.mark.parametrize(
+        ("raw", "value"),
+        [
+            (0x00, HVACControllerMode.AUTO),
+            (0x08, HVACControllerMode.EMERGENCY_HEAT),
+            (0x09, HVACControllerMode.FAN_ONLY),
+            (0x0A, HVACControllerMode.FREE_COOL),
+            (0x0B, HVACControllerMode.ICE),
+        ],
+    )
+    def test_mode_from_knx(self, raw, value):
+        """Test parsing DPTHVACMode from KNX."""
+        assert DPTHVACContrMode.from_knx(DPTArray((raw,))) is value
+
+    def test_mode_from_knx_wrong_value(self):
+        """Test parsing of DPTHVACMode with wrong value)."""
+        with pytest.raises(CouldNotParseTelegram):
+            DPTHVACContrMode.from_knx(DPTArray((1, 2)))
+        with pytest.raises(ConversionError):
+            DPTHVACContrMode.from_knx(DPTArray((18,)))
+
+
 class TestHVACStatus:
     """Test HVACStatus class."""
 
@@ -71,7 +121,7 @@ class TestHVACStatus:
                 HVACStatus(
                     mode=HVACOperationMode.COMFORT,
                     dew_point=False,
-                    heat_cool=HVACControllerMode.HEAT,
+                    heat_cool=HeatCool.HEAT,
                     inactive=False,
                     frost_alarm=False,
                 ),
@@ -87,7 +137,7 @@ class TestHVACStatus:
                 HVACStatus(
                     mode=HVACOperationMode.STANDBY,
                     dew_point=False,
-                    heat_cool=HVACControllerMode.COOL,
+                    heat_cool=HeatCool.COOL,
                     inactive=True,
                     frost_alarm=False,
                 ),
@@ -149,7 +199,7 @@ class TestDPTHVACStatus:
                 HVACStatus(
                     mode=HVACOperationMode.COMFORT,
                     dew_point=False,
-                    heat_cool=HVACControllerMode.HEAT,
+                    heat_cool=HeatCool.HEAT,
                     inactive=False,
                     frost_alarm=False,
                 ),
@@ -159,7 +209,7 @@ class TestDPTHVACStatus:
                 HVACStatus(
                     mode=HVACOperationMode.COMFORT,
                     dew_point=False,
-                    heat_cool=HVACControllerMode.COOL,
+                    heat_cool=HeatCool.COOL,
                     inactive=False,
                     frost_alarm=False,
                 ),
@@ -169,7 +219,7 @@ class TestDPTHVACStatus:
                 HVACStatus(
                     mode=HVACOperationMode.ECONOMY,
                     dew_point=True,
-                    heat_cool=HVACControllerMode.COOL,
+                    heat_cool=HeatCool.COOL,
                     inactive=False,
                     frost_alarm=True,
                 ),
diff --git a/test/dpt_tests/dpt_3_test.py b/test/dpt_tests/dpt_3_test.py
index 3dce4ec0..0d0ad7cd 100644
--- a/test/dpt_tests/dpt_3_test.py
+++ b/test/dpt_tests/dpt_3_test.py
@@ -67,7 +67,7 @@ class TestDPTControlStepCode:
 
     def test_from_knx_wrong_value(self):
         """Test parsing invalid DPTControlStepCode type from KNX."""
-        with pytest.raises(ConversionError):
+        with pytest.raises(CouldNotParseTelegram):
             DPTControlStepCode.from_knx(DPTBinary((0x1F,)))
 
     def test_unit(self):
@@ -134,7 +134,7 @@ class TestDPTControlStepwise:
 
     def test_from_knx_wrong_value(self):
         """Test parsing invalid DPTControlStepwise type from KNX."""
-        with pytest.raises(ConversionError):
+        with pytest.raises(CouldNotParseTelegram):
             DPTControlStepwise.from_knx(DPTBinary(0x1F))
 
     def test_unit(self):
diff --git a/test/dpt_tests/dpt_lookup_test.py b/test/dpt_tests/dpt_lookup_test.py
index 4c8c5efa..d85ac584 100644
--- a/test/dpt_tests/dpt_lookup_test.py
+++ b/test/dpt_tests/dpt_lookup_test.py
@@ -25,7 +25,7 @@ from xknx.dpt import *
 
 
 @pytest.mark.parametrize(
-    "value_type,dpt_class,main,sub,unit",
+    ("value_type", "dpt_class", "main", "sub", "unit"),
     [
         ("1byte_signed", DPTSignedRelativeValue, 6, None, None),
         ("1byte_unsigned", DPTValue1ByteUnsigned, 5, None, None),
@@ -60,7 +60,10 @@ from xknx.dpt import *
         ("capacitance", DPTCapacitance, 14, 11, "F"),
         ("charge_density_surface", DPTChargeDensitySurface, 14, 12, "C/m²"),
         ("charge_density_volume", DPTChargeDensityVolume, 14, 13, "C/m³"),
+        ("color_rgb", DPTColorRGB, 232, 600, None),
+        ("color_rgbw", DPTColorRGBW, 251, 600, None),
         ("color_temperature", DPTColorTemperature, 7, 600, "K"),
+        ("color_xyy", DPTColorXYY, 242, 600, None),
         ("common_temperature", DPTCommonTemperature, 14, 68, "°C"),
         ("compressibility", DPTCompressibility, 14, 14, "m²/N"),
         ("concentration_ugm3", DPTConcentrationUGM3, 9, 30, "μg/m³"),
@@ -95,10 +98,14 @@ from xknx.dpt import *
         ("flow_rate_m3h", DPTFlowRateM3H, 13, 2, "m³/h"),
         ("force", DPTForce, 14, 32, "N"),
         ("frequency", DPTFrequency, 14, 33, "Hz"),
+        ("heat_cool", DPTHeatCool, 1, 100, None),
         ("heat_quantity", DPTHeatQuantity, 14, 37, "J"),
         ("heatcapacity", DPTHeatCapacity, 14, 35, "J/K"),
         ("heatflowrate", DPTHeatFlowRate, 14, 36, "W"),
         ("humidity", DPTHumidity, 9, 7, "%"),
+        ("hvac_controller_mode", DPTHVACContrMode, 20, 105, None),
+        ("hvac_mode", DPTHVACMode, 20, 102, None),
+        ("hvac_status", DPTHVACStatus, 20, 60102, None),
         ("illuminance", DPTLux, 9, 4, "lx"),
         ("impedance", DPTImpedance, 14, 38, "Ω"),
         ("kelvin_per_percent", DPTKelvinPerPercent, 9, 23, "K/%"),
@@ -162,6 +169,7 @@ from xknx.dpt import *
         ("string", DPTString, 16, 0, None),
         ("surface_tension", DPTSurfaceTension, 14, 67, "N/m"),
         ("tariff", DPTTariff, 5, 6, None),
+        ("tariff_active_energy", DPTTariffActiveEnergy, 235, 1, None),
         ("temperature", DPTTemperature, 9, 1, "°C"),
         ("temperature_a", DPTTemperatureA, 9, 3, "K/h"),
         ("temperature_difference", DPTTemperatureDifference, 14, 70, "K"),