Repository: XKNX/xknx (python)

Issue:
DPT 7.* and 8.* should consider resolution
**Description of problem:**
For a number of less frequently used data point types, the numeric content is not displayed/converted correctly. I noticed this because my smart meter displays an unbalanced load (DPT 8.010) that is too high by a factor of 100.

Specifically, these are DPT 7.* and 8.*, which have a resolution other than 1 as specified in the KNX standard. Here the unsigned number must be multiplied accordingly, otherwise the display will not be correct:

- 7.003 time_period_10msec
- 7.004 time_period_100msec
- 8.003 delta_time_10ms
- 8.004 delta_time_100ms
- 8.010 percentV16

This behaviour is specified in the KNX standard (03_07_02 Datapoint Types v01.08.02 AS.pdf, Sections 3.8 and 3.9.)

- [ ] using xknx standalone
- [X] using Home-Assistant knx integration

**Version information:**
- xknx / Home-Assistant release with the issue: 2024.6.4
- last working xknx / Home-Assistant release (if known): none

**KNX installation:**
(Mind the German locale)
- ETS 5.7.7 displays the DPT 8.010 value correctly as: "Prozentdifferenz (%) 0B BC | 30,04 %"
- In HomeAssistant, the corresponding percentV16 value appears as: "3.004 %"

Pull request description:
Fix scaling issues in DPT 7.* and 8.*
<!--
  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.
-->
For some data point types, the numeric content has to be scaled by a fixed resolution in order to be displayed/converted correctly. Specifically, these are DPT 7.* and 8.*, which have a resolution other than 1 as specified in the KNX standard. Here, the integer number must be multiplied accordingly:

7.003 time_period_10msec
7.004 time_period_100msec
8.003 delta_time_10ms
8.004 delta_time_100ms
8.010 percentV16

This is specified in the KNX standard (03_07_02 Datapoint Types v01.08.02 AS.pdf, Sections 3.8 and 3.9.)

Fixes #1501

## 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)
- [ ] 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

- [X] 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)

Once this fix reaches the Home Assistant integration, the respective documentation should be updated accordingly.

Patch:
diff --git a/docs/changelog.md b/docs/changelog.md
index 0693ffa5..52a2f0e3 100644
--- a/docs/changelog.md
+++ b/docs/changelog.md
@@ -21,6 +21,10 @@ nav_order: 2
   - ValueReader: `.send_group_read`
 - Rename DPT transcoder modules for schema `xknx.dpt.dpt_<main-number>.py`
 
+### Bugfixes
+
+- Fix value scaling for sensor types: time_period_100msec, time_period_10msec, delta_time_10ms, delta_time_100ms, percentV16
+
 ### Features
 
 - Added eager telegram data decoding for GroupValueWrite / GroupValueResponse Telegrams. DPTs for group addresses can be set using `xknx.group_address_dpt.set()`. `Telegram` has a new attribute `decoded_data` which is set when a decoder was found.
diff --git a/xknx/dpt/dpt_7.py b/xknx/dpt/dpt_7.py
index bb0a0de2..fd0f85e1 100644
--- a/xknx/dpt/dpt_7.py
+++ b/xknx/dpt/dpt_7.py
@@ -30,13 +30,13 @@ class DPT2ByteUnsigned(DPTNumeric):
     def from_knx(cls, payload: DPTArray | DPTBinary) -> int:
         """Parse/deserialize from KNX/IP raw data."""
         raw = cls.validate_payload(payload)
-        return (raw[0] * 256) + raw[1]
+        return ((raw[0] * 256) + raw[1]) * cls.resolution
 
     @classmethod
     def to_knx(cls, value: int | float) -> DPTArray:
         """Serialize to KNX/IP raw data."""
         try:
-            knx_value = int(value)
+            knx_value = int(value) // cls.resolution
             if not cls._test_boundaries(knx_value):
                 raise ValueError("Value out of range")
             return DPTArray((knx_value >> 8, knx_value & 0xFF))
@@ -75,6 +75,7 @@ class DPTTimePeriod10Msec(DPT2ByteUnsigned):
     dpt_main_number = 7
     dpt_sub_number = 3
     value_type = "time_period_10msec"
+    resolution = 10
     unit = "ms"
 
 
@@ -84,6 +85,7 @@ class DPTTimePeriod100Msec(DPT2ByteUnsigned):
     dpt_main_number = 7
     dpt_sub_number = 4
     value_type = "time_period_100msec"
+    resolution = 100
     unit = "ms"
 
 
diff --git a/xknx/dpt/dpt_8.py b/xknx/dpt/dpt_8.py
index ad552d80..e03df5d9 100644
--- a/xknx/dpt/dpt_8.py
+++ b/xknx/dpt/dpt_8.py
@@ -29,17 +29,17 @@ class DPT2ByteSigned(DPTNumeric):
 
     value_min = -32768
     value_max = 32767
-    resolution = 1
+    resolution: float = 1
 
     _struct_format = ">h"
 
     @classmethod
