Repository: XKNX/xknx

Issue:
Feature Request: lock and diagnostic attributes for XKNX devices
Hi,

would it be possible to extend light, cover, switch and climate (probably even more/all) entities with GAs for additional attributes to see in which actual state they are? As you know, in KNX most things can be locked, have a locked/alarm state and have diagnostic related values you can read out. So my request would be to add the following GAs:

light, cover, switch, climate (all?)
- lock_state (DPT 1.011)
- lock (DPT 1.003) (once entities can be locked in HA)
- diagnostic_state (DPT 16.000/16.001 - string)

For covers, it would be also nice to have an attribute GA for
- automatic_shading_state (DPT 1.011)

If you don't want to add these to the XKNX entities, it would be nice to be able to define custom attributes by specifying an address, DPT and name. Something like:
```
knx:
  covers:
    - name: "Home Diningroom"
      move_long_address: '2/1/35'
      move_short_address: '2/2/35'
      position_address: '2/3/35'
      angle_address: '2/4/35'
      position_state_address: '2/5/35'
      angle_state_address: '2/6/35'
      travelling_time_down: 19
      travelling_time_up: 19
      attributes:
        - name: "Automatic shading"
           type: 'bool'
           state_address: '2/7/35'
        - name: "Diagnostic"
           type: 'string'
           state_address: '30/1/35'
```

Thanks.

