Repository: andrewsayre/pysmartthings

Issue:
Support for windowShadeLevel capability
Hi @andrewsayre  

Thank you for your great work of pysmartthings and Homeassistant ST integration.

Recently, ST decided to use Edge drivers as default. 
https://community.smartthings.com/t/hub-connected-devices-now-use-edge-drivers/241868

One problem might occur when using the stock Edge driver for window shades.
**The Edge driver no longer uses switchLevel capability, but now uses windowShadeLevel capability.**
https://github.com/SmartThingsCommunity/SmartThingsEdgeDrivers/blob/main/drivers/SmartThings/zigbee-window-treatment/profiles/window-treatment-profile.yml

It seems that pysmartthings and Homeassistant ST integration is not ready for this transition.
I hope you could help on this problem of **windowShadeLevel capability**.

Thank you.






Discussion:

Patch:
diff --git a/README.md b/README.md
index 641bf46..8a645e9 100644
--- a/README.md
+++ b/README.md
@@ -141,3 +141,10 @@ Devices with the `switchLevel` capability have the following function that sets
     result = await device.set_level(75, 2)
     assert result == True
 ```
+
+Devices with the `windowShadeLevel` capability have the following function that sets the target shade level.
+
+```pythonstub
+    result = await device.set_window_shade_level(50)
+    assert result == True
+```
diff --git a/pysmartthings/capability.py b/pysmartthings/capability.py
index 5e87f8b..23e019c 100644
--- a/pysmartthings/capability.py
+++ b/pysmartthings/capability.py
@@ -241,6 +241,7 @@ class Capability:
     washer_operating_state = "washerOperatingState"
     water_sensor = "waterSensor"
     window_shade = "windowShade"
+    window_shade_level = "windowShadeLevel"
 
 
 class Attribute:
@@ -336,6 +337,7 @@ class Attribute:
     rssi = "rssi"
     saturation = "saturation"
     schedule = "schedule"
+    shade_level = "shadeLevel"
     smoke = "smoke"
     sound = "sound"
     st = "st"
diff --git a/pysmartthings/device.py b/pysmartthings/device.py
index a5d92f1..918f3fc 100644
--- a/pysmartthings/device.py
+++ b/pysmartthings/device.py
@@ -67,6 +67,7 @@ class Command:
     set_saturation = "setSaturation"
     set_thermostat_fan_mode = "setThermostatFanMode"
     set_thermostat_mode = "setThermostatMode"
+    set_shade_level = "setShadeLevel"
     unlock = "unlock"
     mute = "mute"
     unmute = "unmute"
@@ -727,6 +728,18 @@ class DeviceStatusBase:
         """Get the trackDescription attribute."""
         return self._attributes["trackDescription"].value
 
+    @property
+    def shade_level(self) -> int:
+        """Get the shadeLevel attribute, scaled 0-100."""
+        return int(self._attributes[Attribute.shade_level].value or 0)
+
+    @shade_level.setter
+    def shade_level(self, value: int):
+        """Set the level of the attribute, scaled 0-100."""
+        if not 0 <= value <= 100:
+            raise ValueError("value must be scaled between 0-100.")
+        self.update_attribute_value(Attribute.shade_level, value)
+
 
 class DeviceStatus(DeviceStatusBase):
     """Define the device status."""
@@ -1390,6 +1403,28 @@ class DeviceEntity(Entity, Device):
             component_id, Capability.tv_channel, Command.channel_down
         )
 
+    async def set_window_shade_level(
+        self,
+        level: int,
+        set_status: bool = False,
+        *,
+        component_id: str = "main",
+    ) -> bool:
+        """Call the set shade level device command."""
+        if not 0 <= level <= 100:
+            raise ValueError("level must be scaled between 0-100.")
+
+        result = await self.command(
+            component_id,
+            Capability.window_shade_level,
+            Command.set_shade_level,
+            [level],
+        )
+        if result and set_status:
+            self.status.shade_level = level
+            self.status.switch = level > 0
+        return result
+
     @property
     def status(self):
         """Get the status entity of the device."""
diff --git a/requirements.txt b/requirements.txt
index 448801e..9a8a7f2 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,1 +1,1 @@
-aiohttp==3.8.0
\ No newline at end of file
+aiohttp==3.8.4
\ No newline at end of file


Test patch:
diff --git a/test-requirements.txt b/test-requirements.txt
index c9ebce7..8e881d8 100644
--- a/test-requirements.txt
+++ b/test-requirements.txt
@@ -1,4 +1,4 @@
-black==21.10b0
+black==22.3.0
 coveralls==3.3.0
 flake8==4.0.1
 flake8-docstrings==1.6.0
diff --git a/tests/json/device_command_post_set_shade_level.json b/tests/json/device_command_post_set_shade_level.json
new file mode 100644
index 0000000..5524eaf
--- /dev/null
+++ b/tests/json/device_command_post_set_shade_level.json
@@ -0,0 +1,10 @@
+{
+  "commands": [
+    {
+      "component": "main",
+      "capability": "windowShadeLevel",
+      "command": "setShadeLevel",
+      "arguments": [75]
+    }
+  ]
+}
\ No newline at end of file
diff --git a/tests/test_device.py b/tests/test_device.py
index a1f9e19..db477fc 100644
--- a/tests/test_device.py
+++ b/tests/test_device.py
@@ -1210,6 +1210,44 @@ class TestDeviceEntity:
         # Assert
         assert result
 
+    @staticmethod
+    @pytest.mark.asyncio
+    async def test_set_window_shade_level(api):
+        """Tests the set_window_shade_level method."""
+        # Arrange
+        device = DeviceEntity(api, device_id=DEVICE_ID)
+        # Act
+        result = await device.set_window_shade_level(75)
+        # Assert
+        assert result
+        assert device.status.level == 0
+        assert not device.status.switch
+
+    @staticmethod
+    @pytest.mark.asyncio
+    async def test_set_window_shade_level_invalid(api):
+        """Tests the set_window_shade_level method invalid values."""
+        # Arrange
+        device = DeviceEntity(api, device_id=DEVICE_ID)
+        # Assert level
+        levels = [-1, 101]
+        for level in levels:
+            with pytest.raises(ValueError):
+                await device.set_window_shade_level(level)
+
+    @staticmethod
+    @pytest.mark.asyncio
+    async def test_set_window_shade_level_update(api):
+        """Tests the set_window_shade_level method."""
+        # Arrange
+        device = DeviceEntity(api, device_id=DEVICE_ID)
+        # Act
+        result = await device.set_window_shade_level(75, True)
+        # Assert
+        assert result
+        assert device.status.shade_level == 75
+        assert device.status.switch
+
 
 class TestDeviceStatus:
     """Tests for the DeviceStatus class."""
@@ -1655,3 +1693,24 @@ class TestDeviceStatus:
         assert status.power_consumption_energy_saved is None
         assert status.power_consumption_persisted_energy is None
         assert status.power_consumption_power_energy is None
+
+    @staticmethod
+    def test_shade_level():
+        """Tests the shade_level property."""
+        # Arrange
+        status = DeviceStatus(None, device_id=DEVICE_ID)
+        # Act
+        status.shade_level = 50
+        # Assert
+        assert status.shade_level == 50
+
+    @staticmethod
+    def test_shade_level_range():
+        """Tests the shade_level property's range."""
+        # Arrange
+        status = DeviceStatus(None, device_id=DEVICE_ID)
+        # Act/Assert
+        values = [-1, 101]
+        for value in values:
+            with pytest.raises(ValueError):
+                status.shade_level = value