Repository: XKNX/xknx
Pull request #715 (2021-06-10 21:25:54)

Add NumericValue device
<!--
  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.
-->
add NumericValue device for sending, receiving and responding numeric values over the KNX bus.

It is a combination of Sensor and ExposeSensor. 

## 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)
- [x] 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
- [ ] The Homeassistant plugin has been adjusted in case of new config options



Patch:
diff --git a/changelog.md b/changelog.md
index ca4d1fe1..d023e5b6 100644
--- a/changelog.md
+++ b/changelog.md
@@ -2,9 +2,14 @@
 
 ## Unreleased changes
 
+### Devices
+
+- Add NumericValue device.
+
 ### Internals
 
-- fix DPTBase classmethod return types
+- Add RemoteValueNumeric for values of type `float | int`.
+- Fix DPTBase classmethod return types
 
 ## 0.18.5 DPTNumeric 2021-06-08
 
diff --git a/docs/numeric_value.md b/docs/numeric_value.md
new file mode 100644
index 00000000..20835f26
--- /dev/null
+++ b/docs/numeric_value.md
@@ -0,0 +1,50 @@
+---
+layout: default
+title: NumericValue
+parent: Devices
+nav_order: 6
+---
+
+# [](#header-1)NumericValue - Send and receive numeric values over KNX
+
+## [](#header-2)Overview
+
+NumericValue devices send values to the KNX bus. Received values update the devices state. Optionally the current state can be read from the KNX bus.
+
+## [](#header-2)Interface
+
+- `xknx` is the XKNX object.
+- `name` is the name of the object.
+- `group_address` is the KNX group address of the numeric value device. Used for sending.
+- `group_address_state` is the KNX group address of the numeric value device.
+- `respond_to_read` if `True` GroupValueRead requests to the `group_address` are answered. Defaults to `False`
+- `sync_state` defines if the value should be actively read from the bus. If `False` no GroupValueRead telegrams will be sent to its group address state. Defaults to `True`
+- `value_type` controls how the value should be encoded / decoded. The attribut may have may have parseable value types representing numeric values.
+- `device_updated_cb` awaitable callback for each update.
+
+## [](#header-2)Example
+
+```python
+value = NumericValue(
+    xknx=xknx,
+    name='Temperature limit',
+    group_address='6/2/1',
+    respond_to_read=True,
+    value_type='temperature'
+)
+
+# Set a value without sending to the bus
+sensor.sensor_value.value = 23.0
+
+# Send a new value to the bus
+await value.set(24.0)
+
+# Returns the value of in a human readable way
+sensor.resolve_state()
+
+# Returns the unit of the value as string
+sensor.unit_of_measurement()
+
+# Returns the last received telegram or None
+sensor.last_telegram
+```
diff --git a/docs/sensor.md b/docs/sensor.md
index 65648bdc..04ef1279 100644
--- a/docs/sensor.md
+++ b/docs/sensor.md
@@ -2,7 +2,7 @@
 layout: default
 title: Sensor
 parent: Devices
-nav_order: 6
+nav_order: 7
 ---
 
 # [](#header-1)Sensor - Monitor values of KNX
diff --git a/docs/switch.md b/docs/switch.md
index 7b092bcf..bad66e17 100644
--- a/docs/switch.md
+++ b/docs/switch.md
@@ -2,7 +2,7 @@
 layout: default
 title: Switches
 parent: Devices
-nav_order: 7
+nav_order: 8
 ---
 
 # [](#header-1)Switches
diff --git a/docs/time.md b/docs/time.md
index 080570e5..04bea417 100644
--- a/docs/time.md
+++ b/docs/time.md
@@ -2,7 +2,7 @@
 layout: default
 title: Time
 parent: Devices
-nav_order: 8
+nav_order: 9
 ---
 
 # [](#header-1)Time
diff --git a/docs/weather.md b/docs/weather.md
index fc3e0ec6..f1d6ad42 100644
--- a/docs/weather.md
+++ b/docs/weather.md
@@ -2,7 +2,7 @@
 layout: default
 title: Weather
 parent: Devices
-nav_order: 9
+nav_order: 10
 ---
 
 # [](#header-1)Weather device
diff --git a/xknx/devices/__init__.py b/xknx/devices/__init__.py
index fe3c4f28..3aa7bb02 100644
--- a/xknx/devices/__init__.py
+++ b/xknx/devices/__init__.py
@@ -11,6 +11,7 @@ from .expose_sensor import ExposeSensor
 from .fan import Fan
 from .light import Light
 from .notification import Notification
