Repository: XKNX/xknx
Pull request #1302 (2023-07-22 09:45:45)

Fix conversion of rounded negative zero values to DPT 9
<!--
  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.
-->
We did handle converting values near 0 to DPT 9 just like ETS.
ETS would convert values < 0 and >= -0.005 to `0x8000` which is equivalent to `-20.48`. 
These values are now rounded to 0.

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.
-->

- [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

- [ ] 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/1302?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=XKNX) Report
> Merging [#1302](https://app.codecov.io/gh/XKNX/xknx/pull/1302?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=XKNX) (1a46cf6) into [main](https://app.codecov.io/gh/XKNX/xknx/commit/7fcaf2a9e0b962b96fd4890b4c31f3cea8996b26?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=XKNX) (7fcaf2a) will **increase** coverage by `0.00%`.
> The diff coverage is `100.00%`.

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

```diff
@@           Coverage Diff           @@
##             main    #1302   +/-   ##
=======================================
  Coverage   96.55%   96.55%           
=======================================
  Files         150      150           
  Lines        9580     9582    +2     
=======================================
+ Hits         9250     9252    +2     
  Misses        330      330           
```


| [Impacted Files](https://app.codecov.io/gh/XKNX/xknx/pull/1302?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=XKNX) | Coverage Δ | |
|---|---|---|
| [xknx/dpt/dpt\_2byte\_float.py](https://app.codecov.io/gh/XKNX/xknx/pull/1302?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=XKNX#diff-eGtueC9kcHQvZHB0XzJieXRlX2Zsb2F0LnB5) | `100.00% <100.00%> (ø)` | |

marvin-w: Nice! Looks good!

Patch:
diff --git a/docs/changelog.md b/docs/changelog.md
index d69ebc67..d5bf712a 100644
--- a/docs/changelog.md
+++ b/docs/changelog.md
@@ -6,6 +6,12 @@ nav_order: 2
 
 # Changelog
 
+# Unreleased changes
+
+### Bugfixes
+
+- Fix DPT 9 handling of values < `0` and >= `-0.005`. These are now rounded to `0` instead of being sent as `-20.48`.
+
 # 2.11.1 DateTime fix 2023-06-26
 
 ### Bugfixes
diff --git a/xknx/dpt/dpt_2byte_float.py b/xknx/dpt/dpt_2byte_float.py
index 8cf74985..6ab3f7ca 100644
--- a/xknx/dpt/dpt_2byte_float.py
+++ b/xknx/dpt/dpt_2byte_float.py
@@ -50,19 +50,27 @@ class DPT2ByteFloat(DPTNumeric):
     def to_knx(cls, value: float) -> DPTArray:
         """Serialize to KNX/IP raw data."""
         try:
-            knx_value = float(value)
-            if not cls._test_boundaries(knx_value):
+            value = float(value)
+            if not cls._test_boundaries(value):
                 raise ValueError
 
-            value = knx_value * 100
+            knx_value = value * 100
+            if round(knx_value) == 0:
+                # ETS converts values near 0 like this
+                # -0.0051 -> 0x87FF
+                # -0.005 -> 0x8000  ... we don't want this since its equivalent to -20.48
+                # 0.005 -> 0x0000
+                # 0.0051 -> 0x0001
+                return DPTArray((0x00, 0x00))
+
             exponent = 0
-            while not -2048 <= value <= 2047:
+            while not -2048 <= knx_value <= 2047:
                 exponent += 1
-                value /= 2
+                knx_value /= 2
 
-            mantisse = int(round(value)) & 0x7FF
+            mantisse = int(round(knx_value)) & 0x7FF
             msb = exponent << 3 | mantisse >> 8
-            if value < 0:
+            if knx_value < 0:
                 msb |= 0x80
 
             return DPTArray((msb, mantisse & 0xFF))


Test patch:
diff --git a/test/dpt_tests/dpt_float_test.py b/test/dpt_tests/dpt_float_test.py
index b4955099..e985e907 100644
--- a/test/dpt_tests/dpt_float_test.py
+++ b/test/dpt_tests/dpt_float_test.py
@@ -46,6 +46,24 @@ class TestDPTFloat:
         assert DPT2ByteFloat.to_knx(0.00) == DPTArray((0x00, 0x00))
         assert DPT2ByteFloat.from_knx(DPTArray((0x00, 0x00))) == 0.00
 
+    def test_near_zero_value(self):
+        """Test parsing and streaming of DPT2ByteFloat near zero value."""
+        assert DPT2ByteFloat.to_knx(0.0002) == DPTArray((0x00, 0x00))
+        assert DPT2ByteFloat.to_knx(0.005) == DPTArray((0x00, 0x00))
+        assert DPT2ByteFloat.to_knx(0.00501) == DPTArray((0x00, 0x01))
+
+        assert DPT2ByteFloat.to_knx(-0.0) == DPTArray((0x00, 0x00))
+        # ETS would convert values < 0 and >= -0.005 to 0x8000
+        # which is equivalent to -20.48 so we handle this differently
+        assert DPT2ByteFloat.to_knx(-0.0002) == DPTArray((0x00, 0x00))
+        assert DPT2ByteFloat.to_knx(-0.005) == DPTArray((0x00, 0x00))
+        assert DPT2ByteFloat.to_knx(-0.00501) == DPTArray(
+            (0x87, 0xFF)
+        )  # this is ETS-conform again
+
+        assert DPT2ByteFloat.from_knx(DPTArray((0x00, 0x01))) == 0.01
+        assert DPT2ByteFloat.from_knx(DPTArray((0x87, 0xFF))) == -0.01
+
     def test_room_temperature(self):
         """Test parsing and streaming of DPT2ByteFloat 21.00. Room temperature."""
         assert DPT2ByteFloat.to_knx(21.00) == DPTArray((0x0C, 0x1A))