Repository: XKNX/xknx
Pull request #1586 (2024-10-19 21:03:05)

Climate: Added humidity support
<!--
  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.
-->
Humidity support for climate device.
I want to add it to Home Assistant.

## 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

- [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)

codecov[bot]: ## [Codecov](https://app.codecov.io/gh/XKNX/xknx/pull/1586?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.92%. Comparing base [(`7f6bcfb`)](https://app.codecov.io/gh/XKNX/xknx/commit/7f6bcfb6cc0dfd60c0bfc813e7ec5f39147a0d4f?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=XKNX) to head [(`7688681`)](https://app.codecov.io/gh/XKNX/xknx/commit/76886810595391544a6b691a409e511fa951a762?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/1586/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/1586?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=XKNX)

```diff
@@           Coverage Diff           @@
##             main    #1586   +/-   ##
=======================================
  Coverage   96.92%   96.92%           
=======================================
  Files         157      158    +1     
  Lines       10569    10577    +8     
=======================================
+ Hits        10244    10252    +8     
  Misses        325      325           
```

| [Files with missing lines](https://app.codecov.io/gh/XKNX/xknx/pull/1586?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.py](https://app.codecov.io/gh/XKNX/xknx/pull/1586?src=pr&el=tree&filepath=xknx%2Fdevices%2Fclimate.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=XKNX#diff-eGtueC9kZXZpY2VzL2NsaW1hdGUucHk=) | `100.00% <100.00%> (ø)` | |
| [xknx/remote\_value/\_\_init\_\_.py](https://app.codecov.io/gh/XKNX/xknx/pull/1586?src=pr&el=tree&filepath=xknx%2Fremote_value%2F__init__.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=XKNX#diff-eGtueC9yZW1vdGVfdmFsdWUvX19pbml0X18ucHk=) | `100.00% <100.00%> (ø)` | |
| [xknx/remote\_value/remote\_value\_humidity.py](https://app.codecov.io/gh/XKNX/xknx/pull/1586?src=pr&el=tree&filepath=xknx%2Fremote_value%2Fremote_value_humidity.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=XKNX#diff-eGtueC9yZW1vdGVfdmFsdWUvcmVtb3RlX3ZhbHVlX2h1bWlkaXR5LnB5) | `100.00% <100.00%> (ø)` | |

</details>
farmio: Hi 👋! 
Thank you very much for your contribution!

Since this humidity value has purely informational character and no additional value calculations are needed, I wonder if it would be an option to just use an additional `xknx.devices.Sensor` class in HAs `ClimateEntity` implementation instead of piping it through `xknx.devices.Climate`. A multi-xknx-device entity could be used in some other places in the HA integration too. Just a thought, let me know what you think... 
alexkn: Thank you for your quick feedback.

The humidity is part Home Assistant Climate entity.
Also the sensor is build into the same hardware.
So I think it is consistent to add it to the xknx climate device.

farmio: Are you interested in adding climate to UI configurable entities in HA too?
alexkn: > Are you interested in adding climate to UI configurable entities in HA too?

Maybe. But with my limited Python/HomeAssistant code knowledge, I guess I don't have enough time for that at the moment.

Patch:
diff --git a/docs/changelog.md b/docs/changelog.md
index 7414990f..e53f9099 100644
--- a/docs/changelog.md
+++ b/docs/changelog.md
@@ -6,6 +6,10 @@ nav_order: 2
 
 # Changelog
 
+### Devices
+
+- Climate: Added humidity support
+
 # 3.2.0 Climate Fan speed 2024-09-23
 
 ### Devices
diff --git a/docs/climate.md b/docs/climate.md
index 5fd0111d..6a4655bc 100644
--- a/docs/climate.md
+++ b/docs/climate.md
@@ -31,6 +31,7 @@ Climate are representations of KNX HVAC/Climate controls.
 - `on_off_invert` Invert on/off. Default: `False`
 - `group_address_active_state` KNX address for reading if the climate device is currently active. *DPT 1*
 - `group_address_command_value_state` KNX address for reading the current command value / valve position in %. *DPT 5.001*
+- `group_address_humidity_state` KNX address of current room humidity. *DPT 9.007*
 - `sync_state` defines if and how often the value should be actively read from the bus. If `False` no GroupValueRead telegrams will be sent to its group address. Defaults to `True`
 - `max_temp` Maximum value for target temperature.
 - `min_temp` Minimum value for target temperature.
@@ -94,6 +95,7 @@ climate = Climate(
     mode=climate_mode,
     device_updated_cb=None,
     fan_speed_mode=FanSpeedMode.STEP,
+    group_address_humidity_state='',
 )
 xknx.devices.async_add(climate)
 xknx.devices.async_add(climate_mode)
diff --git a/xknx/devices/climate.py b/xknx/devices/climate.py
index dc54c75b..aa57eb0c 100644
--- a/xknx/devices/climate.py
+++ b/xknx/devices/climate.py
@@ -16,6 +16,7 @@ from xknx.remote_value import (
     GroupAddressesType,
     RemoteValue,
     RemoteValueDptValue1Ucount,
+    RemoteValueNumeric,
     RemoteValueScaling,
     RemoteValueSetpointShift,
     RemoteValueSwitch,
@@ -68,6 +69,7 @@ class Climate(Device):
         group_address_fan_speed: GroupAddressesType = None,
         group_address_fan_speed_state: GroupAddressesType = None,
         fan_speed_mode: FanSpeedMode = FanSpeedMode.PERCENT,
+        group_address_humidity_state: GroupAddressesType = None,
     ):
         """Initialize Climate class."""
         super().__init__(xknx, name, device_updated_cb)
@@ -168,6 +170,16 @@ class Climate(Device):
 
         self.mode = mode
 
+        self.humidity = RemoteValueNumeric(
+            xknx,
+            group_address_state=group_address_humidity_state,
+            sync_state=sync_state,
+            value_type="humidity",
+            device_name=self.name,
+            feature_name="Current humidity",
+            after_update_cb=self.after_update,
+        )
+
     def _iter_remote_values(self) -> Iterator[RemoteValue[Any]]:
         """Iterate the devices RemoteValue classes."""
         yield self.temperature
@@ -177,6 +189,7 @@ class Climate(Device):
         yield self.active
         yield self.command_value
         yield self.fan_speed
+        yield self.humidity
 
     def has_group_address(self, group_address: DeviceGroupAddress) -> bool:
         """Test if device has given group address."""


Test patch:
diff --git a/test/devices_tests/climate_test.py b/test/devices_tests/climate_test.py
index 63fef737..8a2dddee 100644
--- a/test/devices_tests/climate_test.py
+++ b/test/devices_tests/climate_test.py
@@ -11,6 +11,7 @@ from xknx.dpt import (
     DPT2ByteFloat,
     DPTArray,
     DPTBinary,
+    DPTHumidity,
     DPTHVACContrMode,
     DPTHVACMode,
     DPTHVACStatus,
@@ -84,6 +85,7 @@ class TestClimate:
             group_address_setpoint_shift_state="1/2/4",
             group_address_on_off="1/2/11",
             group_address_on_off_state="1/2/12",
+            group_address_humidity_state="1/2/16",
         )
 
         assert climate.has_group_address(GroupAddress("1/2/1"))
@@ -91,6 +93,7 @@ class TestClimate:
         assert climate.has_group_address(GroupAddress("1/2/4"))
         assert climate.has_group_address(GroupAddress("1/2/11"))
         assert climate.has_group_address(GroupAddress("1/2/12"))
+        assert climate.has_group_address(GroupAddress("1/2/16"))
         assert not climate.has_group_address(GroupAddress("1/2/99"))
 
     #
@@ -1073,6 +1076,15 @@ class TestClimate:
         telegram1 = xknx.telegrams.get_nowait()
         assert telegram1 == Telegram(GroupAddress("1/2/4"), payload=GroupValueRead())
 
+    async def test_sync_humidity(self):
+        """Test sync function / sending group reads to KNX bus for humidity."""
+        xknx = XKNX()
+        climate = Climate(xknx, "TestClimate", group_address_humidity_state="1/2/16")
+        await climate.sync()
+        assert xknx.telegrams.qsize() == 1
+        telegram1 = xknx.telegrams.get_nowait()
+        assert telegram1 == Telegram(GroupAddress("1/2/16"), payload=GroupValueRead())
+
     #
     # TEST PROCESS
     #
@@ -1258,6 +1270,22 @@ class TestClimate:
         climate_mode.process(telegram)
         assert climate_mode.controller_mode == HVACControllerMode.HEAT
 
+    async def test_process_humidity(self):
+        """Test process / reading telegrams from telegram queue. Test if humidity is processed correctly."""
+        xknx = XKNX()
+        climate = Climate(xknx, "TestClimate", group_address_humidity_state="1/2/16")
+        after_update_callback = Mock()
+        climate.register_device_updated_cb(after_update_callback)
+
+        telegram = Telegram(
+            destination_address=GroupAddress("1/2/16"),
+            payload=GroupValueWrite(DPTHumidity.to_knx(45.6)),
+        )
+        climate.process(telegram)
+
+        assert climate.humidity.value == 45.6
+        after_update_callback.assert_called_with(climate)
+
     #
     # SUPPORTED OPERATION MODES
     #