Repository: XKNX/xknx
Pull request #1707 (2025-07-17 12:36:33)

Add `group_addresses` method to `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.
-->
cc @philippwaller regarding https://github.com/XKNX/knx-frontend/pull/235#issuecomment-3070219859

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.
-->

- [ ] 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)


Patch:
diff --git a/docs/changelog.md b/docs/changelog.md
index 4f000a91..d642c28a 100644
--- a/docs/changelog.md
+++ b/docs/changelog.md
@@ -8,6 +8,10 @@ nav_order: 2
 
 # Unreleased changes
 
+### Devices
+
+- Add `group_addresses()` method to `Device` (and `RemoteValue`) to get all configured group addresses.
+
 ### Internal
 
 - Use `repr()` for values in exceptions.
diff --git a/docs/devices.md b/docs/devices.md
index ca2deeeb..156096a7 100644
--- a/docs/devices.md
+++ b/docs/devices.md
@@ -19,6 +19,7 @@ An instantiated device can be added to `xknx.devices` to receive telegrams and s
 * `device_updated_cb` List of callbacks for each update.
 * `group_address*` Group address for a specific function. If a list is passed the first element is used for sending / reading,  the rest are passively updating state (listening group address).
 
+* `group_addresses()` Returns a set of all configured group addresses of this Device.
 * `has_group_address(group_address)` Test if device has given group address.
 * `sync(wait_for_result)` Read states of device from KNX bus via GroupValueRead requests.
 * `register_device_updated_cb(device_updated_cb)` Register device updated callback.
diff --git a/xknx/devices/climate.py b/xknx/devices/climate.py
index f4e1ad80..86978d38 100644
--- a/xknx/devices/climate.py
+++ b/xknx/devices/climate.py
@@ -215,11 +215,19 @@ class Climate(Device):
         yield self.horizontal_swing
         yield self.humidity
 
+    def group_addresses(self) -> set[DeviceGroupAddress]:
+        """Return all group addresses of this Device."""
+        if self.mode is None:
+            return super().group_addresses()
+        return super().group_addresses() | self.mode.group_addresses()
+
     def has_group_address(self, group_address: DeviceGroupAddress) -> bool:
         """Test if device has given group address."""
-        if self.mode is not None and self.mode.has_group_address(group_address):
-            return True
-        return super().has_group_address(group_address)
+        return super().has_group_address(group_address) or (
+            self.mode.has_group_address(group_address)
+            if self.mode is not None
+            else False
+        )
 
     @property
     def is_on(self) -> bool:
diff --git a/xknx/devices/device.py b/xknx/devices/device.py
index 21381669..6a33f540 100644
--- a/xknx/devices/device.py
+++ b/xknx/devices/device.py
@@ -64,6 +64,10 @@ class Device(ABC):
         # yield from (<list all used RemoteValue instances>)
         yield from ()
 
+    def group_addresses(self) -> set[DeviceGroupAddress]:
+        """Return all group addresses of this Device."""
+        return {ga for rv in self._iter_remote_values() for ga in rv.group_addresses()}
+
     def register_device_updated_cb(
         self, device_updated_cb: DeviceCallbackType[Self]
     ) -> None:
@@ -127,7 +131,7 @@ class Device(ABC):
     def has_group_address(self, group_address: DeviceGroupAddress) -> bool:
         """Test if device has given group address."""
         return any(
-            remote_value.has_group_address(group_address)
+            group_address in remote_value.group_addresses()
             for remote_value in self._iter_remote_values()
         )
 
diff --git a/xknx/remote_value/remote_value.py b/xknx/remote_value/remote_value.py
index 90dd2762..d69cec25 100644
--- a/xknx/remote_value/remote_value.py
+++ b/xknx/remote_value/remote_value.py
@@ -86,6 +86,14 @@ class RemoteValue(ABC, Generic[ValueT]):
         self.after_update_cb: RVCallbackType[ValueT] | None = after_update_cb
         self._sync_state = sync_state
 
+    def group_addresses(self) -> Iterator[DeviceGroupAddress]:
+        """Return all configured group addresses of this RemoteValue."""
+        if self.group_address is not None:
+            yield self.group_address
+        if self.group_address_state is not None:
+            yield self.group_address_state
+        yield from self.passive_group_addresses
+
     def register_state_updater(self) -> None:
         """Register RemoteValue for state updates."""
         sync_state = (
@@ -144,17 +152,6 @@ class RemoteValue(ABC, Generic[ValueT]):
         """Evaluate if remote value has a group_address set."""
         return bool(self.group_address)
 
-    def has_group_address(self, group_address: DeviceGroupAddress) -> bool:
-        """Test if device has given group address."""
-
-        def remote_value_addresses() -> Iterator[DeviceGroupAddress | None]:
-            """Yield all group_addresses."""
-            yield self.group_address
-            yield self.group_address_state
-            yield from self.passive_group_addresses
-
-        return group_address in remote_value_addresses()
-
     def from_knx(self, payload: DPTArray | DPTBinary) -> ValueT:
         """Convert current payload to value - to be implemented in derived class when `dpt_class` can't be used."""
         if self.dpt_class is None:
@@ -173,9 +170,12 @@ class RemoteValue(ABC, Generic[ValueT]):
 
     def process(self, telegram: Telegram, always_callback: bool = False) -> bool:
         """Process incoming or outgoing telegram."""
-        if not isinstance(
-            telegram.destination_address, GroupAddress | InternalGroupAddress
-        ) or not self.has_group_address(telegram.destination_address):
+        if (
+            not isinstance(
+                telegram.destination_address, GroupAddress | InternalGroupAddress
+            )
+            or telegram.destination_address not in self.group_addresses()
+        ):
             return False
         if not isinstance(telegram.payload, GroupValueWrite | GroupValueResponse):
             raise CouldNotParseTelegram(


Test patch:
diff --git a/test/devices_tests/climate_test.py b/test/devices_tests/climate_test.py
index db201f9e..0147308c 100644
--- a/test/devices_tests/climate_test.py
+++ b/test/devices_tests/climate_test.py
@@ -96,6 +96,16 @@ class TestClimate:
         assert climate.has_group_address(GroupAddress("1/2/16"))
         assert not climate.has_group_address(GroupAddress("1/2/99"))
 
+        assert climate.group_addresses() == {
+            GroupAddress("1/2/1"),
+            GroupAddress("1/2/2"),
+            GroupAddress("1/2/3"),
+            GroupAddress("1/2/4"),
+            GroupAddress("1/2/11"),
+            GroupAddress("1/2/12"),
+            GroupAddress("1/2/16"),
+        }
+
     #
     # TEST HAS GROUP ADDRESS
     #
@@ -135,6 +145,21 @@ class TestClimate:
         assert climate.has_group_address(GroupAddress("1/2/15"))
         assert not climate.has_group_address(GroupAddress("1/2/99"))
 
+        assert climate.group_addresses() == {
+            GroupAddress("1/2/4"),
+            GroupAddress("1/2/5"),
+            GroupAddress("1/2/6"),
+            GroupAddress("1/2/7"),
+            GroupAddress("1/2/8"),
+            GroupAddress("1/2/9"),
+            GroupAddress("1/2/10"),
+            GroupAddress("1/2/11"),
+            GroupAddress("1/2/12"),
+            GroupAddress("1/2/13"),
+            GroupAddress("1/2/14"),
+            GroupAddress("1/2/15"),
+        }
+
     #
     # TEST CALLBACK
     #
diff --git a/test/devices_tests/switch_test.py b/test/devices_tests/switch_test.py
index 4c5ed4ea..60d9f9f1 100644
--- a/test/devices_tests/switch_test.py
+++ b/test/devices_tests/switch_test.py
@@ -367,6 +367,8 @@ class TestSwitch:
         assert switch.has_group_address(GroupAddress("1/2/3"))
         assert not switch.has_group_address(GroupAddress("2/2/2"))
 
+        assert switch.group_addresses() == {GroupAddress("1/2/3")}
+
     #
     # TEST passive group addresses
     #
@@ -378,6 +380,11 @@ class TestSwitch:
         assert switch.has_group_address(GroupAddress("4/4/4"))
         assert not switch.has_group_address(GroupAddress("2/2/2"))
 
+        assert switch.group_addresses() == {
+            GroupAddress("1/2/3"),
+            GroupAddress("4/4/4"),
+        }
+
     async def test_process_passive(self) -> None:
         """Test process / reading telegrams from telegram queue. Test if device was updated."""
         xknx = XKNX()
diff --git a/test/devices_tests/weather_test.py b/test/devices_tests/weather_test.py
index 5aea7863..950ba0ed 100644
--- a/test/devices_tests/weather_test.py
+++ b/test/devices_tests/weather_test.py
@@ -368,5 +368,5 @@ class TestWeather:
         """Test sensor has group address."""
         xknx = XKNX()
         weather = Weather(name="weather", xknx=xknx, group_address_temperature="1/3/4")
-        assert weather._temperature.has_group_address(GroupAddress("1/3/4"))
-        assert not weather._temperature.has_group_address(GroupAddress("1/2/4"))
+        assert weather.has_group_address(GroupAddress("1/3/4"))
+        assert not weather.has_group_address(GroupAddress("1/2/4"))
diff --git a/test/remote_value_tests/remote_value_test.py b/test/remote_value_tests/remote_value_test.py
index e2aa7ca9..87a2cb9b 100644
--- a/test/remote_value_tests/remote_value_test.py
+++ b/test/remote_value_tests/remote_value_test.py
@@ -90,37 +90,28 @@ class TestRemoteValue:
     async def test_process_unsupported_payload_type(self) -> None:
         """Test if exception is raised when processing telegram with unsupported payload type."""
         xknx = XKNX()
-        remote_value = RemoteValue(xknx)
-        with patch(
-            "xknx.remote_value.RemoteValue.has_group_address"
-        ) as patch_has_group_address:
-            patch_has_group_address.return_value = True
+        remote_value = RemoteValue(xknx, group_address="1/2/1")
 
-            telegram = Telegram(
-                destination_address=GroupAddress("1/2/1"), payload=object()
-            )
-            with pytest.raises(
-                CouldNotParseTelegram,
-                match=r".*payload not a GroupValueWrite or GroupValueResponse.*",
-            ):
-                remote_value.process(telegram)
+        telegram = Telegram(destination_address=GroupAddress("1/2/1"), payload=object())
+        with pytest.raises(
+            CouldNotParseTelegram,
+            match=r".*payload not a GroupValueWrite or GroupValueResponse.*",
+        ):
+            remote_value.process(telegram)
 
     def test_process_unsupported_payload(self) -> None:
         """Test warning is logged when processing telegram with unsupported payload."""
         xknx = XKNX()
-        remote_value = RemoteValue(xknx)
+        remote_value = RemoteValue(xknx, group_address="1/2/1")
+
         telegram = Telegram(
             destination_address=GroupAddress("1/2/1"),
             payload=GroupValueWrite(DPTArray((0x01, 0x02))),
         )
         with (
-            patch(
-                "xknx.remote_value.RemoteValue.has_group_address"
-            ) as patch_has_group_address,
             patch("xknx.remote_value.RemoteValue.from_knx") as patch_from,
             patch("logging.Logger.warning") as mock_warning,
         ):
-            patch_has_group_address.return_value = True
             patch_from.side_effect = ConversionError("TestError")
 
             assert remote_value.process(telegram) is False
@@ -176,15 +167,15 @@ class TestRemoteValue:
         assert remote_value_1.group_address == GroupAddress("1/2/3")
         assert remote_value_1.group_address_state is None
         assert remote_value_1.passive_group_addresses == [GroupAddress("1/1/1")]
-        assert remote_value_1.has_group_address(GroupAddress("1/2/3"))
-        assert remote_value_1.has_group_address(GroupAddress("1/1/1"))
+        assert GroupAddress("1/2/3") in remote_value_1.group_addresses()
+        assert GroupAddress("1/1/1") in remote_value_1.group_addresses()
 
         remote_value_2 = RemoteValue(xknx, group_address_state=["1/2/3", "1/1/1"])
         assert remote_value_2.group_address is None
         assert remote_value_2.group_address_state == GroupAddress("1/2/3")
         assert remote_value_2.passive_group_addresses == [GroupAddress("1/1/1")]
-        assert remote_value_2.has_group_address(GroupAddress("1/2/3"))
-        assert remote_value_2.has_group_address(GroupAddress("1/1/1"))
+        assert GroupAddress("1/2/3") in remote_value_2.group_addresses()
+        assert GroupAddress("1/1/1") in remote_value_2.group_addresses()
 
         remote_value_3 = RemoteValue(
             xknx,
@@ -199,13 +190,13 @@ class TestRemoteValue:
             GroupAddress("2/2/2"),
             GroupAddress("2/2/20"),
         ]
-        assert remote_value_3.has_group_address(GroupAddress("1/2/3"))
-        assert remote_value_3.has_group_address(GroupAddress("1/1/1"))
-        assert remote_value_3.has_group_address(GroupAddress("1/1/10"))
-        assert remote_value_3.has_group_address(GroupAddress("2/3/4"))
-        assert remote_value_3.has_group_address(GroupAddress("2/2/2"))
-        assert remote_value_3.has_group_address(GroupAddress("2/2/20"))
-        assert not remote_value_3.has_group_address(GroupAddress("0/0/1"))
+        assert GroupAddress("1/2/3") in remote_value_3.group_addresses()
+        assert GroupAddress("1/1/1") in remote_value_3.group_addresses()
+        assert GroupAddress("1/1/10") in remote_value_3.group_addresses()
+        assert GroupAddress("2/3/4") in remote_value_3.group_addresses()
+        assert GroupAddress("2/2/2") in remote_value_3.group_addresses()
+        assert GroupAddress("2/2/20") in remote_value_3.group_addresses()
+        assert GroupAddress("0/0/1") not in remote_value_3.group_addresses()
         # test empty list
         remote_value_4 = RemoteValue(xknx, group_address=[])
         assert remote_value_4.group_address is None