Discussion:
coveralls: 
[![Coverage Status](https://coveralls.io/builds/38510887/badge)](https://coveralls.io/builds/38510887)

Coverage remained the same at 94.72% when pulling **f96dfc3955794b2f102b57f08c7089b9f507d5b9 on marvin-w:feature/add_blocked_attribute_to_cover** into **8f1d4485e31738d4113a8ddaf0d7e57a4aaf2cd5 on XKNX:main**.


Patch:
diff --git a/changelog.md b/changelog.md
index 23cd52aa..1abf723f 100644
--- a/changelog.md
+++ b/changelog.md
@@ -2,6 +2,7 @@
 
 ## Unreleased changes
 
+- Add support for cover lock
 - Drop support for python 3.7
 
 ### Internals
diff --git a/docs/cover.md b/docs/cover.md
index 1ef4700d..a89b88c8 100644
--- a/docs/cover.md
+++ b/docs/cover.md
@@ -22,6 +22,7 @@ Shutters are simple representations of blind/roller cover actuators. With XKNX y
 - `group_address_position_state` KNX group address for retrieving the relative position. *DPT 5.001*
 - `group_address_angle` KNX group address to tilt blinds to relative position. *DPT 5.001*
 - `group_address_angle_state` KNX group address to retrieve angle of blinds. *DPT 5.001*
+- `group_address_locked_state` KNX group address to retrieve if manual interaction with the cover is currently disabled. *DPT 1.001*
 - `travel_time_down` seconds to reach lower end position. Default: 22
 - `travel_time_up` seconds to reach upper end position. Default: 22
 - `invert_position` invert position (payload for eg. set_up() and relative position). Default: False
@@ -40,6 +41,7 @@ cover = Cover(xknx,
               group_address_position_state='1/2/4',
               group_address_angle='1/2/5',
               group_address_angle_state='1/2/6',
+              group_address_locked_state='1/2/7',
               travel_time_down=50,
               travel_time_up=60,
               invert_position=False,
@@ -83,6 +85,7 @@ is_closed = cover.is_closed()
 is_traveling = cover.is_traveling()
 is_opening = cover.is_opening()
 is_closing = cover.is_closing()
+is_locked = cover.is_locked()
 
 # Accessing cover via 'do'
 await cover.do('up')
diff --git a/home-assistant-plugin/custom_components/xknx/const.py b/home-assistant-plugin/custom_components/xknx/const.py
index 52e15a17..f4df78f7 100644
--- a/home-assistant-plugin/custom_components/xknx/const.py
+++ b/home-assistant-plugin/custom_components/xknx/const.py
@@ -26,6 +26,7 @@ CONF_SYNC_STATE = "sync_state"
 CONF_RESET_AFTER = "reset_after"
 
 ATTR_COUNTER = "counter"
+ATTR_MOVEMENT_LOCKED = "movement_locked"
 
 
 class ColorTempModes(Enum):
diff --git a/home-assistant-plugin/custom_components/xknx/cover.py b/home-assistant-plugin/custom_components/xknx/cover.py
index c45d057c..7e53a7aa 100644
--- a/home-assistant-plugin/custom_components/xknx/cover.py
+++ b/home-assistant-plugin/custom_components/xknx/cover.py
@@ -26,7 +26,7 @@ from homeassistant.helpers.entity import Entity
 from homeassistant.helpers.event import async_track_utc_time_change
 from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
 
-from .const import DOMAIN
+from .const import ATTR_MOVEMENT_LOCKED, DOMAIN
 from .knx_entity import KnxEntity
 
 
@@ -113,6 +113,13 @@ class KNXCover(KnxEntity, CoverEntity):
         """Return if the cover is closing or not."""
         return self._device.is_closing()
 
+    @property
+    def extra_state_attributes(self) -> dict[str, Any] | None:
+        """Return device specific state attributes."""
+        if self._device.supports_locked:
+            return {ATTR_MOVEMENT_LOCKED: self._device.is_locked()}
+        return None
+
     async def async_close_cover(self, **kwargs: Any) -> None:
         """Close the cover."""
         await self._device.set_down()
diff --git a/home-assistant-plugin/custom_components/xknx/expose.py b/home-assistant-plugin/custom_components/xknx/expose.py
index 576eada5..043807d2 100644
--- a/home-assistant-plugin/custom_components/xknx/expose.py
+++ b/home-assistant-plugin/custom_components/xknx/expose.py
@@ -135,9 +135,9 @@ class KNXExposeSensor:
             value = self.expose_default
 
         if self.type == "binary":
-            if value == STATE_ON:
+            if value in (1, STATE_ON, "True"):
                 value = True
-            elif value == STATE_OFF:
+            elif value in (0, STATE_OFF, "False"):
                 value = False
 
         await self.device.set(value)
diff --git a/home-assistant-plugin/custom_components/xknx/factory.py b/home-assistant-plugin/custom_components/xknx/factory.py
index b0b5bcc4..5b55e436 100644
--- a/home-assistant-plugin/custom_components/xknx/factory.py
+++ b/home-assistant-plugin/custom_components/xknx/factory.py
@@ -84,6 +84,7 @@ def _create_cover(knx_module: XKNX, config: ConfigType) -> XknxCover:
         ),
         group_address_angle=config.get(CoverSchema.CONF_ANGLE_ADDRESS),
         group_address_angle_state=config.get(CoverSchema.CONF_ANGLE_STATE_ADDRESS),
+        group_address_locked_state=config.get(CoverSchema.CONF_LOCKED_STATE_ADDRESS),
         group_address_position=config.get(CoverSchema.CONF_POSITION_ADDRESS),
         travel_time_down=config[CoverSchema.CONF_TRAVELLING_TIME_DOWN],
         travel_time_up=config[CoverSchema.CONF_TRAVELLING_TIME_UP],
diff --git a/home-assistant-plugin/custom_components/xknx/schema.py b/home-assistant-plugin/custom_components/xknx/schema.py
index 3139a757..60c45db3 100644
--- a/home-assistant-plugin/custom_components/xknx/schema.py
+++ b/home-assistant-plugin/custom_components/xknx/schema.py
@@ -222,6 +222,7 @@ class CoverSchema:
     CONF_POSITION_STATE_ADDRESS = "position_state_address"
     CONF_ANGLE_ADDRESS = "angle_address"
     CONF_ANGLE_STATE_ADDRESS = "angle_state_address"
+    CONF_LOCKED_STATE_ADDRESS = "locked_state_address"
     CONF_TRAVELLING_TIME_DOWN = "travelling_time_down"
     CONF_TRAVELLING_TIME_UP = "travelling_time_up"
     CONF_INVERT_POSITION = "invert_position"
@@ -240,6 +241,7 @@ class CoverSchema:
             vol.Optional(CONF_POSITION_STATE_ADDRESS): ga_list_validator,
             vol.Optional(CONF_ANGLE_ADDRESS): ga_list_validator,
             vol.Optional(CONF_ANGLE_STATE_ADDRESS): ga_list_validator,
+            vol.Optional(CONF_LOCKED_STATE_ADDRESS): ga_list_validator,
             vol.Optional(
                 CONF_TRAVELLING_TIME_DOWN, default=DEFAULT_TRAVEL_TIME
             ): cv.positive_float,
diff --git a/xknx/devices/cover.py b/xknx/devices/cover.py
index ed0f98b5..25c649a8 100644
--- a/xknx/devices/cover.py
+++ b/xknx/devices/cover.py
@@ -46,6 +46,7 @@ class Cover(Device):
         group_address_position_state: Optional[GroupAddressesType] = None,
         group_address_angle: Optional[GroupAddressesType] = None,
         group_address_angle_state: Optional[GroupAddressesType] = None,
+        group_address_locked_state: Optional[GroupAddressesType] = None,
         travel_time_down: float = DEFAULT_TRAVEL_TIME_DOWN,
         travel_time_up: float = DEFAULT_TRAVEL_TIME_UP,
         invert_position: bool = False,
@@ -115,6 +116,14 @@ class Cover(Device):
             range_to=angle_range_to,
         )
 
+        self.locked = RemoteValueSwitch(
+            xknx,
+            group_address_state=group_address_locked_state,
+            device_name=self.name,
+            feature_name="Locked",
+            after_update_cb=self.after_update,
+        )
+
         self.travel_time_down = travel_time_down
         self.travel_time_up = travel_time_up
 
@@ -131,6 +140,7 @@ class Cover(Device):
         yield self.position_current
         yield self.position_target
         yield self.angle
+        yield self.locked
 
     @property
     def unique_id(self) -> Optional[str]:
@@ -180,6 +190,7 @@ class Cover(Device):
             'position_current="{}" '
             'position_target="{}" '
             'angle="{}" '
+            'locked="{}" '
             'travel_time_down="{}" '
             'travel_time_up="{}" />'.format(
                 self.name,
@@ -189,6 +200,7 @@ class Cover(Device):
                 self.position_current.group_addr_str(),
                 self.position_target.group_addr_str(),
                 self.angle.group_addr_str(),
+                self.locked.group_addr_str(),
                 self.travel_time_down,
                 self.travel_time_up,
             )
@@ -359,6 +371,7 @@ class Cover(Device):
         await self.position_current.process(telegram, always_callback=True)
         await self.position_target.process(telegram)
         await self.angle.process(telegram)
+        await self.locked.process(telegram)
 
     def current_position(self) -> Optional[int]:
         """Return current position of cover."""
@@ -368,6 +381,10 @@ class Cover(Device):
         """Return current tilt angle of cover."""
         return self.angle.value
 
+    def is_locked(self) -> Optional[bool]:
+        """Return if the cover is currently locked for manual movement."""
+        return self.locked.value
+
     def is_traveling(self) -> bool:
         """Return if cover is traveling at the moment."""
         return self.travelcalculator.is_traveling()
@@ -397,6 +414,11 @@ class Cover(Device):
         """Return if cover supports manual stopping."""
         return self.stop_.writable or self.step.writable
 
+    @property
+    def supports_locked(self) -> bool:
+        """Return if cover supports locking."""
+        return self.locked.initialized
+
     @property
     def supports_position(self) -> bool:
         """Return if cover supports direct positioning."""


Test patch:
diff --git a/test/devices_tests/cover_test.py b/test/devices_tests/cover_test.py
index b3d65058..9e588216 100644
--- a/test/devices_tests/cover_test.py
+++ b/test/devices_tests/cover_test.py
@@ -99,6 +99,26 @@ class TestCover:
         )
         assert not cover.supports_angle
 
