Repository: gurumitts/pylutron-caseta

Issue:
Support for SerenaTiltOnlyWoodBlind
Please add support for SerenaTiltOnlyWoodBlind

Discussion:
rlshaw23: I have 4 of these. I'm no programmer but I will would be able to test this.
mdonoughe: If we just add SerenaTiltOnlyWoodBlind to the same list as TriathlonRollerShade, how does the consuming code know that SerenaTiltOnlyWoodBlinds only support tilting and TriathlonRollerShades only support raising and lowering? I suspect with this code the blinds will appear in something like Home Assistant, but the default interaction will probably be to control the position that these blinds don't have.

It doesn't look like pylutron_caseta contains any code for controlling tilt and I expect it will be different from raising/lowering or else they wouldn't be able to sell any shades that have both raise/lower and tilt.
johninaustin: I don't have shades to do any testing, but it seems there are different command types to work with different zones, and some include Tilt. 

    Raise,
    Lower,
    Stop,
    RaiseTilt,
    LowerTilt,
    StopTilt,
    GoToSwitchedLevel,
    GoToDimmedLevel,
    GoToShadeLevel,
    GoToShadeLevelWithTilt,
    GoToSpectrumTuningLevel,
    GoToReceptacleLevel,
    GoToCCOLevel,
    GoToFanSpeed,
    Activate,
    GoToGroupLightingLevel,
    GoToScene

If you do GoToShadeLevelWithTilt, it will take two parameters in the body - Tilt & Level.

