Repository: XKNX/xknx

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 %"

Discussion:
cuveland: I have followed the convention here of how ETS 5.7.7 indicates the values of the data points. It uses the base unit "ms" even for resolutions greater than one and adds zeros accordingly. However, the KNX specification specifies the value ranges for the four time-related data point types as decimal numbers in seconds - so the base unit is not necessarily used here.

For xknx, it would possibly make sense to switch to 's' with decimal places, as the values in the Home Assistant visualization, for example, are easier to record: _123,4 s_ instead of _123400 ms_. If desired, this would be a simple change.
codecov[bot]: ## [Codecov](https://app.codecov.io/gh/XKNX/xknx/pull/1502?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.62%. Comparing base [(`8a9312f`)](https://app.codecov.io/gh/XKNX/xknx/commit/8a9312f0e3d63acdc8a18cfbd725a8da5639b338?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=XKNX) to head [(`9e30164`)](https://app.codecov.io/gh/XKNX/xknx/commit/9e30164b884596c87956b11a41b709b1c5fd4918?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=XKNX).

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


[![Impacted file tree graph](https://app.codecov.io/gh/XKNX/xknx/pull/1502/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/1502?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=XKNX)

```diff
@@           Coverage Diff           @@
##             main    #1502   +/-   ##
=======================================
  Coverage   96.62%   96.62%           
=======================================
  Files         156      156           
  Lines       10149    10154    +5     
=======================================
+ Hits         9806     9811    +5     
  Misses        343      343           
```

| [Files](https://app.codecov.io/gh/XKNX/xknx/pull/1502?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=XKNX) | Coverage Δ | |
|---|---|---|
| [xknx/dpt/dpt\_7.py](https://app.codecov.io/gh/XKNX/xknx/pull/1502?src=pr&el=tree&filepath=xknx%2Fdpt%2Fdpt_7.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=XKNX#diff-eGtueC9kcHQvZHB0XzcucHk=) | `100.00% <100.00%> (ø)` | |
| [xknx/dpt/dpt\_8.py](https://app.codecov.io/gh/XKNX/xknx/pull/1502?src=pr&el=tree&filepath=xknx%2Fdpt%2Fdpt_8.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=XKNX#diff-eGtueC9kcHQvZHB0XzgucHk=) | `100.00% <100.00%> (ø)` | |

</details>
farmio: > For xknx, it would possibly make sense to switch to 's' with decimal places, as the values in the Home Assistant visualization, for example, are easier to record: 123,4 s instead of 123400 ms. If desired, this would be a simple change.

I'd say: stick to `ms` here for following reasons:
- value type for all DPT 7 and 8 is `int` - we would need to switch to `float` which may introduce rounding errors or such that aren't necessary here
- a HA sensor with device_class `duration` can work fine with `ms` as native_unit_of_measurement and convert for UI purposes internally
- all these time-DPT with resolution other than `1` have a footnote in the specs
  > Not allowed for run-time communication. This DPT shall only be used for parameters and diagnostic data or if
specified as such in a FB specification.
  
  which I would interpret as "only use this for parameters" - and we may want to be as specific as possible there (rounding)
farmio: I did try DPT 8.010 on ETS 5.7.7 and 6.2.0 and they seem to be implemented differently there.

while ETS 5 seems to have float calculation issues
![dpt8010 ets5](https://github.com/XKNX/xknx/assets/12422879/cf3f3c73-7138-4ac9-8069-13b4e7c48a68)

ETS 6 ignores resolution for 8.010 completely (like xknx did) and it happily displays `0x7FFF` as "32767 %" (which according to specs denotes "invalid" data). Decimal values in the input field are rounded 0,5 -> 0%; 0,6 -> 1% 🙃
![dpt8010](https://github.com/XKNX/xknx/assets/12422879/4f11890f-5c3f-4128-94ca-f64447993bd6)

Not really sure what to make of this. 
The time DPTs are implemented just as your change did it.

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",