+from .numeric_value import NumericValue
 from .scene import Scene
 from .sensor import Sensor
 from .switch import Switch
@@ -29,6 +30,7 @@ __all__ = [
     "Fan",
     "Light",
     "Notification",
+    "NumericValue",
     "Scene",
     "Sensor",
     "Switch",
diff --git a/xknx/devices/expose_sensor.py b/xknx/devices/expose_sensor.py
index bd239aba..d4c166b6 100644
--- a/xknx/devices/expose_sensor.py
+++ b/xknx/devices/expose_sensor.py
@@ -7,9 +7,8 @@ It provides functionality for
 * KNX devices may read local values via GROUP READ.
 
 (A typical example for using this class is the outside temperature
-read from an internet service (e.g. Yahoo weather) and exposed to
-ths KNX bus. KNX sensors may show this outside temperature within their
-LCD display.
+read from e.g. an internet serviceand exposed to ths KNX bus.
+KNX devices may show this value within their display.)
 """
 from __future__ import annotations
 
diff --git a/xknx/devices/light.py b/xknx/devices/light.py
index b2f0cc78..4ac1a9cf 100644
--- a/xknx/devices/light.py
+++ b/xknx/devices/light.py
@@ -397,7 +397,7 @@ class Light(Device):
         if white is not None:
             if self.supports_rgbw:
                 if self.rgbw.initialized:
-                    await self.rgbw.set(list(color) + [white])
+                    await self.rgbw.set((*color, white))
                     return
                 if all(
                     c.brightness.initialized for c in self._iter_individual_colors()
diff --git a/xknx/devices/numeric_value.py b/xknx/devices/numeric_value.py
new file mode 100644
index 00000000..6b5dc836
--- /dev/null
+++ b/xknx/devices/numeric_value.py
@@ -0,0 +1,94 @@
+"""
+Module for managing a generic numeric value via KNX.
+
+It provides functionality for
+
+* readingthe current state from KNX bus or providing its state to the bus.
+* send local state changes to KNX bus.
+* watching for state updates from KNX bus.
+"""
+from __future__ import annotations
+
+from typing import TYPE_CHECKING, Iterator
+
+from xknx.remote_value import GroupAddressesType, RemoteValueNumeric
+
+from .device import Device, DeviceCallbackType
+
+if TYPE_CHECKING:
+    from xknx.telegram import Telegram
+    from xknx.xknx import XKNX
+
+
+class NumericValue(Device):
+    """Class for managing a numeric value."""
+
+    def __init__(
+        self,
+        xknx: XKNX,
+        name: str,
+        group_address: GroupAddressesType | None = None,
+        group_address_state: GroupAddressesType | None = None,
+        respond_to_read: bool = False,
+        sync_state: bool = True,
+        value_type: int | str | None = None,
+        device_updated_cb: DeviceCallbackType | None = None,
+    ):
+        """Initialize Sensor class."""
+        super().__init__(xknx, name, device_updated_cb)
+        self.respond_to_read = respond_to_read
+        self.sensor_value = RemoteValueNumeric(
+            xknx,
+            group_address=group_address,
+            group_address_state=group_address_state,
+            sync_state=sync_state,
+            value_type=value_type,
+            device_name=self.name,
+            after_update_cb=self.after_update,
+        )
+
+    def _iter_remote_values(self) -> Iterator[RemoteValueNumeric]:
+        """Iterate the devices RemoteValue classes."""
+        yield self.sensor_value
+
+    @property
+    def last_telegram(self) -> Telegram | None:
+        """Return the last telegram received from the RemoteValue."""
+        return self.sensor_value.telegram
+
+    async def process_group_write(self, telegram: "Telegram") -> None:
+        """Process incoming and outgoing GROUP WRITE telegram."""
+        await self.sensor_value.process(telegram)
+
+    async def process_group_read(self, telegram: "Telegram") -> None:
+        """Process incoming GroupValueResponse telegrams."""
+        if (
+            self.respond_to_read
+            and telegram.destination_address == self.sensor_value.group_address
+        ):
+            await self.sensor_value.respond()
+
+    async def set(self, value: float | int) -> None:
+        """Set new value."""
+        await self.sensor_value.set(value)
+
+    def unit_of_measurement(self) -> str | None:
+        """Return the unit of measurement."""
+        return self.sensor_value.unit_of_measurement
+
+    def ha_device_class(self) -> str | None:
+        """Return the home assistant device class as string."""
+        return self.sensor_value.ha_device_class
+
+    def resolve_state(self) -> float | int | None:
+        """Return the current state of the sensor as a human readable string."""
+        return self.sensor_value.value
+
+    def __str__(self) -> str:
+        """Return object as readable string."""
+        return '<NumericValue name="{}" addresses={} value={} unit="{}"/>'.format(
+            self.name,
+            self.sensor_value.group_addr_str(),
+            self.resolve_state().__repr__(),
+            self.unit_of_measurement(),
+        )
diff --git a/xknx/devices/weather.py b/xknx/devices/weather.py
index d00ead4e..bf8211fa 100644
--- a/xknx/devices/weather.py
+++ b/xknx/devices/weather.py
@@ -21,7 +21,7 @@ from typing import TYPE_CHECKING, Any, Callable, Iterator
 from xknx.remote_value import (
     GroupAddressesType,
     RemoteValue,
-    RemoteValueSensor,
+    RemoteValueNumeric,
     RemoteValueSwitch,
 )
 
@@ -105,7 +105,7 @@ class Weather(Device):
         """Initialize Weather class."""
         super().__init__(xknx, name, device_updated_cb)
 
-        self._temperature = RemoteValueSensor(
+        self._temperature = RemoteValueNumeric(
             xknx,
             group_address_state=group_address_temperature,
             sync_state=sync_state,
@@ -115,7 +115,7 @@ class Weather(Device):
             after_update_cb=self.after_update,
         )
 
-        self._brightness_south = RemoteValueSensor(
+        self._brightness_south = RemoteValueNumeric(
             xknx,
             group_address_state=group_address_brightness_south,
             sync_state=sync_state,
@@ -125,7 +125,7 @@ class Weather(Device):
             after_update_cb=self.after_update,
         )
 
-        self._brightness_north = RemoteValueSensor(
+        self._brightness_north = RemoteValueNumeric(
             xknx,
             group_address_state=group_address_brightness_north,
             sync_state=sync_state,
@@ -135,7 +135,7 @@ class Weather(Device):
             after_update_cb=self.after_update,
         )
 
-        self._brightness_west = RemoteValueSensor(
+        self._brightness_west = RemoteValueNumeric(
             xknx,
             group_address_state=group_address_brightness_west,
             sync_state=sync_state,
@@ -145,7 +145,7 @@ class Weather(Device):
             after_update_cb=self.after_update,
         )
 
-        self._brightness_east = RemoteValueSensor(
+        self._brightness_east = RemoteValueNumeric(
             xknx,
             group_address_state=group_address_brightness_east,
             sync_state=sync_state,
@@ -155,7 +155,7 @@ class Weather(Device):
             after_update_cb=self.after_update,
         )
 
-        self._wind_speed = RemoteValueSensor(
+        self._wind_speed = RemoteValueNumeric(
             xknx,
             group_address_state=group_address_wind_speed,
             sync_state=sync_state,
@@ -165,7 +165,7 @@ class Weather(Device):
             after_update_cb=self.after_update,
         )
 
-        self._wind_bearing = RemoteValueSensor(
+        self._wind_bearing = RemoteValueNumeric(
             xknx,
             group_address_state=group_address_wind_bearing,
             sync_state=sync_state,
@@ -207,7 +207,7 @@ class Weather(Device):
             after_update_cb=self.after_update,
         )
 
-        self._air_pressure = RemoteValueSensor(
+        self._air_pressure = RemoteValueNumeric(
             xknx,
             group_address_state=group_address_air_pressure,
             sync_state=sync_state,
@@ -217,7 +217,7 @@ class Weather(Device):
             after_update_cb=self.after_update,
         )
 
-        self._humidity = RemoteValueSensor(
+        self._humidity = RemoteValueNumeric(
             xknx,
             group_address_state=group_address_humidity,
             sync_state=sync_state,
@@ -259,40 +259,40 @@ class Weather(Device):
     @property
     def temperature(self) -> float | None:
         """Return current temperature."""
-        return self._temperature.value  # type: ignore
+        return self._temperature.value
 
     @property
     def brightness_south(self) -> float:
         """Return brightness south."""
         if self._brightness_south.value is not None:
-            return self._brightness_south.value  # type: ignore
+            return self._brightness_south.value
         return 0.0
 
     @property
     def brightness_north(self) -> float:
         """Return brightness north."""
         if self._brightness_north.value is not None:
-            return self._brightness_north.value  # type: ignore
+            return self._brightness_north.value
         return 0.0
 
     @property
     def brightness_east(self) -> float:
         """Return brightness east."""
         if self._brightness_east.value is not None:
-            return self._brightness_east.value  # type: ignore
+            return self._brightness_east.value
         return 0.0
 
     @property
     def brightness_west(self) -> float:
         """Return brightness west."""
         if self._brightness_west.value is not None:
-            return self._brightness_west.value  # type: ignore
+            return self._brightness_west.value
         return 0.0
 
     @property
     def wind_speed(self) -> float | None:
         """Return wind speed in m/s."""
-        return self._wind_speed.value  # type: ignore
+        return self._wind_speed.value
 
     @property
     def wind_bearing(self) -> int | None:
@@ -322,12 +322,12 @@ class Weather(Device):
     @property
     def air_pressure(self) -> float | None:
         """Return pressure in Pa."""
-        return self._air_pressure.value  # type: ignore
+        return self._air_pressure.value
 
     @property
     def humidity(self) -> float | None:
         """Return humidity in %."""
-        return self._humidity.value  # type: ignore
+        return self._humidity.value
 
     @property
     def max_brightness(self) -> float:
diff --git a/xknx/remote_value/__init__.py b/xknx/remote_value/__init__.py
index 059427cc..b52a244d 100644
--- a/xknx/remote_value/__init__.py
+++ b/xknx/remote_value/__init__.py
@@ -17,7 +17,7 @@ from .remote_value_dpt_2_byte_unsigned import RemoteValueDpt2ByteUnsigned
 from .remote_value_dpt_value_1_ucount import RemoteValueDptValue1Ucount
 from .remote_value_scaling import RemoteValueScaling
 from .remote_value_scene_number import RemoteValueSceneNumber
-from .remote_value_sensor import RemoteValueSensor
+from .remote_value_sensor import RemoteValueNumeric, RemoteValueSensor
 from .remote_value_setpoint_shift import RemoteValueSetpointShift
 from .remote_value_step import RemoteValueStep
 from .remote_value_string import RemoteValueString
@@ -39,6 +39,7 @@ __all__ = [
     "RemoteValueDateTime",
     "RemoteValueDpt2ByteUnsigned",
     "RemoteValueDptValue1Ucount",
+    "RemoteValueNumeric",
     "RemoteValueOperationMode",
     "RemoteValueScaling",
     "RemoteValueSceneNumber",
diff --git a/xknx/remote_value/remote_value.py b/xknx/remote_value/remote_value.py
index 39824329..0a2f7e05 100644
--- a/xknx/remote_value/remote_value.py
+++ b/xknx/remote_value/remote_value.py
@@ -12,7 +12,6 @@ from abc import ABC, abstractmethod
 import logging
 from typing import (
     TYPE_CHECKING,
-    Any,
     Awaitable,
     Callable,
     Generic,
@@ -101,14 +100,14 @@ class RemoteValue(ABC, Generic[DPTPayloadType, ValueType]):
         return self._value
 
     @value.setter
-    def value(self, value: Any) -> None:
+    def value(self, value: ValueType | None) -> None:
         """Set new value without creating a Telegram or calling after_update_cb. Raises ConversionError on invalid value."""
         if value is not None:
             # raises ConversionError on invalid value
             self.to_knx(value)
         self._value = value
 
-    async def update_value(self, value: Any) -> None:
+    async def update_value(self, value: ValueType | None) -> None:
         """Set new value without creating a Telegram. Awaits after_update_cb. Raises ConversionError on invalid value."""
         self.value = value
         if self.after_update_cb is not None:
@@ -156,7 +155,7 @@ class RemoteValue(ABC, Generic[DPTPayloadType, ValueType]):
         """Convert current payload to value - to be implemented in derived class."""
 
     @abstractmethod
-    def to_knx(self, value: Any) -> DPTPayloadType:
+    def to_knx(self, value: ValueType) -> DPTPayloadType:
         """Convert value to payload - to be implemented in derived class."""
 
     async def process(self, telegram: Telegram, always_callback: bool = False) -> bool:
@@ -214,7 +213,7 @@ class RemoteValue(ABC, Generic[DPTPayloadType, ValueType]):
             )
             await self.xknx.telegrams.put(telegram)
 
-    async def set(self, value: Any, response: bool = False) -> None:
+    async def set(self, value: ValueType, response: bool = False) -> None:
         """Set new value."""
         if not self.initialized:
             logger.info(
@@ -289,7 +288,7 @@ class RemoteValue(ABC, Generic[DPTPayloadType, ValueType]):
             self.group_addr_str(),
         )
 
-    def __eq__(self, other: Any) -> bool:
+    def __eq__(self, other: object) -> bool:
         """Equal operator."""
         for key, value in self.__dict__.items():
             if key == "after_update_cb":
diff --git a/xknx/remote_value/remote_value_sensor.py b/xknx/remote_value/remote_value_sensor.py
index 09f3fbf4..26f980d6 100644
--- a/xknx/remote_value/remote_value_sensor.py
+++ b/xknx/remote_value/remote_value_sensor.py
@@ -6,9 +6,10 @@ for serialization and deserialization of the KNX value.
 """
 from __future__ import annotations
 
-from typing import TYPE_CHECKING, Union
+from abc import abstractmethod
+from typing import TYPE_CHECKING, TypeVar, Union
 
-from xknx.dpt import DPTArray, DPTBase, DPTBinary
+from xknx.dpt import DPTArray, DPTBase, DPTBinary, DPTNumeric
 from xknx.exceptions import ConversionError
 
 from .remote_value import AsyncCallbackType, GroupAddressesType, RemoteValue
@@ -16,9 +17,13 @@ from .remote_value import AsyncCallbackType, GroupAddressesType, RemoteValue
 if TYPE_CHECKING:
     from xknx.xknx import XKNX
 
+ValueType = TypeVar("ValueType")
 
-class RemoteValueSensor(RemoteValue[DPTArray, Union[int, float, str]]):
-    """Abstraction for many different sensor DPT types."""
+
+class _RemoteValueGeneric(RemoteValue[DPTArray, ValueType]):
+    """Abstraction for generic DPT types."""
+
+    dpt_base_class: type[DPTBase]
 
     def __init__(
         self,
@@ -34,7 +39,7 @@ class RemoteValueSensor(RemoteValue[DPTArray, Union[int, float, str]]):
         """Initialize RemoteValueSensor class."""
         if value_type is None:
             raise ConversionError("no value type given", device_name=device_name)
-        _dpt_class = DPTBase.parse_transcoder(value_type)
+        _dpt_class = self.dpt_base_class.parse_transcoder(value_type)
         if _dpt_class is None:
             raise ConversionError(
                 "invalid value type", value_type=value_type, device_name=device_name
@@ -59,13 +64,13 @@ class RemoteValueSensor(RemoteValue[DPTArray, Union[int, float, str]]):
             else None
         )
 
-    def to_knx(self, value: int | float | str) -> DPTArray:
+    def to_knx(self, value: ValueType) -> DPTArray:
         """Convert value to payload."""
         return DPTArray(self.dpt_class.to_knx(value))
 
-    def from_knx(self, payload: DPTArray) -> int | float | str:
+    @abstractmethod
+    def from_knx(self, payload: DPTArray) -> ValueType:
         """Convert current payload to value."""
-        return self.dpt_class.from_knx(payload.value)  # type: ignore
 
     @property
     def unit_of_measurement(self) -> str | None:
@@ -76,3 +81,25 @@ class RemoteValueSensor(RemoteValue[DPTArray, Union[int, float, str]]):
     def ha_device_class(self) -> str | None:
         """Return a string representing the home assistant device class."""
         return getattr(self.dpt_class, "ha_device_class", None)  # type: ignore
+
+
+class RemoteValueSensor(_RemoteValueGeneric[Union[int, float, str]]):
+    """Abstraction for sensor DPT types."""
+
+    dpt_base_class = DPTBase
+    dpt_class: type[DPTBase]
+
+    def from_knx(self, payload: DPTArray) -> int | float | str:
+        """Convert current payload to value."""
+        return self.dpt_class.from_knx(payload.value)  # type: ignore
+
+
+class RemoteValueNumeric(_RemoteValueGeneric[Union[int, float]]):
+    """Abstraction for numeric DPT types."""
+
+    dpt_base_class = DPTNumeric
+    dpt_class: type[DPTNumeric]
+
+    def from_knx(self, payload: DPTArray) -> int | float:
+        """Convert current payload to value."""
+        return self.dpt_class.from_knx(payload.value)


Test patch:
diff --git a/requirements/testing.txt b/requirements/testing.txt
index 35516923..e8ba845f 100644
--- a/requirements/testing.txt
+++ b/requirements/testing.txt
@@ -11,4 +11,4 @@ pytest-asyncio==0.15.1
 pytest-cov==2.12.1
 setuptools==57.0.0
 tox==3.23.1
-mypy==0.901
+mypy==0.902
diff --git a/test/devices_tests/numeric_value_test.py b/test/devices_tests/numeric_value_test.py
new file mode 100644
index 00000000..8fb45f64
--- /dev/null
+++ b/test/devices_tests/numeric_value_test.py
@@ -0,0 +1,285 @@
+"""Unit test for NumericValue objects."""
+from unittest.mock import AsyncMock
+
+import pytest
+from xknx import XKNX
+from xknx.devices import NumericValue
+from xknx.dpt import DPTArray
+from xknx.telegram import GroupAddress, Telegram
+from xknx.telegram.apci import GroupValueRead, GroupValueResponse, GroupValueWrite
+
+
+@pytest.mark.asyncio
+class TestNumericValue:
+    """Test class for NumericValue objects."""
+
+    @pytest.mark.parametrize(
+        "value_type,raw_payload,expected_state",
+        [
+            # DPT-14 values are according to ETS group monitor values
+            (
+                "absolute_temperature",
+                DPTArray((0x44, 0xD7, 0xD2, 0x8B)),
+                1726.579,
+            ),
+            (
+                "angle",
+                DPTArray((0xE4,)),
+                322,
+            ),
+            (
+                "brightness",
+                DPTArray((0xC3, 0x56)),
+                50006,
+            ),
+            (
+                "color_temperature",
+                DPTArray((0x6C, 0x95)),
+                27797,
+            ),
+            (
+                "counter_pulses",
+                DPTArray((0x9D,)),
+                -99,
+            ),
+            (
+                "current",
+                DPTArray((0xCA, 0xCC)),
+                51916,
+            ),
+            (
+                "percent",
+                DPTArray((0xE3,)),
+                89,
+            ),
+            (
+                "percentU8",
+                DPTArray((0x6B,)),
+                107,
+            ),
+            (
+                "percentV8",
+                DPTArray((0x20,)),
+                32,
+            ),
+            (
+                "percentV16",
+                DPTArray((0x8A, 0x2F)),
+                -30161,
+            ),
+            (
+                "pulse",
+                DPTArray((0xFC,)),
+                252,
+            ),
+            (
+                "scene_number",
+                DPTArray((0x1,)),
+                2,
+            ),
+        ],
+    )
+    async def test_sensor_value_types(
+        self,
+        value_type,
+        raw_payload,
+        expected_state,
+    ):
+        """Test sensor value types."""
+        xknx = XKNX()
+        sensor = NumericValue(
+            xknx,
+            "TestSensor",
+            group_address="1/2/3",
+            value_type=value_type,
+        )
+        await sensor.process(
+            Telegram(
+                destination_address=GroupAddress("1/2/3"),
+                payload=GroupValueWrite(value=raw_payload),
+            )
+        )
+
+        assert sensor.resolve_state() == expected_state
+
+    #
+    # TEST RESPOND
+    #
+    async def test_respond_to_read(self):
+        """Test respond_to_read function."""
+        xknx = XKNX()
+        responding = NumericValue(
+            xknx,
+            "TestSensor1",
+            group_address="1/1/1",
+            respond_to_read=True,
+            value_type="volume_liquid_litre",
+        )
+        non_responding = NumericValue(
+            xknx,
+            "TestSensor2",
+            group_address="1/1/1",
+            respond_to_read=False,
+            value_type="volume_liquid_litre",
+        )
+        responding_multiple = NumericValue(
+            xknx,
+            "TestSensor3",
+            group_address=["1/1/1", "3/3/3"],
+            group_address_state="2/2/2",
+            respond_to_read=True,
+            value_type="volume_liquid_litre",
+        )
+        #  set initial payload of NumericValue
+        responding.sensor_value.value = 256
+        non_responding.sensor_value.value = 256
+        responding_multiple.sensor_value.value = 256
+
+        read_telegram = Telegram(
+            destination_address=GroupAddress("1/1/1"), payload=GroupValueRead()
+        )
+        # verify no response when respond is False
+        await non_responding.process(read_telegram)
+        assert xknx.telegrams.qsize() == 0
+
+        # verify response when respond is True
+        await responding.process(read_telegram)
+        assert xknx.telegrams.qsize() == 1
+        response = xknx.telegrams.get_nowait()
+        assert response == Telegram(
+            destination_address=GroupAddress("1/1/1"),
+            payload=GroupValueResponse(DPTArray((0x00, 0x00, 0x01, 0x00))),
+        )
+        # verify no response when GroupValueRead request is not for group_address
+        await responding_multiple.process(read_telegram)
+        assert xknx.telegrams.qsize() == 1
+        response = xknx.telegrams.get_nowait()
+        assert response == Telegram(
+            destination_address=GroupAddress("1/1/1"),
+            payload=GroupValueResponse(DPTArray((0x00, 0x00, 0x01, 0x00))),
+        )
+        await responding_multiple.process(
+            Telegram(
+                destination_address=GroupAddress("2/2/2"), payload=GroupValueRead()
+            )
+        )
+        await responding_multiple.process(
+            Telegram(
+                destination_address=GroupAddress("3/3/3"), payload=GroupValueRead()
+            )
+        )
+        assert xknx.telegrams.qsize() == 0
+
+    #
+    # TEST SET
+    #
+    async def test_set_percent(self):
+        """Test set with percent numeric value."""
+        xknx = XKNX()
+        num_value = NumericValue(
+            xknx, "TestSensor", group_address="1/2/3", value_type="percent"
+        )
+        await num_value.set(75)
+        assert xknx.telegrams.qsize() == 1
+
+        telegram = xknx.telegrams.get_nowait()
+        assert telegram == Telegram(
+            destination_address=GroupAddress("1/2/3"),
+            payload=GroupValueWrite(DPTArray((0xBF,))),
+        )
+
+    async def test_set_temperature(self):
+        """Test set with temperature numeric value."""
+        xknx = XKNX()
+        num_value = NumericValue(
+            xknx, "TestSensor", group_address="1/2/3", value_type="temperature"
+        )
+        await num_value.set(21.0)
+        assert xknx.telegrams.qsize() == 1
+        telegram = xknx.telegrams.get_nowait()
+        assert telegram == Telegram(
+            destination_address=GroupAddress("1/2/3"),
+            payload=GroupValueWrite(DPTArray((0x0C, 0x1A))),
+        )
+        # test HomeAssistant device class
+        assert num_value.ha_device_class() == "temperature"
+
+    #
+    # SYNC
+    #
+    async def test_sync(self):
+        """Test sync function / sending group reads to KNX bus."""
+        xknx = XKNX()
+        sensor = NumericValue(
+            xknx, "TestSensor", value_type="temperature", group_address_state="1/2/3"
+        )
+        await sensor.sync()
+        assert xknx.telegrams.qsize() == 1
+        telegram = xknx.telegrams.get_nowait()
+        assert telegram == Telegram(
+            destination_address=GroupAddress("1/2/3"), payload=GroupValueRead()
+        )
+
+    #
+    # TEST PROCESS
+    #
+    async def test_process(self):
+        """Test process / reading telegrams from telegram queue."""
+        xknx = XKNX()
+        sensor = NumericValue(
+            xknx, "TestSensor", value_type="temperature", group_address="1/2/3"
+        )
+
+        telegram = Telegram(
+            destination_address=GroupAddress("1/2/3"),
+            payload=GroupValueWrite(DPTArray((0x06, 0xA0))),
+        )
+        await sensor.process(telegram)
+        assert sensor.sensor_value.value == 16.96
+        assert sensor.sensor_value.telegram.payload.value == DPTArray((0x06, 0xA0))
+        assert sensor.resolve_state() == 16.96
+
+    async def test_process_callback(self):
+        """Test process / reading telegrams from telegram queue. Test if callback is called."""
+
+        xknx = XKNX()
+        sensor = NumericValue(
+            xknx, "TestSensor", group_address="1/2/3", value_type="temperature"
+        )
+        after_update_callback = AsyncMock()
+        sensor.register_device_updated_cb(after_update_callback)
+
+        telegram = Telegram(
+            destination_address=GroupAddress("1/2/3"),
+            payload=GroupValueWrite(DPTArray((0x01, 0x02))),
+        )
+        await sensor.process(telegram)
+        after_update_callback.assert_called_with(sensor)
+        assert sensor.last_telegram == telegram
+
+    async def test_process_callback_set(self):
+        """Test setting value. Test if callback is called."""
+
+        xknx = XKNX()
+        after_update_callback = AsyncMock()
+        num_value = NumericValue(
+            xknx, "TestSensor", group_address="1/2/3", value_type="temperature"
+        )
+        num_value.register_device_updated_cb(after_update_callback)
+
+        await num_value.set(21.0)
+        await xknx.devices.process(xknx.telegrams.get_nowait())
+        after_update_callback.assert_called_with(num_value)
+
+    def test_string(self):
+        """Test NumericValue string representation."""
+
+        xknx = XKNX()
+        value = NumericValue(
+            xknx, "Num", group_address="1/2/3", value_type="temperature"
+        )
+        value.sensor_value.value = 4.9
+        assert (
+            str(value)
+            == '<NumericValue name="Num" addresses=<1/2/3, None, [], 4.9 /> value=4.9 unit="°C"/>'
+        )
diff --git a/test/devices_tests/sensor_test.py b/test/devices_tests/sensor_test.py
index 008a8197..085d0dc8 100644
--- a/test/devices_tests/sensor_test.py
+++ b/test/devices_tests/sensor_test.py
@@ -775,6 +775,8 @@ class TestSensor:
         assert sensor.sensor_value.value == 16.96
         assert sensor.sensor_value.telegram.payload.value == DPTArray((0x06, 0xA0))
         assert sensor.resolve_state() == 16.96
+        # test HomeAssistant device class
+        assert sensor.ha_device_class() == "temperature"
 
     async def test_process_callback(self):
         """Test process / reading telegrams from telegram queue. Test if callback is called."""
diff --git a/test/remote_value_tests/remote_value_sensor_test.py b/test/remote_value_tests/remote_value_sensor_test.py
index f4ebbfd5..9b152b07 100644
--- a/test/remote_value_tests/remote_value_sensor_test.py
+++ b/test/remote_value_tests/remote_value_sensor_test.py
@@ -3,19 +3,58 @@ import pytest
 from xknx import XKNX
 from xknx.dpt import DPTBase
 from xknx.exceptions import ConversionError
-from xknx.remote_value import RemoteValueSensor
+from xknx.remote_value import RemoteValueNumeric, RemoteValueSensor
 
 
 class TestRemoteValueSensor:
     """Test class for RemoteValueSensor objects."""
 
+    def test_value_type(self):
+        """Test initializing a value_type."""
+        xknx = XKNX()
+        assert RemoteValueSensor(xknx=xknx, value_type="pulse")
+        assert RemoteValueSensor(xknx=xknx, value_type=9)
+        assert RemoteValueSensor(xknx=xknx, value_type="9.021")
+        assert RemoteValueSensor(xknx=xknx, value_type="string")
+
     def test_wrong_value_type(self):
         """Test initializing with wrong value_type."""
         xknx = XKNX()
         with pytest.raises(ConversionError):
             RemoteValueSensor(xknx=xknx, value_type="wrong_value_type")
+        with pytest.raises(ConversionError):
+            RemoteValueSensor(xknx=xknx, value_type="binary")
+        with pytest.raises(ConversionError):
+            RemoteValueSensor(xknx=xknx, value_type=1)
+        with pytest.raises(ConversionError):
+            RemoteValueSensor(xknx=xknx, value_type=2)
+        with pytest.raises(ConversionError):
+            RemoteValueSensor(xknx=xknx)
 
     def test_payload_length_defined(self):
         """Test if all members of DPTMAP implement payload_length."""
         for dpt_class in DPTBase.__recursive_subclasses__():
             assert isinstance(dpt_class.payload_length, int)
+
+
+class TestRemoteValueNumeric:
+    """Test class for RemoteValueNumeric objects."""
+
+    def test_value_type(self):
+        """Test initializing a value_type."""
+        xknx = XKNX()
+        assert RemoteValueNumeric(xknx=xknx, value_type="pulse")
+        assert RemoteValueNumeric(xknx=xknx, value_type=9)
+        assert RemoteValueNumeric(xknx=xknx, value_type="9.021")
+
+    def test_wrong_value_type(self):
+        """Test initializing with wrong value_type."""
+        xknx = XKNX()
+        with pytest.raises(ConversionError):
+            RemoteValueNumeric(xknx=xknx, value_type="string")
+        with pytest.raises(ConversionError):
+            RemoteValueNumeric(xknx=xknx, value_type=16)
+        with pytest.raises(ConversionError):
+            RemoteValueNumeric(xknx=xknx, value_type="binary")
+        with pytest.raises(ConversionError):
+            RemoteValueNumeric(xknx=xknx)