When you query the zone, you should get Tilt the same way you get Level and FanSpeed. I expect setting works the same as well. 
mdonoughe: I think we're missing a GoToTilt command, but more than that this library does not have support for tilting shades at all. The tilt level is being reported as a Tilt property that needs to be read.
mdonoughe: Using the leap command from #97, can you figure out how to read and control the tilt?
mdonoughe: Can somebody install the latest development version with the cli extra (see [The leap tool](https://github.com/gurumitts/pylutron-caseta#the-leap-tool)) and try this command:

```
leap -X CreateRequest BRIDGE_IP/device/DEVICE_ID/commandprocessor -d '{"Command":{"CommandType":"GoToTilt","TiltParameters":{"Tilt":50}}}'
```

Maybe it will set the tilt to 50. My blinds are ZWave so I can't test this myself.
rlshaw23: I’m running home assistant os. I tried to install the cli via putty. No go, pip not recognized.  I’m assuming that I won’t be able to test this command? I do have the Serena tilt only blinds.

From: Matthew Donoughe ***@***.***>
Sent: Sunday, April 17, 2022 8:46 AM
To: gurumitts/pylutron-caseta ***@***.***>
Cc: Randall Shaw ***@***.***>; Comment ***@***.***>
Subject: Re: [gurumitts/pylutron-caseta] Add support for SerenaTiltOnlyWoodBlind (PR #89)


Can somebody install the latest development version with the cli extra (see The leap tool<https://github.com/gurumitts/pylutron-caseta#the-leap-tool>) and try this command:

leap -X CreateRequest BRIDGE_IP/device/DEVICE_ID/commandprocessor -d '{"Command":{"CommandType":"GoToTilt","TiltParameters":{"Tilt":50}}}'

Maybe it will set the tilt to 50. My blinds are ZWave so I can't test this myself.

—
Reply to this email directly, view it on GitHub<https://github.com/gurumitts/pylutron-caseta/pull/89#issuecomment-1100878922>, or unsubscribe<https://github.com/notifications/unsubscribe-auth/ASCV7SLXCSXKSIWJJFA72ADVFQIYTANCNFSM5NV3S46Q>.
You are receiving this because you commented.Message ID: ***@***.******@***.***>>

mdonoughe: I'm not sure how you would do it on Home Assistant OS. I've only ever used Home Assistant Core. If pip isn't found, you may need to use pip3 instead. It may be easier to run the cli from another computer.

It can be used from Windows but if all you have is Windows it may be easier using Python in WSL than Windows Python. I had problems with quotes in command line arguments. I don't know if it was PowerShell's native command quoting bugs or Python or both.
rlshaw23: When I installed it this is the error message I get

pip3 install pylutron_caseta[cli]
Collecting pylutron_caseta[cli]
  Downloading pylutron_caseta-0.13.1-py3-none-any.whl (17 kB)
  WARNING: pylutron-caseta 0.13.1 does not provide the extra 'cli'

Any ideas what could be wrong. The leap tool will not work
mdonoughe: The CLI hasn't been released yet. For now, you need to download the code from GitHub and do `pip install .[cli]` from inside the pylutron-caseta folder.
rlshaw23: I cloned the directory and ran pip install .[cli] and this is what I get. see attached. Any thoughts?
![error capture](https://user-images.githubusercontent.com/75849673/167520515-df39aad3-e63d-444d-822f-a6c1f6e3429d.PNG)


mdonoughe: I think you need to update pip.
DougieJynx: > 
@mdonoughe just as an aside - I don't believe they sell any blinds that do both raise/lower and tilt, it's either/or for all of their products, so I wonder if they don't use the same functions for tilt as they do raise/lower.  I know the Lutron app allows you to set the boundaries for open and closed manually within the app, so maybe based on that they can send the same commands to the tilt blinds that they would send to the roller blinds?  As an additional aside I had the tilt blinds in SmartThings and they seem to integrate well there (although even if you integrate ST into HA it still won't bring over the blinds).

DougieJynx: > -d '{"Command":{"CommandType":"GoToTilt","TiltParameters":{"Tilt":50}}}'


I'm not sure if this is helpful... I got a request back for TiltParameters when poking around with Tilt...

$:~/.config/pylutron_caseta# device=$(leap "${ip}/zone/status/expanded?where=Zone.ControlType:\"Tilt\"" | jq -r '.ZoneExpandedStatuses[0].Zone.href')
root@gemini:~/.config/pylutron_caseta# echo $device
/zone/21
$:~/.config/pylutron_caseta# leap -X CreateRequest "${ip}${device}/commandprocessor" -d '{"Command":{"CommandType":"GoToLevel","Parameter":[{"Type":"Level","Value":10}]}}'
{"Message": "GoToLevel not supported for zone type: Tilt"}
$:~/.config/pylutron_caseta# leap -X CreateRequest "${ip}${device}/commandprocessor" -d '{"Command":{"CommandType":"Tilt","Parameter":[{"Type":"Level","Value":10}]}}'
{"Message": "This command is not supported: Tilt"}
$:~/.config/pylutron_caseta# leap -X CreateRequest "${ip}${device}/commandprocessor" -d '{"Command":{"CommandType":"GoToTilt","Parameter":[{"Type":"Level","Value":10}]}}'
{"Message": "The body is missing a required property : TiltParameters"}


DougieJynx: IT WORKED!!!!

$:~/.config/pylutron_caseta# leap -X CreateRequest "${ip}${device}/commandprocessor" -d '{"Command":{"CommandType":"GoToTilt","TiltParameters":{"Tilt":50}}}'
{"ZoneStatus": {"href": "/zone/21/status", "Tilt": 50, "Zone": {"href": "/zone/21"}, "StatusAccuracy": "Good", "Availability": "Available"}}
$:~/.config/pylutron_caseta# leap -X CreateRequest "${ip}${device}/commandprocessor" -d '{"Command":{"CommandType":"GoToTilt","TiltParameters":{"Tilt":25}}}'
{"ZoneStatus": {"href": "/zone/21/status", "Tilt": 25, "Zone": {"href": "/zone/21"}, "StatusAccuracy": "Good", "Availability": "Available"}}


mdonoughe: Based on that, I added support for getting and setting the tilt, similar to how the existing code has been working. However, the way it works for projects like Home Assistant is not great. Basically, they know a cover supports tilt if `device['tilt'] is not None` and they know a cover supports raise/lower if `device['current_state'] != -1`.

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index ca2e5e9..235c1a7 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 
 ### Added
 
+- Support for Serena tilt-only wood blinds.
 - New command line tools: lap-pair, leap-scan, leap.
 
 ### Removed
diff --git a/pylutron_caseta/__init__.py b/pylutron_caseta/__init__.py
index 1f7ff92..87c76c3 100644
--- a/pylutron_caseta/__init__.py
+++ b/pylutron_caseta/__init__.py
@@ -21,6 +21,7 @@ _LEAP_DEVICE_TYPES = {
         "TriathlonHoneycombShade",
         "TriathlonRollerShade",
         "QsWirelessShade",
+        "SerenaTiltOnlyWoodBlind",
     ],
     "sensor": [
         "Pico1Button",
diff --git a/pylutron_caseta/smartbridge.py b/pylutron_caseta/smartbridge.py
index b9146a3..a8c3cc6 100644
--- a/pylutron_caseta/smartbridge.py
+++ b/pylutron_caseta/smartbridge.py
@@ -376,6 +376,28 @@ class Smartbridge:
                 },
             )
 
+    async def set_tilt(self, device_id: str, value: int):
+        """
+        Set the tilt for tiltable blinds.
+
+        :param device_id: The device ID of the blinds.
+        :param value: The desired tilt between 0 and 100.
+        """
+        zone_id = self._get_zone_id(device_id)
+        if zone_id:
+            await self._request(
+                "CreateRequest",
+                f"/zone/{zone_id}/commandprocessor",
+                {
+                    "Command": {
+                        "CommandType": "GoToTilt",
+                        "TiltParameters": {
+                            "Tilt": value,
+                        },
+                    },
+                },
+            )
+
     async def turn_on(self, device_id: str, **kwargs):
         """
         Will turn 'on' the device with the given ID.
@@ -483,10 +505,12 @@ class Smartbridge:
         zone = id_from_href(status["Zone"]["href"])
         level = status.get("Level", -1)
         fan_speed = status.get("FanSpeed", None)
+        tilt = status.get("Tilt", None)
         _LOG.debug("zone=%s level=%s", zone, level)
         device = self.get_device_by_zone_id(zone)
         device["current_state"] = level
         device["fan_speed"] = fan_speed
+        device["tilt"] = tilt
         if device["device_id"] in self._subscribers:
             self._subscribers[device["device_id"]]()
 
@@ -600,7 +624,12 @@ class Smartbridge:
             device_name = "_".join(device["FullyQualifiedName"])
             self.devices.setdefault(
                 device_id,
-                {"device_id": device_id, "current_state": -1, "fan_speed": None},
+                {
+                    "device_id": device_id,
+                    "current_state": -1,
+                    "fan_speed": None,
+                    "tilt": None,
+                },
             ).update(
                 zone=device_zone,
                 name=device_name,


Test patch:
diff --git a/tests/responses/devices.json b/tests/responses/devices.json
index d9cb6bf..e4fa5ef 100644
--- a/tests/responses/devices.json
+++ b/tests/responses/devices.json
@@ -251,6 +251,30 @@
                 "DeviceRules": [{
                     "href": "/devicerule/32"
                 }]
+            },
+            {
+                "href": "/device/10",
+                "Name": "Blinds",
+                "FullyQualifiedName": ["Living Room", "Blinds"],
+                "Parent": {
+                    "href": "/project"
+                },
+                "SerialNumber": 4567,
+                "ModelNumber": "SYC-EDU-B-J",
+                "DeviceType": "SerenaTiltOnlyWoodBlind",
+                "LocalZones": [{
+                    "href": "/zone/3"
+                }],
+                "AssociatedArea": {
+                    "href": "/area/2"
+                },
+                "LinkNodes": [{
+                    "href": "/device/10/linknode/18"
+                }],
+                "DeviceRules": [{
+                    "href": "/devicerule/125"
+                }],
+                "AddressedState": "Addressed"
             }
         ]
     }
diff --git a/tests/test_smartbridge.py b/tests/test_smartbridge.py
index 2eaa4cf..382b866 100644
--- a/tests/test_smartbridge.py
+++ b/tests/test_smartbridge.py
@@ -238,7 +238,7 @@ class Bridge:
 
         # Finally, we should check the zone status on each zone
         requested_zones = []
-        for _ in range(0, 3):
+        for _ in range(0, 4):
             request, response = await wait(leap.requests.get())
             logging.info("Read %s", request)
             assert request.communique_type == "ReadRequest"
@@ -254,7 +254,6 @@ class Bridge:
                     Body={
                         "ZoneStatus": {
                             "href": request.url,
-                            "Level": 0,
                             "Zone": {"href": request.url.replace("/status", "")},
                             "StatusAccuracy": "Good",
                         }
@@ -263,7 +262,12 @@ class Bridge:
             )
             leap.requests.task_done()
         requested_zones.sort()
-        assert requested_zones == ["/zone/1/status", "/zone/2/status", "/zone/6/status"]
+        assert requested_zones == [
+            "/zone/1/status",
+            "/zone/2/status",
+            "/zone/3/status",
+            "/zone/6/status",
+        ]
 
     def disconnect(self, exception=None):
         """Disconnect SmartBridge."""
@@ -350,6 +354,7 @@ async def test_device_list(bridge: Bridge):
             "model": "L-BDG2-WH",
             "serial": 1234,
             "button_groups": None,
+            "tilt": None,
         },
         "2": {
             "device_id": "2",
@@ -358,9 +363,10 @@ async def test_device_list(bridge: Bridge):
             "zone": "1",
             "model": "PD-6WCL-XX",
             "serial": 2345,
-            "current_state": 0,
+            "current_state": -1,
             "fan_speed": None,
             "button_groups": None,
+            "tilt": None,
         },
         "3": {
             "device_id": "3",
@@ -369,9 +375,10 @@ async def test_device_list(bridge: Bridge):
             "zone": "2",
             "model": "PD-FSQN-XX",
             "serial": 3456,
-            "current_state": 0,
+            "current_state": -1,
             "fan_speed": None,
             "button_groups": None,
+            "tilt": None,
         },
         "4": {
             "device_id": "4",
@@ -383,6 +390,7 @@ async def test_device_list(bridge: Bridge):
             "fan_speed": None,
             "zone": None,
             "button_groups": None,
+            "tilt": None,
         },
         "5": {
             "device_id": "5",
@@ -394,6 +402,7 @@ async def test_device_list(bridge: Bridge):
             "fan_speed": None,
             "zone": None,
             "button_groups": None,
+            "tilt": None,
         },
         "6": {
             "device_id": "6",
@@ -405,6 +414,7 @@ async def test_device_list(bridge: Bridge):
             "fan_speed": None,
             "zone": None,
             "button_groups": None,
+            "tilt": None,
         },
         "7": {
             "device_id": "7",
@@ -412,10 +422,11 @@ async def test_device_list(bridge: Bridge):
             "type": "QsWirelessShade",
             "model": "QSYC-J-RCVR",
             "serial": 1234,
-            "current_state": 0,
+            "current_state": -1,
             "fan_speed": None,
             "zone": "6",
             "button_groups": None,
+            "tilt": None,
         },
         "8": {
             "device_id": "8",
@@ -427,6 +438,7 @@ async def test_device_list(bridge: Bridge):
             "fan_speed": None,
             "button_groups": ["2"],
             "zone": None,
+            "tilt": None,
         },
         "9": {
             "button_groups": ["5", "6"],
@@ -438,6 +450,19 @@ async def test_device_list(bridge: Bridge):
             "serial": 92322656,
             "type": "FourGroupRemote",
             "zone": None,
+            "tilt": None,
+        },
+        "10": {
+            "device_id": "10",
+            "name": "Living Room_Blinds",
+            "type": "SerenaTiltOnlyWoodBlind",
+            "zone": "3",
+            "model": "SYC-EDU-B-J",
+            "serial": 4567,
+            "current_state": -1,
+            "fan_speed": None,
+            "button_groups": None,
+            "tilt": None,
         },
     }
 
@@ -465,11 +490,24 @@ async def test_device_list(bridge: Bridge):
             Body={"ZoneStatus": {"FanSpeed": "Medium", "Zone": {"href": "/zone/2"}}},
         )
     )
+    bridge.leap.send_unsolicited(
+        Response(
+            CommuniqueType="ReadResponse",
+            Header=ResponseHeader(
+                MessageBodyType="OneZoneStatus",
+                StatusCode=ResponseStatus(200, "OK"),
+                Url="/zone/3/status",
+            ),
+            Body={"ZoneStatus": {"Tilt": 25, "Zone": {"href": "/zone/3"}}},
+        )
+    )
     devices = bridge.target.get_devices()
     assert devices["2"]["current_state"] == 100
     assert devices["2"]["fan_speed"] is None
     assert devices["3"]["current_state"] == -1
     assert devices["3"]["fan_speed"] == FAN_MEDIUM
+    assert devices["10"]["current_state"] == -1
+    assert devices["10"]["tilt"] == 25
 
     devices = bridge.target.get_devices_by_domain("light")
     assert len(devices) == 1
@@ -493,6 +531,12 @@ async def test_device_list(bridge: Bridge):
     assert len(devices) == 1
     assert devices[0]["device_id"] == "3"
 
+    devices = bridge.target.get_devices_by_domain("cover")
+    assert [device["device_id"] for device in devices] == ["7", "10"]
+
+    devices = bridge.target.get_devices_by_type("SerenaTiltOnlyWoodBlind")
+    assert [device["device_id"] for device in devices] == ["10"]
+
 
 @pytest.mark.asyncio
 async def test_lip_device_list(bridge: Bridge):
@@ -941,6 +985,25 @@ async def test_set_fan(bridge: Bridge, event_loop):
     task.cancel()
 
 
+@pytest.mark.asyncio
+async def test_set_tilt(bridge: Bridge, event_loop):
+    """Test that setting tilt produces the right commands."""
+    task = event_loop.create_task(bridge.target.set_tilt("10", 50))
+    command, _ = await bridge.leap.requests.get()
+    assert command == Request(
+        communique_type="CreateRequest",
+        url="/zone/3/commandprocessor",
+        body={
+            "Command": {
+                "CommandType": "GoToTilt",
+                "TiltParameters": {"Tilt": 50},
+            }
+        },
+    )
+    bridge.leap.requests.task_done()
+    task.cancel()
+
+
 @pytest.mark.asyncio
 async def test_lower_cover(bridge: Bridge, event_loop):
     """Test that lowering a cover produces the right commands."""