Repository: XKNX/xknx
Pull request #672 (2021-04-18 21:52:39)

Internal addresses
<!--
  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.
-->
This adds internal addresses to xknx. They can be used as a bridge between devices without sending anything to the bus.
Eg. an ExposeSensor can be used for a light brightness or an inverted BinarySensors value for Cover movement.

An internal address is configured by prefixing a string with "i", "i-" or "i_" where ever a group address can be configured.

TODO:
- [x] test this in HA test installation
- [x] test mixed GroupAddress and XknxInternalAddress eg. in passive addresses
- [x] write documentation
- [x] Add more tests

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)
- [x] This change requires a documentation update

## Checklist:

- [x] I have performed a self-review of my own code
- [x] The documentation has been adjusted accordingly
- [x] The changes generate no new warnings
- [x] Tests have been added that prove the fix is effective or that the feature works
- [x] The changes are documented in the changelog
- [x] The Homeassistant plugin has been adjusted in case of new config options

codecov[bot]: # [Codecov](https://codecov.io/gh/XKNX/xknx/pull/672?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=XKNX) Report
> :exclamation: No coverage uploaded for pull request base (`main@6085733`). [Click here to learn what that means](https://docs.codecov.io/docs/error-reference?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=XKNX#section-missing-base-commit).
> The diff coverage is `96.59%`.

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

```diff
@@           Coverage Diff           @@
##             main     #672   +/-   ##
=======================================
  Coverage        ?   94.38%           
=======================================
  Files           ?      101           
  Lines           ?     6431           
  Branches        ?        0           
=======================================
  Hits            ?     6070           
  Misses          ?      361           
  Partials        ?        0           
```


| [Impacted Files](https://codecov.io/gh/XKNX/xknx/pull/672?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=XKNX) | Coverage Δ | |
|---|---|---|
| [xknx/telegram/address.py](https://codecov.io/gh/XKNX/xknx/pull/672/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=XKNX#diff-eGtueC90ZWxlZ3JhbS9hZGRyZXNzLnB5) | `98.32% <92.10%> (ø)` | |
| [xknx/core/telegram\_queue.py](https://codecov.io/gh/XKNX/xknx/pull/672/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=XKNX#diff-eGtueC9jb3JlL3RlbGVncmFtX3F1ZXVlLnB5) | `95.37% <100.00%> (ø)` | |
| [xknx/core/value\_reader.py](https://codecov.io/gh/XKNX/xknx/pull/672/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=XKNX#diff-eGtueC9jb3JlL3ZhbHVlX3JlYWRlci5weQ==) | `100.00% <100.00%> (ø)` | |
| [xknx/devices/climate.py](https://codecov.io/gh/XKNX/xknx/pull/672/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=XKNX#diff-eGtueC9kZXZpY2VzL2NsaW1hdGUucHk=) | `100.00% <100.00%> (ø)` | |
| [xknx/devices/device.py](https://codecov.io/gh/XKNX/xknx/pull/672/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=XKNX#diff-eGtueC9kZXZpY2VzL2RldmljZS5weQ==) | `100.00% <100.00%> (ø)` | |
| [xknx/devices/devices.py](https://codecov.io/gh/XKNX/xknx/pull/672/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=XKNX#diff-eGtueC9kZXZpY2VzL2RldmljZXMucHk=) | `100.00% <100.00%> (ø)` | |
| [xknx/knxip/cemi\_frame.py](https://codecov.io/gh/XKNX/xknx/pull/672/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=XKNX#diff-eGtueC9rbnhpcC9jZW1pX2ZyYW1lLnB5) | `100.00% <100.00%> (ø)` | |
| [xknx/remote\_value/remote\_value.py](https://codecov.io/gh/XKNX/xknx/pull/672/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=XKNX#diff-eGtueC9yZW1vdGVfdmFsdWUvcmVtb3RlX3ZhbHVlLnB5) | `100.00% <100.00%> (ø)` | |
| [xknx/telegram/address\_filter.py](https://codecov.io/gh/XKNX/xknx/pull/672/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=XKNX#diff-eGtueC90ZWxlZ3JhbS9hZGRyZXNzX2ZpbHRlci5weQ==) | `97.80% <100.00%> (ø)` | |
| [xknx/telegram/telegram.py](https://codecov.io/gh/XKNX/xknx/pull/672/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=XKNX#diff-eGtueC90ZWxlZ3JhbS90ZWxlZ3JhbS5weQ==) | `93.54% <100.00%> (ø)` | |


Patch:
diff --git a/changelog.md b/changelog.md
index 14c5d808..72cd4611 100644
--- a/changelog.md
+++ b/changelog.md
@@ -2,6 +2,10 @@
 
 ## Unreleased changes
 
+### Devices
+
+- Add InternalGroupAddress for communication between Devices without sending to the bus.
+
 ### Internals
 
 - RemoteValue.value changed to a settable property. It is used to create payloads for outgoing telegrams.
diff --git a/docs/devices.md b/docs/devices.md
index edacc4ae..5e5e2509 100644
--- a/docs/devices.md
+++ b/docs/devices.md
@@ -55,6 +55,25 @@ GroupAddress("1/3/3")
 [GroupAddress("4/2/10"), GroupAddress("4/2/20"), GroupAddress("4/3/10"), GroupAddress("4/3/20")]
 ```
 
+## [](#header-2)Addresses
+
+`GroupAddress` classes are initialized with strings or integers in the format “1/2/3” for 3-level GA-structure, “1/2” for 2-level GA-structure or “1” for free GA-structure.
+
+`InternalGroupAddress` classes are initialized by prepending "i", "i-" or "i_" to any string. These can be used to connect xknx devices without sending telegrams to the KNX/IP interface.
+
+Addresses passed to devices as arguments are initialized by `xknx.telegram.address.parse_device_group_address()` to create the according address class.
+
+```python
+>>> s = Switch(xknx,
+...     name="Switch",
+...     group_address=["1/2/3", "1/2/100", "i-🤖⚡️"],
+...     )
+>>> s.switch.group_address
+GroupAddress("1/2/3")
+>>> s.switch.passive_group_addresses
+[GroupAddress("1/2/100"), InternalGroupAddress("i-🤖⚡️")]
+```
+
 ## [](#header-2)Device classes
 
 The following pages will give you an overview over the available devices within XKNX.
diff --git a/home-assistant-plugin/custom_components/xknx/__init__.py b/home-assistant-plugin/custom_components/xknx/__init__.py
index e990f27d..a0442b06 100644
--- a/home-assistant-plugin/custom_components/xknx/__init__.py
+++ b/home-assistant-plugin/custom_components/xknx/__init__.py
@@ -15,7 +15,8 @@ from xknx.io import (
     ConnectionConfig,
     ConnectionType,
 )
-from xknx.telegram import AddressFilter, GroupAddress, Telegram
+from xknx.telegram import AddressFilter, Telegram
+from xknx.telegram.address import parse_device_group_address
 from xknx.telegram.apci import GroupValueRead, GroupValueResponse, GroupValueWrite
 
 from homeassistant.const import (
@@ -402,7 +403,7 @@ class KNXModule:
     async def service_event_register_modify(self, call: ServiceCall) -> None:
         """Service for adding or removing a GroupAddress to the knx_event filter."""
         attr_address = call.data[KNX_ADDRESS]
-        group_addresses = map(GroupAddress, attr_address)
+        group_addresses = map(parse_device_group_address, attr_address)
 
         if call.data.get(SERVICE_XKNX_ATTR_REMOVE):
             for group_address in group_addresses:
@@ -473,7 +474,7 @@ class KNXModule:
 
         for address in attr_address:
             telegram = Telegram(
-                destination_address=GroupAddress(address),
+                destination_address=parse_device_group_address(address),
                 payload=GroupValueWrite(payload),
             )
             await self.xknx.telegrams.put(telegram)
@@ -482,7 +483,7 @@ class KNXModule:
         """Service for sending a GroupValueRead telegram to the KNX bus."""
         for address in call.data[KNX_ADDRESS]:
             telegram = Telegram(
-                destination_address=GroupAddress(address),
+                destination_address=parse_device_group_address(address),
                 payload=GroupValueRead(),
             )
             await self.xknx.telegrams.put(telegram)
diff --git a/home-assistant-plugin/custom_components/xknx/schema.py b/home-assistant-plugin/custom_components/xknx/schema.py
index 60c45db3..98855522 100644
--- a/home-assistant-plugin/custom_components/xknx/schema.py
+++ b/home-assistant-plugin/custom_components/xknx/schema.py
@@ -1,8 +1,11 @@
 """Voluptuous schemas for the KNX integration."""
+from typing import Any
+
 import voluptuous as vol
 from xknx.devices.climate import SetpointShiftMode
+from xknx.exceptions import CouldNotParseAddress
 from xknx.io import DEFAULT_MCAST_PORT
-from xknx.telegram.address import GroupAddress, IndividualAddress
+from xknx.telegram.address import IndividualAddress, parse_device_group_address
 
 from homeassistant.const import (
     CONF_DEVICE_CLASS,
@@ -29,11 +32,20 @@ from .const import (
 # KNX VALIDATORS
 ##################
 
-ga_validator = vol.Any(
-    cv.matches_regex(GroupAddress.ADDRESS_RE.pattern),
-    vol.All(vol.Coerce(int), vol.Range(min=1, max=65535)),
-    msg="value does not match pattern for KNX group address '<main>/<middle>/<sub>', '<main>/<sub>' or '<free>' (eg.'1/2/3', '9/234', '123')",
-)
+
+def ga_validator(value: Any) -> str:
+    """Validate that value is parsable as GroupAddress or InternalGroupAddress."""
+    if isinstance(value, (str, int)):
+        try:
+            parse_device_group_address(value)
+            return value
+        except CouldNotParseAddress:
+            pass
+    raise vol.Invalid(
+        f"value '{value}' is not a valid KNX group address '<main>/<middle>/<sub>', '<main>/<sub>' or '<free>' (eg.'1/2/3', '9/234', '123'), nor xknx internal address 'i-<string>'."
+    )
+
+
 ga_list_validator = vol.All(cv.ensure_list, [ga_validator])
 
 ia_validator = vol.Any(
diff --git a/xknx/core/telegram_queue.py b/xknx/core/telegram_queue.py
index 6021a016..bd4a4e2c 100644
--- a/xknx/core/telegram_queue.py
+++ b/xknx/core/telegram_queue.py
@@ -14,7 +14,8 @@ import logging
 from typing import TYPE_CHECKING, Awaitable, Callable
 
 from xknx.exceptions import CommunicationError, XKNXException
-from xknx.telegram import AddressFilter, GroupAddress, Telegram, TelegramDirection
+from xknx.telegram import AddressFilter, Telegram, TelegramDirection
+from xknx.telegram.address import GroupAddress, InternalGroupAddress
 
 if TYPE_CHECKING:
     from xknx.xknx import XKNX
@@ -35,7 +36,7 @@ class TelegramQueue:
             self,
             callback: AsyncTelegramCallback,
             address_filters: list[AddressFilter] | None = None,
-            group_addresses: list[GroupAddress] | None = None,
+            group_addresses: list[GroupAddress | InternalGroupAddress] | None = None,
             match_for_outgoing_telegrams: bool = False,
         ):
             """Initialize Callback class."""
@@ -54,7 +55,9 @@ class TelegramQueue:
                 return False
             if self._match_all:
                 return True
-            if isinstance(telegram.destination_address, GroupAddress):
+            if isinstance(
+                telegram.destination_address, (GroupAddress, InternalGroupAddress)
+            ):
                 for address_filter in self.address_filters:
                     if address_filter.match(telegram.destination_address):
                         return True
@@ -74,7 +77,7 @@ class TelegramQueue:
         self,
         telegram_received_cb: AsyncTelegramCallback,
         address_filters: list[AddressFilter] | None = None,
-        group_addresses: list[GroupAddress] | None = None,
+        group_addresses: list[GroupAddress | InternalGroupAddress] | None = None,
         match_for_outgoing: bool = False,
     ) -> TelegramQueue.Callback:
         """Register callback for a telegram being received from KNX bus."""
@@ -150,7 +153,9 @@ class TelegramQueue:
                 self.xknx.telegrams.task_done()
 
             # limit rate to knx bus - defaults to 20 per second
-            if self.xknx.rate_limit:
+            if self.xknx.rate_limit and not isinstance(
+                telegram.destination_address, InternalGroupAddress
+            ):
                 await asyncio.sleep(1 / self.xknx.rate_limit)
 
     async def _process_all_telegrams(self) -> None:
@@ -172,15 +177,15 @@ class TelegramQueue:
     async def process_telegram_outgoing(self, telegram: Telegram) -> None:
         """Process outgoing telegram."""
         telegram_logger.debug(telegram)
-        if self.xknx.knxip_interface is not None:
+        if not isinstance(telegram.destination_address, InternalGroupAddress):
+            if self.xknx.knxip_interface is None:
+                raise CommunicationError("No KNXIP interface defined")
             await self.xknx.knxip_interface.send_telegram(telegram)
-            await self.xknx.devices.process(telegram)
 
-            for telegram_received_cb in self.telegram_received_cbs:
-                if telegram_received_cb.is_within_filter(telegram):
-                    await telegram_received_cb.callback(telegram)
-        else:
-            raise CommunicationError("No KNXIP interface defined")
+        await self.xknx.devices.process(telegram)
+        for telegram_received_cb in self.telegram_received_cbs:
+            if telegram_received_cb.is_within_filter(telegram):
+                await telegram_received_cb.callback(telegram)
 
     async def process_telegram_incoming(self, telegram: Telegram) -> None:
         """Process incoming telegram."""
diff --git a/xknx/core/value_reader.py b/xknx/core/value_reader.py
index 170f761f..60081d90 100644
--- a/xknx/core/value_reader.py
+++ b/xknx/core/value_reader.py
@@ -13,7 +13,8 @@ import asyncio
 import logging
 from typing import TYPE_CHECKING
 
-from xknx.telegram import GroupAddress, Telegram
+from xknx.telegram import Telegram
+from xknx.telegram.address import GroupAddress, InternalGroupAddress
 from xknx.telegram.apci import GroupValueRead, GroupValueResponse, GroupValueWrite
 
 if TYPE_CHECKING:
@@ -26,11 +27,14 @@ class ValueReader:
     """Class for reading the value of a specific KNX group address from KNX bus."""
 
     def __init__(
-        self, xknx: XKNX, group_address: GroupAddress, timeout_in_seconds: float = 2.0
+        self,
+        xknx: XKNX,
+        group_address: GroupAddress | InternalGroupAddress,
+        timeout_in_seconds: float = 2.0,
     ):
         """Initialize ValueReader class."""
         self.xknx = xknx
-        self.group_address: GroupAddress = group_address
+        self.group_address: GroupAddress | InternalGroupAddress = group_address
         self.response_received_event = asyncio.Event()
         self.timeout_in_seconds: float = timeout_in_seconds
         self.received_telegram: Telegram | None = None
diff --git a/xknx/devices/climate.py b/xknx/devices/climate.py
index 39fc92d0..67df602e 100644
--- a/xknx/devices/climate.py
+++ b/xknx/devices/climate.py
@@ -24,7 +24,7 @@ from .sensor import Sensor
 
 if TYPE_CHECKING:
     from xknx.telegram import Telegram
-    from xknx.telegram.address import GroupAddress
+    from xknx.telegram.address import DeviceGroupAddress
     from xknx.xknx import XKNX
 
 logger = logging.getLogger("xknx.log")
@@ -167,7 +167,7 @@ class Climate(Device):
         """Return unique id for this device."""
         return f"{self.temperature.group_address_state}"
 
-    def has_group_address(self, group_address: GroupAddress) -> bool:
+    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
@@ -276,7 +276,7 @@ class Climate(Device):
             return self.base_temperature + self.setpoint_shift_min
         return None
 
-    async def process_group_write(self, telegram: "Telegram") -> None:
+    async def process_group_write(self, telegram: Telegram) -> None:
         """Process incoming and outgoing GROUP WRITE telegram."""
         for remote_value in self._iter_remote_values():
             await remote_value.process(telegram)
diff --git a/xknx/devices/device.py b/xknx/devices/device.py
index f7a2e7f0..68d1c717 100644
--- a/xknx/devices/device.py
+++ b/xknx/devices/device.py
@@ -10,7 +10,8 @@ import logging
 from typing import TYPE_CHECKING, Any, Awaitable, Callable, Iterator
 
 from xknx.remote_value import RemoteValue
-from xknx.telegram import GroupAddress, Telegram
+from xknx.telegram import Telegram
+from xknx.telegram.address import DeviceGroupAddress
 from xknx.telegram.apci import GroupValueRead, GroupValueResponse, GroupValueWrite
 
 if TYPE_CHECKING:
@@ -111,7 +112,7 @@ class Device(ABC):
         """Return name of device."""
         return self.name
 
-    def has_group_address(self, group_address: GroupAddress) -> bool:
+    def has_group_address(self, group_address: DeviceGroupAddress) -> bool:
         """Test if device has given group address."""
         for remote_value in self._iter_remote_values():
             if remote_value.has_group_address(group_address):
diff --git a/xknx/devices/devices.py b/xknx/devices/devices.py
index b4f424e1..d92a8ae8 100644
--- a/xknx/devices/devices.py
+++ b/xknx/devices/devices.py
@@ -7,7 +7,8 @@ from __future__ import annotations
 
 from typing import Awaitable, Callable, Iterator
 
-from xknx.telegram import GroupAddress, Telegram
+from xknx.telegram import Telegram
+from xknx.telegram.address import DeviceGroupAddress, GroupAddress, InternalGroupAddress
 
 from .device import Device
 
@@ -36,7 +37,9 @@ class Devices:
         """Iterate registered devices."""
         yield from self.__devices
 
-    def devices_by_group_address(self, group_address: GroupAddress) -> Iterator[Device]:
+    def devices_by_group_address(
+        self, group_address: DeviceGroupAddress
+    ) -> Iterator[Device]:
         """Return device(s) by group address."""
         for device in self.__devices:
             if device.has_group_address(group_address):
@@ -80,7 +83,9 @@ class Devices:
 
     async def process(self, telegram: Telegram) -> None:
         """Process telegram."""
-        if isinstance(telegram.destination_address, GroupAddress):
+        if isinstance(
+            telegram.destination_address, (GroupAddress, InternalGroupAddress)
+        ):
             for device in self.devices_by_group_address(telegram.destination_address):
                 await device.process(telegram)
 
diff --git a/xknx/knxip/cemi_frame.py b/xknx/knxip/cemi_frame.py
index 620e3874..f32f2610 100644
--- a/xknx/knxip/cemi_frame.py
+++ b/xknx/knxip/cemi_frame.py
@@ -73,9 +73,6 @@ class CEMIFrame:
     @telegram.setter
     def telegram(self, telegram: Telegram) -> None:
         """Set telegram."""
-        self.dst_addr = telegram.destination_address
-        self.payload = telegram.payload
-
         # TODO: Move to separate function, together with setting of
         # CEMIMessageCode
         self.flags = (
@@ -95,6 +92,9 @@ class CEMIFrame:
         else:
             raise TypeError()
 
+        self.dst_addr = telegram.destination_address
+        self.payload = telegram.payload
+
     def set_hops(self, hops: int) -> None:
         """Set hops."""
         # Resetting hops
diff --git a/xknx/remote_value/remote_value.py b/xknx/remote_value/remote_value.py
index d96dff64..1648f3e0 100644
--- a/xknx/remote_value/remote_value.py
+++ b/xknx/remote_value/remote_value.py
@@ -25,16 +25,21 @@ from typing import (
 from xknx.dpt.dpt import DPTArray, DPTBinary, DPTPayloadType
 from xknx.exceptions import CouldNotParseTelegram
 from xknx.telegram import GroupAddress, Telegram
+from xknx.telegram.address import (
+    DeviceGroupAddress,
+    InternalGroupAddress,
+    parse_device_group_address,
+)
 from xknx.telegram.apci import GroupValueResponse, GroupValueWrite
 
 if TYPE_CHECKING:
-    from xknx.telegram.address import GroupAddressableType
+    from xknx.telegram.address import DeviceAddressableType
     from xknx.xknx import XKNX
 
 logger = logging.getLogger("xknx.log")
 
 AsyncCallbackType = Callable[[], Awaitable[None]]
-GroupAddressesType = Union["GroupAddressableType", List["GroupAddressableType"]]
+GroupAddressesType = Union["DeviceAddressableType", List["DeviceAddressableType"]]
 ValueType = TypeVar("ValueType")
 
 
@@ -53,17 +58,17 @@ class RemoteValue(ABC, Generic[DPTPayloadType, ValueType]):
     ):
         """Initialize RemoteValue class."""
         self.xknx: XKNX = xknx
-        self.passive_group_addresses: list[GroupAddress] = []
+        self.passive_group_addresses: list[DeviceGroupAddress] = []
 
         def unpack_group_addresses(
             addresses: GroupAddressesType | None,
-        ) -> GroupAddress | None:
+        ) -> DeviceGroupAddress | None:
             """Parse group addresses and assign passive addresses when given."""
             if addresses is None:
                 return None
             if not isinstance(addresses, list):
-                return GroupAddress(addresses)
-            active, *passive = map(GroupAddress, addresses)
+                return parse_device_group_address(addresses)
+            active, *passive = map(parse_device_group_address, addresses)
             self.passive_group_addresses.extend(passive)  # type: ignore
             return active
 
@@ -128,16 +133,16 @@ class RemoteValue(ABC, Generic[DPTPayloadType, ValueType]):
         """Evaluate if remote value has a group_address set."""
         return bool(self.group_address)
 
-    def has_group_address(self, group_address: GroupAddress) -> bool:
+    def has_group_address(self, group_address: DeviceGroupAddress) -> bool:
         """Test if device has given group address."""
 
-        def _internal_addresses() -> Iterator[GroupAddress | None]:
+        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 _internal_addresses()
+        return group_address in remote_value_addresses()
 
     @abstractmethod
     # TODO: typing - remove Optional
@@ -157,7 +162,7 @@ class RemoteValue(ABC, Generic[DPTPayloadType, ValueType]):
     async def process(self, telegram: Telegram, always_callback: bool = False) -> bool:
         """Process incoming or outgoing telegram."""
         if not isinstance(
-            telegram.destination_address, GroupAddress
+            telegram.destination_address, (GroupAddress, InternalGroupAddress)
         ) or not self.has_group_address(telegram.destination_address):
             return False
         if not isinstance(
diff --git a/xknx/telegram/address.py b/xknx/telegram/address.py
index 0169d0a4..e67213fc 100644
--- a/xknx/telegram/address.py
+++ b/xknx/telegram/address.py
@@ -5,6 +5,7 @@ The module can handle:
 
 * individual addresses of devices.
 * (logical) group addresses.
+* xknx internal group addresses.
 
 The module supports all different writings of group addresses:
 
@@ -26,6 +27,23 @@ GroupAddressableType = Optional[Union["GroupAddress", str, int, Tuple[int, int]]
 IndividualAddressableType = Optional[
     Union["IndividualAddress", str, int, Tuple[int, int]]
 ]
+InternalGroupAddressableType = Union["InternalGroupAddress", str]
+DeviceAddressableType = Union[GroupAddressableType, InternalGroupAddressableType]
+DeviceGroupAddress = Union["GroupAddress", "InternalGroupAddress"]
+
+
+def parse_device_group_address(
+    address: DeviceAddressableType,
+) -> DeviceGroupAddress:
+    """Parse an Addressable type to GroupAddress or InternalGroupAddress."""
+    if isinstance(address, (GroupAddress, InternalGroupAddress)):
+        return address
+    try:
+        return GroupAddress(address)
+    except CouldNotParseAddress as ex:
+        if isinstance(address, str):
+            return InternalGroupAddress(address)
+        raise ex
 
 
 def address_tuple_to_int(address: tuple[int, int]) -> int:
@@ -65,8 +83,6 @@ class BaseAddress(ABC):
 
         Returns `True` if we check against the same subclass and the
         raw Value matches.
-
-        Returns `False` if we check against `None`.
         """
         if isinstance(self, type(other)):
             return self.__hash__() == other.__hash__()
@@ -88,7 +104,7 @@ class IndividualAddress(BaseAddress):
     )
 
     def __init__(self, address: IndividualAddressableType) -> None:
-        """Initialize Address class."""
+        """Initialize IndividualAddress class."""
         super().__init__()
         if isinstance(address, IndividualAddress):
             self.raw = address.raw
@@ -196,7 +212,7 @@ class GroupAddress(BaseAddress):
         address: GroupAddressableType,
         levels: GroupAddressType = GroupAddressType.LONG,
     ) -> None:
-        """Initialize Address class."""
+        """Initialize GroupAddress class."""
         super().__init__()
         self.levels = levels
 
@@ -307,5 +323,52 @@ class GroupAddress(BaseAddress):
         return f"{self.sub}"
 
     def __repr__(self) -> str:
-        """Return object as readable string."""
+        """Return object as parsable string."""
         return f'GroupAddress("{self}")'
+
+
+class InternalGroupAddress:
+    """Class for handling addresses used internally in xknx devices only."""
+
+    def __init__(self, address: str | InternalGroupAddress) -> None:
+        """Initialize InternalGroupAddress class."""
+        self.address: str
+
+        if isinstance(address, InternalGroupAddress):
+            self.address = address.address
+            return
+        if not isinstance(address, str):
+            raise CouldNotParseAddress(address)
+
+        prefix_length = 1
+        if len(address) < 2 or not address[0].lower() == "i":
+            raise CouldNotParseAddress(address)
+        if address[1] in "-_":
+            prefix_length = 2
+
+        self.address = address[prefix_length:].strip()
+        if not self.address:
+            raise CouldNotParseAddress(address)
+
+    def __str__(self) -> str:
+        """Return object as readable string (e.g. 'i-123')."""
+        return f"i-{self.address}"
+
+    def __repr__(self) -> str:
+        """Return object as parsable string."""
+        return f'InternalGroupAddress("{self}")'
+
+    def __eq__(self, other: object | None) -> bool:
+        """
+        Implement the equal operator.
+
+        Returns `True` if we check against the same subclass and the
+        raw Value matches.
+        """
+        if isinstance(self, type(other)):
+            return self.__hash__() == other.__hash__()
+        return False
+
+    def __hash__(self) -> int:
+        """Hash Address so it can be used as dict key."""
+        return hash(self.address)
diff --git a/xknx/telegram/address_filter.py b/xknx/telegram/address_filter.py
index 46cd4524..7b9c8614 100644
--- a/xknx/telegram/address_filter.py
+++ b/xknx/telegram/address_filter.py
@@ -3,29 +3,37 @@ AddressFilter provides a mechanism for filtering KNX addresses with patterns.
 
 Patterns can be
 
-    for level3 KNX addresses:
+    for level3 KNX group addresses:
 
         AddressFilter("1/*/2-5")
         AddressFilter("1/1-3,4,5/*")
         AddressFilter("1/2/-10)
 
-    for level2 KNX addresses:
+    for level2 KNX group addresses:
 
         AddressFilter("*/2-5")
         AddressFilter("1-3,4,5/*")
         AddressFilter("2/-10")
 
-    for free format KNX addresses:
+    for free format KNX group addresses:
 
         AddressFilter("2-5")
         AddressFilter("1-3,4,5")
         AddressFilter("-10")
+
+    for xknx internal group addresses:
+
+        AddressFilter("i-test")
+        AddressFilter("i-t?st")
+        AddressFilter("i-t*t")
 """
 from __future__ import annotations
 
+from fnmatch import fnmatch
+
 from xknx.exceptions import ConversionError
 
-from .address import GroupAddress
+from .address import GroupAddress, InternalGroupAddress, parse_device_group_address
 
 
 class AddressFilter:
@@ -34,23 +42,38 @@ class AddressFilter:
     def __init__(self, pattern: str) -> None:
         """Initialize AddressFilter class."""
         self.level_filters: list[AddressFilter.LevelFilter] = []
+        self.internal_group_address_pattern: str | None = None
         self._parse_pattern(pattern)
 
     def _parse_pattern(self, pattern: str) -> None:
+        if pattern.startswith("i"):
+            self.internal_group_address_pattern = InternalGroupAddress(pattern).address
+            return
+
         for part in pattern.split("/"):
             self.level_filters.append(AddressFilter.LevelFilter(part))
         if len(self.level_filters) > 3:
             raise ConversionError("Too many parts within pattern.", pattern=pattern)
 
-    def match(self, address: str | GroupAddress) -> bool:
+    def match(self, address: str | GroupAddress | InternalGroupAddress) -> bool:
         """Test if provided address matches Addressfilter."""
         if isinstance(address, str):
-            address = GroupAddress(address)
-        if len(self.level_filters) == 3:
-            return self._match_level3(address)
-        if len(self.level_filters) == 2:
-            return self._match_level2(address)
-        return self._match_free(address)
+            address = parse_device_group_address(address)
+
+        if isinstance(address, GroupAddress) and self.level_filters:
+            if len(self.level_filters) == 3:
+                return self._match_level3(address)
+            if len(self.level_filters) == 2:
+                return self._match_level2(address)
+            return self._match_free(address)
+
+        if (
+            isinstance(address, InternalGroupAddress)
+            and self.internal_group_address_pattern
+        ):
+            return fnmatch(address.address, self.internal_group_address_pattern)
+
+        return False
 
     def _match_level3(self, address: GroupAddress) -> bool:
         if address.main is None or address.middle is None:
diff --git a/xknx/telegram/telegram.py b/xknx/telegram/telegram.py
index 47709f82..555ad4f7 100644
--- a/xknx/telegram/telegram.py
+++ b/xknx/telegram/telegram.py
@@ -18,7 +18,7 @@ from __future__ import annotations
 from datetime import datetime
 from enum import Enum
 
-from .address import GroupAddress, IndividualAddress
+from .address import GroupAddress, IndividualAddress, InternalGroupAddress
 from .apci import APCI
 
 
@@ -34,7 +34,9 @@ class Telegram:
 
     def __init__(
         self,
-        destination_address: GroupAddress | IndividualAddress = GroupAddress(0),
+        destination_address: GroupAddress
+        | IndividualAddress
+        | InternalGroupAddress = GroupAddress(0),
         direction: TelegramDirection = TelegramDirection.OUTGOING,
         payload: APCI | None = None,
         source_address: IndividualAddress = IndividualAddress(0),


Test patch:
diff --git a/test/core_tests/telegram_queue_test.py b/test/core_tests/telegram_queue_test.py
index 0b7ffc0b..74481726 100644
--- a/test/core_tests/telegram_queue_test.py
+++ b/test/core_tests/telegram_queue_test.py
@@ -6,7 +6,8 @@ import pytest
 from xknx import XKNX
 from xknx.dpt import DPTBinary
 from xknx.exceptions import CommunicationError, CouldNotParseTelegram
-from xknx.telegram import AddressFilter, GroupAddress, Telegram, TelegramDirection
+from xknx.telegram import AddressFilter, Telegram, TelegramDirection
+from xknx.telegram.address import GroupAddress, InternalGroupAddress
 from xknx.telegram.apci import GroupValueWrite
 
 
@@ -61,7 +62,11 @@ class TestTelegramQueue:
             direction=TelegramDirection.OUTGOING,
             payload=GroupValueWrite(DPTBinary(1)),
         )
-
+        telegram_internal = Telegram(
+            direction=TelegramDirection.OUTGOING,
+            payload=GroupValueWrite(DPTBinary(1)),
+            destination_address=InternalGroupAddress("i-test"),
+        )
         await xknx.telegram_queue.start()
 
         # no sleep for incoming telegrams
@@ -69,6 +74,7 @@ class TestTelegramQueue:
         xknx.telegrams.put_nowait(telegram_in)
         await xknx.telegrams.join()
         assert async_sleep_mock.call_count == 0
+
         # sleep for outgoing telegrams
         xknx.telegrams.put_nowait(telegram_out)
         xknx.telegrams.put_nowait(telegram_out)
@@ -76,6 +82,13 @@ class TestTelegramQueue:
         assert async_sleep_mock.call_count == 2
         async_sleep_mock.assert_called_with(sleep_time)
 
+        async_sleep_mock.reset_mock()
+        # no sleep for internal group address telegrams
+        xknx.telegrams.put_nowait(telegram_internal)
+        xknx.telegrams.put_nowait(telegram_internal)
+        await xknx.telegrams.join()
+        async_sleep_mock.assert_not_called()
+
         await xknx.telegram_queue.stop()
 
     #
diff --git a/test/devices_tests/sensor_expose_loop_test.py b/test/devices_tests/sensor_expose_loop_test.py
index 78e65ca9..bda263b0 100644
--- a/test/devices_tests/sensor_expose_loop_test.py
+++ b/test/devices_tests/sensor_expose_loop_test.py
@@ -5,7 +5,8 @@ import pytest
 from xknx import XKNX
 from xknx.devices import BinarySensor, ExposeSensor, Sensor
 from xknx.dpt import DPTArray, DPTBinary
-from xknx.telegram import GroupAddress, Telegram, TelegramDirection
+from xknx.telegram import AddressFilter, Telegram, TelegramDirection
+from xknx.telegram.address import GroupAddress, InternalGroupAddress
 from xknx.telegram.apci import GroupValueRead, GroupValueResponse, GroupValueWrite
 
 
@@ -301,3 +302,77 @@ class TestBinarySensorExposeLoop:
         assert bin_sensor.state == test_value
         assert expose.resolve_state() == bin_sensor.state
         await xknx.telegram_queue.stop()
+
+
+@pytest.mark.asyncio
+class TestBinarySensorInternalGroupAddressExposeLoop:
+    """Process incoming Telegrams and send values to other devices."""
+
+    @pytest.mark.parametrize(
+        "value_type,test_payload,test_value",
+        [
+            ("binary", DPTBinary(0), False),
+            ("binary", DPTBinary(1), True),
+        ],
+    )
+    async def test_binary_sensor_loop(self, value_type, test_payload, test_value):
+        """Test binary_sensor and expose_sensor with binary values."""
+        xknx = XKNX()
+        xknx.knxip_interface = AsyncMock()
+        xknx.rate_limit = False
+
+        telegram_callback = AsyncMock()
+        xknx.telegram_queue.register_telegram_received_cb(
+            telegram_callback,
+            address_filters=[AddressFilter("i-test")],
+            match_for_outgoing=True,
+        )
+        await xknx.telegram_queue.start()
+
+        expose = ExposeSensor(
+            xknx,
+            "TestExpose",
+            group_address="i-test",
+            value_type=value_type,
+        )
+        assert expose.resolve_state() is None
+
+        await expose.set(test_value)
+        await xknx.telegrams.join()
+        outgoing_telegram = Telegram(
+            destination_address=InternalGroupAddress("i-test"),
+            direction=TelegramDirection.OUTGOING,
+            payload=GroupValueWrite(test_payload),
+        )
+        # InternalGroupAddress isn't passed to knxip_interface
+        xknx.knxip_interface.send_telegram.assert_not_called()
+        telegram_callback.assert_called_with(outgoing_telegram)
+        assert expose.resolve_state() == test_value
+
+        bin_sensor = BinarySensor(xknx, "TestSensor", group_address_state="i-test")
+        assert bin_sensor.state is None
+
+        # read sensor state (from expose as it has the same GA)
+        # wait_for_result so we don't have to await self.xknx.telegrams.join()
+        await bin_sensor.sync(wait_for_result=True)
+        read_telegram = Telegram(
+            destination_address=InternalGroupAddress("i-test"),
+            direction=TelegramDirection.OUTGOING,
+            payload=GroupValueRead(),
+        )
+        response_telegram = Telegram(
+            destination_address=InternalGroupAddress("i-test"),
+            direction=TelegramDirection.OUTGOING,
+            payload=GroupValueResponse(test_payload),
+        )
+        xknx.knxip_interface.send_telegram.assert_not_called()
+        telegram_callback.assert_has_calls(
+            [
+                call(read_telegram),
+                call(response_telegram),
+            ]
+        )
+        # test if Sensor has successfully read from ExposeSensor
+        assert bin_sensor.state == test_value
+        assert expose.resolve_state() == bin_sensor.state
+        await xknx.telegram_queue.stop()
diff --git a/test/telegram_tests/address_filter_test.py b/test/telegram_tests/address_filter_test.py
index ae88e3b0..545bf15c 100644
--- a/test/telegram_tests/address_filter_test.py
+++ b/test/telegram_tests/address_filter_test.py
@@ -1,7 +1,8 @@
 """Unit test for Address class."""
 import pytest
 from xknx.exceptions import ConversionError
-from xknx.telegram import AddressFilter, GroupAddress
+from xknx.telegram import AddressFilter
+from xknx.telegram.address import GroupAddress, InternalGroupAddress
 
 
 class TestAddressFilter:
@@ -148,3 +149,63 @@ class TestAddressFilter:
             == GroupAddress.MAX_FREE
         )
         assert AddressFilter.Range._adjust_range(-1) == 0
+
+    def test_internal_address_filter_exact(self):
+        """Test AddressFilter for InternalGroupAddress."""
+        af1 = AddressFilter("i-1")
+        assert af1.match("i1")
+        assert af1.match("i 1")
+        assert af1.match("i-1")
+        assert af1.match(InternalGroupAddress("i-1"))
+        assert not af1.match("1")
+        assert not af1.match(GroupAddress(1))
+
+    def test_internal_address_filter_wildcard(self):
+        """Test AddressFilter with wildcards for InternalGroupAddress."""
+        af1 = AddressFilter("i-?")
+        assert af1.match("i1")
+        assert af1.match("i 2")
+        assert af1.match("i-3")
+        assert af1.match(InternalGroupAddress("i-4"))
+        assert not af1.match("1")
+        assert not af1.match(GroupAddress(1))
+        assert not af1.match("i11")
+        assert not af1.match("i 11")
+        assert not af1.match("i-11")
+        assert not af1.match(InternalGroupAddress("i-11"))
+
+        af2 = AddressFilter("i-t?st")
+        assert af2.match("it1st")
+        assert af2.match("i t2st")
+        assert af2.match("i-test")
+        assert af2.match(InternalGroupAddress("i-test"))
+        assert not af2.match("1")
+        assert not af2.match(GroupAddress(1))
+        assert not af2.match("i11")
+        assert not af2.match("i tst")
+        assert not af2.match("i-teest")
+        assert not af2.match(InternalGroupAddress("i-tst"))
+
+        af3 = AddressFilter("i-*")
+        assert af3.match("i1")
+        assert af3.match("i asdf")
+        assert af3.match("i-3sdf")
+        assert af3.match(InternalGroupAddress("i-4"))
+        assert not af3.match("1")
+        assert not af3.match(GroupAddress(1))
+        assert af3.match("i11")
+        assert af3.match("i 11??")
+        assert af3.match("i-11*")
+        assert af3.match(InternalGroupAddress("i-11"))
+
+        af4 = AddressFilter("i-t*t")
+        assert af4.match("it1t")
+        assert af4.match("i t22t")
+        assert af4.match("i-testt")
+        assert af4.match("i-tt")
+        assert af4.match(InternalGroupAddress("i-t333t"))
+        assert not af4.match("1")
+        assert not af4.match(GroupAddress(1))
+        assert not af4.match("i testx")
+        assert not af4.match("i-11test")
+        assert not af4.match(InternalGroupAddress("i-11"))
diff --git a/test/telegram_tests/address_test.py b/test/telegram_tests/address_test.py
index 98a20ac3..35a44612 100644
--- a/test/telegram_tests/address_test.py
+++ b/test/telegram_tests/address_test.py
@@ -1,49 +1,134 @@
 """Unit test for Address class."""
 import pytest
 from xknx.exceptions import CouldNotParseAddress
-from xknx.telegram import GroupAddress, GroupAddressType, IndividualAddress
+from xknx.telegram.address import (
+    GroupAddress,
+    GroupAddressType,
+    IndividualAddress,
+    InternalGroupAddress,
+    parse_device_group_address,
+)
+
+group_addresses_valid = {
+    "0/0": 0,
+    "0/1": 1,
+    "0/11": 11,
+    "0/111": 111,
+    "0/1111": 1111,
+    "0/2047": 2047,
+    "0/0/0": 0,
+    "0/0/1": 1,
+    "0/0/11": 11,
+    "0/0/111": 111,
+    "0/0/255": 255,
+    "0/1/11": 267,
+    "0/1/111": 367,
+    "0/7/255": 2047,
+    "1/0": 2048,
+    "1/0/0": 2048,
+    "1/1/111": 2415,
+    "1/7/255": 4095,
+    "31/7/255": 65535,
+    "1": 1,
+    0: 0,
+    65535: 65535,
+    (0xFF, 0xFF): 65535,
+    GroupAddress("1/1/111"): 2415,
+    None: 0,
+}
+
+group_addresses_invalid = [
+    "0/2049",
+    "0/8/0",
+    "0/0/256",
+    "32/0",
+    "0/0a",
+    "a0/0",
+    "abc",
+    "1.1.1",
+    "0.0",
+    IndividualAddress("11.11.111"),
+    65536,
+    (0xFF, 0xFFF),
+    (0xFFF, 0xFF),
+    (-1, -1),
+    [],
+]
+
+individual_addresses_valid = {
+    "0.0.0": 0,
+    "123": 123,
+    "1.0.0": 4096,
+    "1.1.0": 4352,
+    "1.1.1": 4353,
+    "1.1.11": 4363,
+    "1.1.111": 4463,
+    "1.11.111": 7023,
+    "11.11.111": 47983,
+    IndividualAddress("11.11.111"): 47983,
+    "15.15.255": 65535,
+    (0xFF, 0xFF): 65535,
+    0: 0,
+    65535: 65535,
+}
+
+individual_addresses_invalid = [
+    "15.15.256",
+    "16.0.0",
+    "0.16.0",
+    "15.15.255a",
+    "a15.15.255",
+    "abc",
+    "1/1/1",
+    "0/0",
+    GroupAddress("1/1/111"),
+    65536,
+    (0xFF, 0xFFF),
+    (0xFFF, 0xFF),
+    (-1, -1),
+    [],
+]
+
+internal_group_addresses_valid = {
+    "i 123": "123",
+    "i-123": "123",
+    "i_123": "123",
+    "I 123": "123",
+    "i abc": "abc",
+    "i-abc": "abc",
+    "i_abc": "abc",
+    "I-abc": "abc",
+    "i123": "123",
+    "iabc": "abc",
+    "IABC": "ABC",
+    "i   abc  ": "abc",
+    "i asdf 123 adsf ": "asdf 123 adsf",
+    "i-1/2/3": "1/2/3",
+    InternalGroupAddress("i-123"): "123",
+}
+
+internal_group_addresses_invalid = [
+    "i",
+    "i-",
+    "i ",
+    "i  ",
+    "i- ",
+    "a",
+]
 
 
 class TestIndividualAddress:
     """Test class for IndividualAddress."""
 
-    valid_addresses = [
-        ("0.0.0", 0),
-        ("123", 123),
-        ("1.0.0", 4096),
-        ("1.1.0", 4352),
-        ("1.1.1", 4353),
-        ("1.1.11", 4363),
-        ("1.1.111", 4463),
-        ("1.11.111", 7023),
-        ("11.11.111", 47983),
-        (IndividualAddress("11.11.111"), 47983),
-        ("15.15.255", 65535),
-        ((0xFF, 0xFF), 65535),
-        (0, 0),
-        (65535, 65535),
-    ]
-    invalid_addresses = [
-        "15.15.256",
-        "16.0.0",
-        "0.16.0",
-        "15.15.255a",
-        "a15.15.255",
-        "abc",
-        65536,
-        (0xFF, 0xFFF),
-        (0xFFF, 0xFF),
-        (-1, -1),
-        [],
-    ]
-
-    @pytest.mark.parametrize("address_test,address_raw", valid_addresses)
+    @pytest.mark.parametrize(
+        "address_test,address_raw", individual_addresses_valid.items()
+    )
     def test_with_valid(self, address_test, address_raw):
         """Test with some valid addresses."""
 
         assert IndividualAddress(address_test).raw == address_raw
 
-    @pytest.mark.parametrize("address_test", invalid_addresses)
+    @pytest.mark.parametrize("address_test", individual_addresses_invalid)
     def test_with_invalid(self, address_test):
         """Test with some invalid addresses."""
 
@@ -95,50 +180,7 @@ class TestIndividualAddress:
 class TestGroupAddress:
     """Test class for GroupAddress."""
 
-    valid_addresses = [
-        ("0/0", 0),
-        ("0/1", 1),
-        ("0/11", 11),
-        ("0/111", 111),
-        ("0/1111", 1111),
-        ("0/2047", 2047),
-        ("0/0/0", 0),
-        ("0/0/1", 1),
-        ("0/0/11", 11),
-        ("0/0/111", 111),
-        ("0/0/255", 255),
-        ("0/1/11", 267),
-        ("0/1/111", 367),
-        ("0/7/255", 2047),
-        ("1/0", 2048),
-        ("1/0/0", 2048),
-        ("1/1/111", 2415),
-        ("1/7/255", 4095),
-        ("31/7/255", 65535),
-        ("1", 1),
-        (0, 0),
-        (65535, 65535),
-        ((0xFF, 0xFF), 65535),
-        (GroupAddress("1/1/111"), 2415),
-        (None, 0),
-    ]
-
-    invalid_addresses = [
-        "0/2049",
-        "0/8/0",
-        "0/0/256",
-        "32/0",
-        "0/0a",
-        "a0/0",
-        "abc",
-        65536,
-        (0xFF, 0xFFF),
-        (0xFFF, 0xFF),
-        (-1, -1),
-        [],
-    ]
-
-    @pytest.mark.parametrize("address_test,address_raw", valid_addresses)
+    @pytest.mark.parametrize("address_test,address_raw", group_addresses_valid.items())
     def test_with_valid(self, address_test, address_raw):
         """
         Test if the class constructor generates valid raw values.
@@ -151,7 +193,7 @@ class TestGroupAddress:
 
         assert GroupAddress(address_test).raw == address_raw
 
-    @pytest.mark.parametrize("address_test", invalid_addresses)
+    @pytest.mark.parametrize("address_test", group_addresses_invalid)
     def test_with_invalid(self, address_test):
         """
         Test if constructor raises an exception for all known invalid cases.
@@ -228,3 +270,77 @@ class TestGroupAddress:
         assert repr(GroupAddress("0", GroupAddressType.FREE)) == 'GroupAddress("0")'
         assert repr(GroupAddress("0", GroupAddressType.SHORT)) == 'GroupAddress("0/0")'
         assert repr(GroupAddress("0", GroupAddressType.LONG)) == 'GroupAddress("0/0/0")'
+
+
+class TestInternalGroupAddress:
+    """Test class for InternalGroupAddress."""
+
+    @pytest.mark.parametrize(
+        "address_test,address_raw", internal_group_addresses_valid.items()
+    )
+    def test_with_valid(self, address_test, address_raw):
+        """Test if the class constructor generates valid raw values."""
+
+        assert InternalGroupAddress(address_test).address == address_raw
+
+    @pytest.mark.parametrize(
+        "address_test",
+        [
+            *internal_group_addresses_invalid,
+            *group_addresses_valid,
+            *group_addresses_invalid,
+            *individual_addresses_valid,
+            *individual_addresses_invalid,
+        ],
+    )
+    def test_with_invalid(self, address_test):
+        """Test if constructor raises an exception for all known invalid cases."""
+
+        with pytest.raises(CouldNotParseAddress):
+            InternalGroupAddress(address_test)
+
+    def test_equal(self):
+        """Test if the equal operator works in all cases."""
+        assert InternalGroupAddress("i 123") == InternalGroupAddress("i 123")
+        assert InternalGroupAddress("i-asdf") == InternalGroupAddress("i asdf")
+        assert InternalGroupAddress("i-asdf") == InternalGroupAddress("Iasdf")
+        assert InternalGroupAddress("i-1") != InternalGroupAddress("i-2")
+        assert InternalGroupAddress("i-1") is not None
+        assert InternalGroupAddress("i-example") != "example"
+        assert InternalGroupAddress("i-0") != GroupAddress(0)
+        assert InternalGroupAddress("i-1") != IndividualAddress(1)
+        assert InternalGroupAddress("i-1") != 1
+
+    def test_representation(self):
+        """Test string representation of address."""
+        assert repr(InternalGroupAddress("i0")) == 'InternalGroupAddress("i-0")'
+        assert repr(InternalGroupAddress("i-0")) == 'InternalGroupAddress("i-0")'
+        assert repr(InternalGroupAddress("i 0")) == 'InternalGroupAddress("i-0")'
+
+
+class TestParseDestinationAddress:
+    """Test class for parsing destination addresses."""
+
+    @pytest.mark.parametrize("address_test", group_addresses_valid)
+    def test_parse_group_address(self, address_test):
+        """Test if the function returns GroupAddress objects."""
+        assert isinstance(parse_device_group_address(address_test), GroupAddress)
+
+    @pytest.mark.parametrize("address_test", internal_group_addresses_valid)
+    def test_parse_internal_group_address(self, address_test):
+        """Test if the function returns InternalGroupAddress objects."""
+        assert isinstance(
+            parse_device_group_address(address_test), InternalGroupAddress
+        )
+
+    @pytest.mark.parametrize(
+        "address_test",
+        [
+            *internal_group_addresses_invalid,
+            *group_addresses_invalid,
+        ],
+    )
+    def test_parse_invalid(self, address_test):
+        """Test if the function raises CouldNotParseAddress on invalid values."""
+        with pytest.raises(CouldNotParseAddress):
+            parse_device_group_address(address_test)