-    def from_knx(cls, payload: DPTArray | DPTBinary) -> int:
+    def from_knx(cls, payload: DPTArray | DPTBinary) -> int | float:
         """Parse/deserialize from KNX/IP raw data."""
         raw = cls.validate_payload(payload)
 
         try:
-            return struct.unpack(cls._struct_format, bytes(raw))[0]  # type: ignore[no-any-return]
+            return struct.unpack(cls._struct_format, bytes(raw))[0] * cls.resolution  # type: ignore[no-any-return]
         except struct.error as err:
             raise ConversionError(f"Could not parse {cls.__name__}", raw=raw) from err
 
@@ -47,7 +47,7 @@ class DPT2ByteSigned(DPTNumeric):
     def to_knx(cls, value: int | float) -> DPTArray:
         """Serialize to KNX/IP raw data."""
         try:
-            knx_value = int(value)
+            knx_value = int(float(value) / cls.resolution)
             if not cls._test_boundaries(knx_value):
                 raise ValueError("Value out of range")
             return DPTArray(struct.pack(cls._struct_format, knx_value))
@@ -86,6 +86,7 @@ class DPTDeltaTime10Msec(DPT2ByteSigned):
     dpt_main_number = 8
     dpt_sub_number = 3
     value_type = "delta_time_10ms"
+    resolution = 10
     unit = "ms"
 
 
@@ -95,6 +96,7 @@ class DPTDeltaTime100Msec(DPT2ByteSigned):
     dpt_main_number = 8
     dpt_sub_number = 4
     value_type = "delta_time_100ms"
+    resolution = 100
     unit = "ms"
 
 
@@ -131,6 +133,7 @@ class DPTPercentV16(DPT2ByteSigned):
     dpt_main_number = 8
     dpt_sub_number = 10
     value_type = "percentV16"
+    resolution = 0.01
     unit = "%"
 
 


Test patch:
diff --git a/test/devices_tests/numeric_value_test.py b/test/devices_tests/numeric_value_test.py
index b180a064..45298174 100644
--- a/test/devices_tests/numeric_value_test.py
+++ b/test/devices_tests/numeric_value_test.py
@@ -66,7 +66,7 @@ class TestNumericValue:
             (
                 "percentV16",
                 DPTArray((0x8A, 0x2F)),
-                -30161,
+                -301.61,
             ),
             (
                 "pulse",
diff --git a/test/devices_tests/sensor_expose_loop_test.py b/test/devices_tests/sensor_expose_loop_test.py
index 4432d389..564acd47 100644
--- a/test/devices_tests/sensor_expose_loop_test.py
+++ b/test/devices_tests/sensor_expose_loop_test.py
@@ -95,7 +95,7 @@ class TestSensorExposeLoop:
         ("percent", DPTArray((0xE3,)), 89),
         ("percentU8", DPTArray((0x6B,)), 107),
         ("percentV8", DPTArray((0x20,)), 32),
-        ("percentV16", DPTArray((0x8A, 0x2F)), -30161),
+        ("percentV16", DPTArray((0x8A, 0x2F)), -301.61),
         ("phaseanglerad", DPTArray((0x45, 0x54, 0xAC, 0x2D)), 3402.761),
         ("phaseangledeg", DPTArray((0xC5, 0x25, 0x13, 0x37)), -2641.201),
         ("power", DPTArray((0x45, 0xCB, 0xE2, 0x5C)), 6524.295),
@@ -153,8 +153,8 @@ class TestSensorExposeLoop:
         ("thermoelectric_power", DPTArray((0x41, 0xCF, 0x9E, 0x4F)), 25.9523),
         ("time_1", DPTArray((0x5E, 0x1E)), 32071.68),
         ("time_2", DPTArray((0xFB, 0x29)), -405995.52),
-        ("time_period_100msec", DPTArray((0x6A, 0x35)), 27189),
-        ("time_period_10msec", DPTArray((0x32, 0x3)), 12803),
+        ("time_period_100msec", DPTArray((0x6A, 0x35)), 2718900),
+        ("time_period_10msec", DPTArray((0x32, 0x3)), 128030),
         ("time_period_hrs", DPTArray((0x29, 0xDE)), 10718),
         ("time_period_min", DPTArray((0x0, 0x54)), 84),
         ("time_period_msec", DPTArray((0x93, 0xC7)), 37831),
diff --git a/test/devices_tests/sensor_test.py b/test/devices_tests/sensor_test.py
index 7487b7e8..176fa376 100644
--- a/test/devices_tests/sensor_test.py
+++ b/test/devices_tests/sensor_test.py
@@ -416,7 +416,7 @@ class TestSensor:
             (
                 "percentV16",
                 DPTArray((0x8A, 0x2F)),
-                -30161,
+                -301.61,
             ),
             (
                 "phaseanglerad",
@@ -591,12 +591,12 @@ class TestSensor:
             (
                 "time_period_100msec",
                 DPTArray((0x6A, 0x35)),
-                27189,
+                2718900,
             ),
             (
                 "time_period_10msec",
                 DPTArray((0x32, 0x3)),
-                12803,
+                128030,
             ),
             (
                 "time_period_hrs",