+    #
+    # SUPPORTS LOCKED
+    #
+    def test_support_locked(self):
+        """Test support_position_true."""
+        xknx = XKNX()
+        cover_locked = Cover(
+            xknx,
+            "Children.Venetian",
+            group_address_locked_state="1/4/14",
+        )
+        assert cover_locked.supports_locked
+        cover_manual_stop = Cover(
+            xknx,
+            "Children.Venetian",
+            group_address_long="1/4/14",
+            group_address_stop="1/4/15",
+        )
+        assert cover_manual_stop.supports_locked is False
+
     #
     # SYNC
     #
@@ -665,6 +685,21 @@ class TestCover:
         await cover.process(telegram)
         assert cover.current_angle() == 16
 
+    async def test_process_locked(self):
+        """Test process / reading telegrams from telegram queue. Test if position is processed correctly."""
+        xknx = XKNX()
+        cover = Cover(
+            xknx,
+            "TestCover",
+            group_address_long="1/2/1",
+            group_address_locked_state="1/2/4",
+        )
+        telegram = Telegram(
+            GroupAddress("1/2/4"), payload=GroupValueWrite(DPTBinary(1))
+        )
+        await cover.process(telegram)
+        assert cover.is_locked() is True
+
     async def test_process_up(self):
         """Test process / reading telegrams from telegram queue. Test if up/down is processed correctly."""
         xknx = XKNX()
diff --git a/test/str_test.py b/test/str_test.py
index eef934cb..582a1531 100644
--- a/test/str_test.py
+++ b/test/str_test.py
@@ -157,6 +157,7 @@ class TestStringRepresentations:
             group_address_position_state="1/2/6",
             group_address_angle="1/2/7",
             group_address_angle_state="1/2/8",
+            group_address_locked_state="1/2/9",
             travel_time_down=8,
             travel_time_up=10,
         )
@@ -165,7 +166,7 @@ class TestStringRepresentations:
             == '<Cover name="Rolladen" updown="GroupAddress("1/2/2")/None/None/None" step="GroupAddress("1/2/3")/None/None/None" '
             'stop="GroupAddress("1/2/4")/None/None/None" '
             'position_current="None/GroupAddress("1/2/6")/None/None" position_target="GroupAddress("1/2/5")/None/None/None" '
-            'angle="GroupAddress("1/2/7")/GroupAddress("1/2/8")/None/None" '
+            'angle="GroupAddress("1/2/7")/GroupAddress("1/2/8")/None/None" locked="None/GroupAddress("1/2/9")/None/None" '
             'travel_time_down="8" travel_time_up="10" />'
         )