pax_global_header00006660000000000000000000000064147634360220014521gustar00rootroot0000000000000052 comment=ad98539deed25c102529420f1c100bbc07a21613 pylutron-caseta-0.24.0/000077500000000000000000000000001476343602200147365ustar00rootroot00000000000000pylutron-caseta-0.24.0/.devcontainer/000077500000000000000000000000001476343602200174755ustar00rootroot00000000000000pylutron-caseta-0.24.0/.devcontainer/devcontainer.json000066400000000000000000000010621476343602200230500ustar00rootroot00000000000000{ "name": "Python 3.11", "image": "mcr.microsoft.com/vscode/devcontainers/python:1-3.11", "containerUser": "vscode", "customizations": { "vscode": { "settings": { "python.analysis.typeCheckingMode": "basic", "python.defaultInterpreterPath": "/home/vscode/.env/default/bin/python" }, "extensions": [ "charliermarsh.ruff", "ms-python.mypy-type-checker", "ms-python.python", "visualstudioexptteam.vscodeintellicode", "esbenp.prettier-vscode" ] } }, "updateContentCommand": "bash ./.devcontainer/updateContent.sh" } pylutron-caseta-0.24.0/.devcontainer/updateContent.sh000066400000000000000000000006521476343602200226510ustar00rootroot00000000000000#!/bin/bash pip install hatch && hatch env create || exit 1 # VSCode doesn't support Hatch. # The path to the Python interpretter must be statically known, but Hatch generates a path. # You can't use a wrapper script because VSCode will actually look for other files relative to the # wrapper instead of asking the wrapped Python what configuration to use. mkdir ~/.env && ln -s "$(hatch env find)" ~/.env/default || exit 1 pylutron-caseta-0.24.0/.gitattributes000066400000000000000000000001301476343602200176230ustar00rootroot00000000000000# Always use LF line endings. # This avoids problems when using WSL. * text=auto eol=lf pylutron-caseta-0.24.0/.github/000077500000000000000000000000001476343602200162765ustar00rootroot00000000000000pylutron-caseta-0.24.0/.github/workflows/000077500000000000000000000000001476343602200203335ustar00rootroot00000000000000pylutron-caseta-0.24.0/.github/workflows/ci.yml000066400000000000000000000017311476343602200214530ustar00rootroot00000000000000name: Continuous Integration on: push: branches: [dev] pull_request: branches: [dev] jobs: build: runs-on: ubuntu-latest strategy: fail-fast: false matrix: python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] steps: - uses: actions/checkout@v2 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v2 with: python-version: ${{ matrix.python-version }} - name: Install dependencies run: | python -m pip install --upgrade pip pip install coveralls hatch - name: Lint if: matrix.python-version == '3.13' run: | hatch run lint.py${{ matrix.python-version }}:run - name: Test run: | hatch run test.py${{ matrix.python-version }}:run - name: Collect code coverage env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | coveralls --service=github pylutron-caseta-0.24.0/.gitignore000066400000000000000000000002561476343602200167310ustar00rootroot00000000000000.idea .iml __pycache__/ *.iml .idea* files.txt .tox .coverage MANIFEST run.py *.pem build/ /caseta-bridge.crt /caseta.crt /caseta.key /dist/ /pylutron_caseta.egg-info/ /env/ pylutron-caseta-0.24.0/CHANGELOG.md000066400000000000000000000264751476343602200165650ustar00rootroot00000000000000# Changelog All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## Unreleased ## [0.24.0] - 2025-03-09 ### Added - Optional callback for connection establishment. - Support for SerenaEssentialsRollerShade. ## [0.23.0] - 2025-01-05 ### Added - Support for Lumaris RGB + Tunable White Tape Light. - Support for wood tilt blinds (Sivoia?). ## [0.22.0] - 2024-10-04 ### Added - Support for Triathalon Essentials roller shades. ## [0.21.1] - 2024-08-11 ### Fixed - Connecting to the bridge no longer does blocking file I/O in the async event loop. ## [0.21.0] - 2024-07-04 ### Added - Support for PowPak0-10V dimmers. ## [0.20.0] - 2024-02-22 ### Added - Support for over 99 areas. ### Fixed - An error would occur if a button group contained no buttons. ## [0.19.0] - 2024-01-27 ### Added - Support for Lumaris Tape Light and Ketra color. ## [0.18.3] - 2023-10-07 ### Fixed - Reconnecting could sometimes fail. ## [0.18.2] - 2023-09-03 ### Added - Support for Palladiom Wire-Free shades. ### Fixed - Restored support for Wall Mounted Motion Sensor on RA3 23.4 firmware. ## [0.18.1] - 2023-02-03 ### Fixed - Increased maximum message size for compatibility with 22.08.16f000 firmware. ## [0.18.0] - 2022-01-17 ### Added - Support for Sunnata hybrid keypads. ## [0.17.1] - 2022-10-23 ### Fixed - Now discovers the complete area list with working parent_area relationship. ## [0.17.0] - 2022-10-18 ### Added - Support for RadioRA 2 PhantomKeypad. ### Fixed - leap-scan now detects devices besides Caseta. ## [0.16.0] - 2022-09-28 ### Changed - The `name` field on QSX and RadioRA 3 devices now more closely matches the format of the name field on Caseta devices. ### Added - Devices now have an `area` field containing the area ID and a `device_name` field containing the name of the device without any prefixes or suffixes added by this library. These fields should be used instead of trying to parse the same values out of the `name` field. ## [0.15.2] - 2022-09-19 ### Added - Support for new Claro and Diva devices on Caseta. ## [0.15.1] - 2022-09-10 ### Changed - To match the previous behavior with Caseta, the `name` field on the button devices created for QSX and RadioRA 3 no longer contains the button name. The button name is still available in the `button_name` field. ## [0.15.0] - 2022-09-10 ### Added - Support for HomeWorks QSX and RadioRA 3. - Support for RadioRA 2 InLineDimmer and InLineSwitch. ## [0.14.0] - 2022-06-18 ### Added - Support for Serena tilt-only wood blinds. - New command line tools: lap-pair, leap-scan, leap. - Occupancy sensors are linked using `device['occupancy_sensors']` and `group['sensors']`. ### Removed - get_lutron_cert.py. Use lap-pair instead. See README.md for details. ### Fixed - `async_pair` works on Windows. ## [0.13.1] - 2022-02-01 ### Fixed - No longer fails to initialize when no buttons are associated with the bridge. ## [0.13.0] - 2021-12-05 ### Added - Support for remotes that have multiple button groups (eg Serena RF 4-group Remote). ### Changed - The `buttongroup` member of of a device has been replaced with `button_groups`. ## [0.12.1] - 2021-12-05 ### Fixed - No longer fails to initialize when a remote with multiple button groups (eg Serena RF 4-group Remote) is detected. Only the buttons in the first group are available until 0.13.0. ## [0.12.0] - 2021-12-04 ### Added - Pico Remote button status and event handlers. ## [0.11.0] - 2021-06-01 ### Added - Support for 15-AMP Plug-in Appliance Module (RR-15APS-1-XX). ## [0.10.0] - 2021-05-22 ### Added - Support for PD-15OUT outdoor switch. - Support for RA2 Select fan controller. ## [0.9.0] - 2021-01-23 ### Added - `bridge.lip_devices` can be used to obtain information about paired Pico remotes (PRO and RASelect2 hubs only). ## [0.8.0] - 2021-01-17 ### Added - `pylutron_caseta.pairing.async_pair` can be used to generate the authentication certificates, similar to using the `get_lutron_cert.py` file. This enables software like Home Assistant to perform pairing from inside Home Assistant. ## [0.7.2] - 2020-11-10 ### Changed - Instances in the `areas` and `occupancy_groups` dictionaries are no longer replaced during reconnection, which can cause surprise issues after a network interruption or bridge restart in consuming software such as Home Assistant. This is consistent with the `devices` dictionary. ## [0.7.1] - 2020-11-01 ### Fixed - If the bridge does not return information about occupancy groups, pylutron_caseta will still initialize. - Occupancy groups are now subscribed correctly. ## [0.7.0] - 2020-10-03 ### Added - `set_value` (and shortcuts `turn_on` `turn_off`) now take an optional `fade_time` parameter taking a `timedelta` that controls the transition time for lights. - Shades can be told to raise/lower/stop. ### Changed - Methods that involve making requests to the Caséta bridge are now asyncio coroutines and must be awaited. Previously, the requests were async, but there was no way to observe the request once it was started. ## Removed - Support for Python 3.5 and 3.6. ## [0.6.1] - 2020-04-08 ### Fixed - `OSError` is now handled the same as `ConnectionError` because Python sometimes raises `OSError` when it fails to make a connection. - Users are no longer required to have occupancy sensors. ## [0.6.0] - 2020-03-22 ### Added - Support for occupancy sensors. ### Changed - `get_lutron_cert.py` now uses LAP pairing instead of requiring Lutron cloud services. ### Fixed - Associating a scene remote with Caséta no longer prevents pylutron_caseta from starting. ### Removed - Support for Python 3.4. ## [0.5.1] - 2019-11-19 ### Added - Support for fans. ### Fixed - TLS SNI is never sent to the Caséta bridge when connecting. The bridge responds with different certificates when SNI is sent, which was causing some users problems with the pairing process. - Reconnecting after network errors should be more robust. - pylutron_caseta no longer defaults its own log level to debug. ## [0.5.0] - 2018-04-17 ### Added - An updated version of `get_lutron_cert.py` is now included in the repository. This script is used for pairing with the Caséta bridge. ### Fixed - Unexpected messages sent by the Caséta bridge during startup no longer prevent pylutron_caseta from initializing. ## [0.4.0] - 2018-03-26 ### Changed - Device names now include the name of the room containing the device. ## [0.3.0] - 2017-11-01 ### Changed - pylutron_caseta now uses LEAP over TLS for connecting to the Caséta bridge. SSH support is removed from pylutron_caseta, matching the removal of SSH support from the Caséta firmware. ## [0.2.8] - 2017-09-01 ### Added - `get_devices_by_domain` returns all devices of a given domain (a domain contains similar device types). ## [0.2.7] - 2017-07-28 ### Added - Support for scenes. ## [0.2.6] - 2017-04-17 ### Changed - pylutron_caseta no longer uses LIP over telnet for communicating with the Caséta bridge. ### Fixed - Event subscribers are actually notified. ## [0.2.5] - 2017-04-02 ### Added - `get_devices_by_types` returns all devices having one of multiple given types. ## [0.2.4] - 2017-03-27 ### Fixed - paramiko is now automatically installed along with pylutron_caseta. ## [0.2.3] - 2017-03-20 ### Added - `get_devices_by_type` returns all devices of the given type. ## [0.2.0] - 2017-03-16 ### Fixed - Initial device state is no longer unknown. - Unexpected telnet messages are handled. - Importing pylutron_caseta no longer enables logging to stdout. ## [0.1.6] - 2017-03-15 ### Added - Ability to subscribe to changes in device state. ## [0.1.0] - 2017-03-15 ### Added - Ability to interact with Caséta bridge using LIP over Telnet and LEAP over SSH. [unreleased]: https://github.com/gurumitts/pylutron-caseta/compare/v0.24.0...HEAD [0.24.0]: https://github.com/gurumitts/pylutron-caseta/compare/v0.23.0...v0.24.0 [0.23.0]: https://github.com/gurumitts/pylutron-caseta/compare/v0.22.0...v0.23.0 [0.22.0]: https://github.com/gurumitts/pylutron-caseta/compare/v0.21.1...v0.22.0 [0.21.1]: https://github.com/gurumitts/pylutron-caseta/compare/v0.21.0...v0.21.1 [0.21.0]: https://github.com/gurumitts/pylutron-caseta/compare/v0.20.0...v0.21.0 [0.20.0]: https://github.com/gurumitts/pylutron-caseta/compare/v0.19.0...v0.20.0 [0.19.0]: https://github.com/gurumitts/pylutron-caseta/compare/v0.18.3...v0.19.0 [0.18.3]: https://github.com/gurumitts/pylutron-caseta/compare/v0.18.2...v0.18.3 [0.18.2]: https://github.com/gurumitts/pylutron-caseta/compare/v0.18.1...v0.18.2 [0.18.1]: https://github.com/gurumitts/pylutron-caseta/compare/v0.18.0...v0.18.1 [0.18.0]: https://github.com/gurumitts/pylutron-caseta/compare/v0.17.1...v0.18.0 [0.17.1]: https://github.com/gurumitts/pylutron-caseta/compare/v0.17.0...v0.17.1 [0.17.0]: https://github.com/gurumitts/pylutron-caseta/compare/v0.16.0...v0.17.0 [0.16.0]: https://github.com/gurumitts/pylutron-caseta/compare/v0.15.2...v0.16.0 [0.15.2]: https://github.com/gurumitts/pylutron-caseta/compare/v0.15.1...v0.15.2 [0.15.1]: https://github.com/gurumitts/pylutron-caseta/compare/v0.15.0...v0.15.1 [0.15.0]: https://github.com/gurumitts/pylutron-caseta/compare/v0.14.0...v0.15.0 [0.14.0]: https://github.com/gurumitts/pylutron-caseta/compare/v0.13.1...v0.14.0 [0.13.1]: https://github.com/gurumitts/pylutron-caseta/compare/v0.13.0...v0.13.1 [0.13.0]: https://github.com/gurumitts/pylutron-caseta/compare/v0.12.1...v0.13.0 [0.12.1]: https://github.com/gurumitts/pylutron-caseta/compare/v0.12.0...v0.12.1 [0.12.0]: https://github.com/gurumitts/pylutron-caseta/compare/v0.11.0...v0.12.0 [0.11.0]: https://github.com/gurumitts/pylutron-caseta/compare/v0.10.0...v0.11.0 [0.10.0]: https://github.com/gurumitts/pylutron-caseta/compare/v0.9.0...v0.10.0 [0.9.0]: https://github.com/gurumitts/pylutron-caseta/compare/v0.8.0...v0.9.0 [0.8.0]: https://github.com/gurumitts/pylutron-caseta/compare/v0.7.2...v0.8.0 [0.7.2]: https://github.com/gurumitts/pylutron-caseta/compare/v0.7.1...v0.7.2 [0.7.1]: https://github.com/gurumitts/pylutron-caseta/compare/v0.7.0...v0.7.1 [0.7.0]: https://github.com/gurumitts/pylutron-caseta/compare/v0.6.1...v0.7.0 [0.6.1]: https://github.com/gurumitts/pylutron-caseta/compare/v0.6.0...v0.6.1 [0.6.0]: https://github.com/gurumitts/pylutron-caseta/compare/v0.5.1...v0.6.0 [0.5.1]: https://github.com/gurumitts/pylutron-caseta/compare/v0.5.0...v0.5.1 [0.5.0]: https://github.com/gurumitts/pylutron-caseta/compare/v0.4.0...v0.5.0 [0.4.0]: https://github.com/gurumitts/pylutron-caseta/compare/v0.3.0...v0.4.0 [0.3.0]: https://github.com/gurumitts/pylutron-caseta/compare/v0.2.8...v0.3.0 [0.2.8]: https://github.com/gurumitts/pylutron-caseta/compare/v0.2.7...v0.2.8 [0.2.7]: https://github.com/gurumitts/pylutron-caseta/compare/v0.2.6...v0.2.7 [0.2.6]: https://github.com/gurumitts/pylutron-caseta/compare/v0.2.5...v0.2.6 [0.2.5]: https://github.com/gurumitts/pylutron-caseta/compare/v0.2.4...v0.2.5 [0.2.4]: https://github.com/gurumitts/pylutron-caseta/compare/v0.2.3...v0.2.4 [0.2.3]: https://github.com/gurumitts/pylutron-caseta/compare/v0.2.0...v0.2.3 [0.2.0]: https://github.com/gurumitts/pylutron-caseta/compare/v0.1.6...v0.2.0 [0.1.6]: https://github.com/gurumitts/pylutron-caseta/compare/0.1.0...v0.1.6 [0.1.0]: https://github.com/gurumitts/pylutron-caseta/releases/tag/0.1.0 pylutron-caseta-0.24.0/LICENSE000066400000000000000000000261351476343602200157520ustar00rootroot00000000000000 Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright [yyyy] [name of copyright owner] Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. pylutron-caseta-0.24.0/README.md000066400000000000000000000075041476343602200162230ustar00rootroot00000000000000# pylutron-caseta A Python API to control Lutron Caséta devices. [![Coverage Status](https://coveralls.io/repos/github/gurumitts/pylutron-caseta/badge.svg?branch=dev)](https://coveralls.io/github/gurumitts/pylutron-caseta?branch=dev) ## Getting started If you don't know the IP address of the bridge, the `leap-scan` tool (requires the cli extra, `pip install pylutron_caseta[cli]`) will search for LEAP devices on the local network and display their address and LEAP port number. ### Authentication In order to communicate with the bridge device, you must complete the pairing process. This generates certificate files for authentication. pylutron_caseta can do this two ways. #### lap-pair If pylutron_caseta is installed with the cli extra (`pip install pylutron_caseta[cli]`), the `lap-pair` tool can be used to generate the certificate files. Simply running `lap-pair ` (note the LEAP port number should not be included) will begin the pairing process. The certificate files will be saved in `$XDG_CONFIG_HOME/pylutron_caseta` (normally `~/.config/pylutron_caseta`) in the files `[BRIDGE HOST]-bridge.crt`, `[BRIDGE HOST].crt`, `[BRIDGE HOST].key`. Check `lap-pair --help` if you want to use different files. #### The pairing module If pylutron_caseta is being integrated into a larger application, the pairing functionality can be reused to allow pairing from within that application. ```py async def pair(host: str): def _ready(): print("Press the small black button on the back of the bridge.") data = await async_pair(host, _ready) with open("caseta-bridge.crt", "w") as cacert: cacert.write(data["ca"]) with open("caseta.crt", "w") as cert: cert.write(data["cert"]) with open("caseta.key", "w") as key: key.write(data["key"]) print(f"Successfully paired with {data['version']}") ``` ### Connecting to the bridge Once you have the certificate files, you can connect to the bridge and start controlling devices. ```py import asyncio from pylutron_caseta.smartbridge import Smartbridge async def example(): # `Smartbridge` provides an API for interacting with the Caséta bridge. bridge = Smartbridge.create_tls( "YOUR_BRIDGE_IP", "caseta.key", "caseta.crt", "caseta-bridge.crt" ) await bridge.connect() # Get the first light. # The device is represented by a dict. device = bridge.get_devices_by_domain("light")[0] # Turn on the light. # Methods that act on devices expect to be given the device id. await bridge.turn_on(device["device_id"]) await bridge.close() # Because pylutron_caseta uses asyncio, # it must be run within the context of an asyncio event loop. loop = asyncio.get_event_loop() loop.run_until_complete(example()) ``` ### The leap tool For development and testing of new features, there is a `leap` command in the cli extras (`pip install pylutron_caseta[cli]`) which can be used for communicating directly with the bridge, similar to using `curl`. Getting information about the bridge: ``` $ leap 192.168.86.49/server | jq { "Servers": [ { "href": "/server/1", "Type": "LEAP", "NetworkInterfaces": [ { "href": "/networkinterface/1" } ], "EnableState": "Enabled", "LEAPProperties": { "PairingList": { "href": "/server/leap/pairinglist" } }, "Endpoints": [ { "Protocol": "TCP", "Port": 8081, "AssociatedNetworkInterfaces": null } ] } ] } ``` Turning on the first dimmer: ``` $ ip=192.168.86.49 $ device=$(leap "${ip}/zone/status/expanded?where=Zone.ControlType:\"Dimmed\"" | jq -r '.ZoneExpandedStatuses[0].Zone.href') $ leap -X CreateRequest "${ip}${device}/commandprocessor" -d '{"Command":{"CommandType":"GoToLevel","Parameter":[{"Type":"Level","Value":100}]}}' ``` pylutron-caseta-0.24.0/pyproject.toml000066400000000000000000000034671476343602200176640ustar00rootroot00000000000000[build-system] requires = ["hatchling"] build-backend = "hatchling.build" [project] name = "pylutron-caseta" version = "0.24.0" description = "Provides an API to the Lutron Smartbridge" readme = "README.md" license = "Apache-2.0" requires-python = ">=3.9.0" authors = [ { name = "gurumitts" }, ] maintainers = [ { name = "mdonoughe" }, ] dependencies = [ "async_timeout>=3.0.1;python_version<'3.11'", "cryptography", "orjson", ] classifiers = [ "License :: OSI Approved :: Apache Software License", ] [project.optional-dependencies] cli = [ "click~=8.1.2", "xdg~=5.1.1", "zeroconf~=0.38.4", ] [project.scripts] lap-pair = "pylutron_caseta.cli:lap_pair[cli]" leap = "pylutron_caseta.cli:leap[cli]" leap-scan = "pylutron_caseta.cli:leap_scan[cli]" [project.urls] Homepage = "https://github.com/gurumitts/pylutron-caseta" [tool.hatch.build.targets.sdist] include = [ "/src", "/tests", ] [tool.hatch.envs.default] features = ["cli"] [tool.hatch.envs.lint] extra-dependencies = [ # linters such as mypy and ruff should be pinned, as new releases # make new things fail. Manually update these pins when pulling in a # new version "mypy==1.5.1", "ruff==0.1.14", ] features = ["cli"] template = "test" [tool.hatch.envs.lint.scripts] run = [ "ruff format --check src tests", "ruff check src tests", "mypy src tests", ] [[tool.hatch.envs.lint.matrix]] python = ["3.13"] [tool.hatch.envs.test] dependencies = [ "coveralls~=3.3.1", "pytest-asyncio==0.23.3", "pytest-cov~=4.1.0", "pytest-sugar~=0.9.7", "pytest-timeout~=2.2.0", "pytest~=7.4.4", ] [tool.hatch.envs.test.scripts] run = [ "py.test --timeout=30 --durations=10 --cov=pylutron_caseta --cov-report=", ] [[tool.hatch.envs.test.matrix]] python = ["3.9", "3.10", "3.11", "3.12", "3.13"] pylutron-caseta-0.24.0/src/000077500000000000000000000000001476343602200155255ustar00rootroot00000000000000pylutron-caseta-0.24.0/src/pylutron_caseta/000077500000000000000000000000001476343602200207415ustar00rootroot00000000000000pylutron-caseta-0.24.0/src/pylutron_caseta/__init__.py000066400000000000000000000057261476343602200230640ustar00rootroot00000000000000"""An API to communicate with the Lutron Caseta Smart Bridge.""" from typing import Optional from .messages import Response, ResponseStatus _LEAP_DEVICE_TYPES = { "light": [ "WallDimmer", "PlugInDimmer", "InLineDimmer", "SunnataDimmer", "TempInWallPaddleDimmer", "WallDimmerWithPreset", "Dimmed", "SpectrumTune", # Ketra lamps "DivaSmartDimmer", "WhiteTune", # Lumaris Tunable White Tape Light "PowPak0-10V", "ColorTune", # Lumaris RGB + Tunable White Tape Light ], "switch": [ "WallSwitch", "OutdoorPlugInSwitch", "PlugInSwitch", "InLineSwitch", "PowPakSwitch", "SunnataSwitch", "TempInWallPaddleSwitch", "Switched", "KeypadLED", "DivaSmartSwitch", ], "fan": [ "CasetaFanSpeedController", "MaestroFanSpeedController", "FanSpeed", ], "cover": [ "SerenaHoneycombShade", "SerenaRollerShade", "TriathlonHoneycombShade", "TriathlonEssentialsRollerShade", "TriathlonRollerShade", "QsWirelessShade", "QsWirelessHorizontalSheerBlind", "QsWirelessWoodBlind", "RightDrawDrape", "Shade", "Tilt", "SerenaTiltOnlyWoodBlind", "PalladiomWireFreeShade", "SerenaEssentialsRollerShade", ], "sensor": [ "Pico1Button", "Pico2Button", "Pico2ButtonRaiseLower", "Pico3Button", "Pico3ButtonRaiseLower", "Pico4Button", "Pico4ButtonScene", "Pico4ButtonZone", "Pico4Button2Group", "FourGroupRemote", "SeeTouchTabletopKeypad", "SunnataKeypad", "SunnataHybridKeypad", "SeeTouchHybridKeypad", "SeeTouchInternational", "SeeTouchKeypad", "HomeownerKeypad", "GrafikTHybridKeypad", "AlisseKeypad", "PalladiomKeypad", "PhantomKeypad", ], } FAN_OFF = "Off" FAN_LOW = "Low" FAN_MEDIUM = "Medium" FAN_MEDIUM_HIGH = "MediumHigh" FAN_HIGH = "High" OCCUPANCY_GROUP_OCCUPIED = "Occupied" OCCUPANCY_GROUP_UNOCCUPIED = "Unoccupied" OCCUPANCY_GROUP_UNKNOWN = "Unknown" RA3_OCCUPANCY_SENSOR_DEVICE_TYPES = [ "RPSOccupancySensor", "RPSCeilingMountedOccupancySensor", "RPSWallMountedOccupancySensor", ] BUTTON_STATUS_PRESSED = "Press" BUTTON_STATUS_RELEASED = "Release" class BridgeDisconnectedError(Exception): """Raised when the connection is lost while waiting for a response.""" class BridgeResponseError(Exception): """Raised when the bridge sends an error response.""" def __init__(self, response: Response): """Create a BridgeResponseError.""" super().__init__(str(response.Header.StatusCode)) self.response = response @property def code(self) -> Optional[ResponseStatus]: """Get the status code returned by the server.""" return self.response.Header.StatusCode pylutron-caseta-0.24.0/src/pylutron_caseta/assets.py000066400000000000000000000171311476343602200226200ustar00rootroot00000000000000"""Keys and certificates needed for pairing.""" LAP_CA_PEM = """-----BEGIN CERTIFICATE----- MIIEsjCCA5qgAwIBAgIBATANBgkqhkiG9w0BAQ0FADCBlzELMAkGA1UEBhMCVVMx FTATBgNVBAgTDFBlbm5zeWx2YW5pYTElMCMGA1UEChMcTHV0cm9uIEVsZWN0cm9u aWNzIENvLiwgSW5jLjEUMBIGA1UEBxMLQ29vcGVyc2J1cmcxNDAyBgNVBAMTK0Nh c2V0YSBMb2NhbCBBY2Nlc3MgUHJvdG9jb2wgQ2VydCBBdXRob3JpdHkwHhcNMTUx MDMxMDAwMDAwWhcNMzUxMDMxMDAwMDAwWjCBlzELMAkGA1UEBhMCVVMxFTATBgNV BAgTDFBlbm5zeWx2YW5pYTElMCMGA1UEChMcTHV0cm9uIEVsZWN0cm9uaWNzIENv LiwgSW5jLjEUMBIGA1UEBxMLQ29vcGVyc2J1cmcxNDAyBgNVBAMTK0Nhc2V0YSBM b2NhbCBBY2Nlc3MgUHJvdG9jb2wgQ2VydCBBdXRob3JpdHkwggEiMA0GCSqGSIb3 DQEBAQUAA4IBDwAwggEKAoIBAQDamUREO0dENJxvxdbsDATdDFq+nXdbe62XJ4hI t15nrUolwv7S28M/6uPPFtRSJW9mwvk/OKDlz0G2D3jw6SdzV3I7tNzvDptvbAL2 aDy9YNp9wTub/pLF6ONDa56gfAxsPQnMBwgoZlKqNQQsjykiyBv8FX42h3Nsa+Bl q3hjnZEdOAkdn0rvCWD605c0+VWWOWm2vv7bwyOsfgsvCPxooAyBhTDeA0JPjVE/ wHPfiDF3WqA8JzWv4Ibvkg1g33oD6lG8LulWKDS9TPBYF+cvJ40aFPMreMoAQcrX uD15vaS7iWXKI+anVrBpqE6pRkwLhR+moFjv5GZ+9oP8eawzAgMBAAGjggEFMIIB ATAMBgNVHRMEBTADAQH/MB0GA1UdDgQWBBSB7qznOajKywOtZypVvV7ECAsgZjCB xAYDVR0jBIG8MIG5gBSB7qznOajKywOtZypVvV7ECAsgZqGBnaSBmjCBlzELMAkG A1UEBhMCVVMxFTATBgNVBAgTDFBlbm5zeWx2YW5pYTElMCMGA1UEChMcTHV0cm9u IEVsZWN0cm9uaWNzIENvLiwgSW5jLjEUMBIGA1UEBxMLQ29vcGVyc2J1cmcxNDAy BgNVBAMTK0Nhc2V0YSBMb2NhbCBBY2Nlc3MgUHJvdG9jb2wgQ2VydCBBdXRob3Jp dHmCAQEwCwYDVR0PBAQDAgG+MA0GCSqGSIb3DQEBDQUAA4IBAQB9UDVi2DQI7vHp F2Lape8SCtcdGEY/7BV4a3F+Xp9WxpE4bVtwoHlb+HG4tYQk9LO7jReE3VBmzvmU aj+Y3xa25PSb+/q6U6MuY5OscyWo6ZGwtlsrWcP5xsey950WLwW6i8mfIkqFf6uT gPbUjLsOstB4p7PQVpFgS2rP8h50Psue+XtUKRpR+JSBrHXKX9VuU/aM4PYexSvF WSHa2HEbjvp6ccPm53/9/EtOtzcUMNspKt3YzABAoQ5/69nebRtC5lWjFI0Ga6kv zKyu/aZJXWqskHkMz+Mbnky8tP37NmVkMnmRLCfdCG0gHiq/C2tjWDfPQID6HY0s zq38av5E -----END CERTIFICATE----- """ LAP_CERT_PEM = """-----BEGIN CERTIFICATE----- MIIECjCCAvKgAwIBAgIBAzANBgkqhkiG9w0BAQ0FADCBlzELMAkGA1UEBhMCVVMx FTATBgNVBAgTDFBlbm5zeWx2YW5pYTElMCMGA1UEChMcTHV0cm9uIEVsZWN0cm9u aWNzIENvLiwgSW5jLjEUMBIGA1UEBxMLQ29vcGVyc2J1cmcxNDAyBgNVBAMTK0Nh c2V0YSBMb2NhbCBBY2Nlc3MgUHJvdG9jb2wgQ2VydCBBdXRob3JpdHkwHhcNMTUx MDMxMDAwMDAwWhcNMzUxMDMxMDAwMDAwWjB+MQswCQYDVQQGEwJVUzEVMBMGA1UE CBMMUGVubnN5bHZhbmlhMSUwIwYDVQQKExxMdXRyb24gRWxlY3Ryb25pY3MgQ28u LCBJbmMuMRQwEgYDVQQHEwtDb29wZXJzYnVyZzEbMBkGA1UEAxMSQ2FzZXRhIEFw cGxpY2F0aW9uMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAyAOELqTw WNkF8ofSYJ9QkOHAYMmkVSRjVvZU2AqFfaZYCfWLoors7EBeQrsuGyojqxCbtRUd l2NQrkPrGVw9cp4qsK54H8ntVadNsYi7KAfDW8bHQNf3hzfcpe8ycXcdVPZram6W pM9P7oS36jV2DLU59A/OGkcO5AkC0v5ESqzab3qaV3ZvELP6qSt5K4MaJmm8lZT2 6deHU7Nw3kR8fv41qAFe/B0NV7IT+hN+cn6uJBxG5IdAimr4Kl+vTW9tb+/Hh+f+ pQ8EzzyWyEELRp2C72MsmONarnomei0W7dVYbsgxUNFXLZiXBdtNjPCMv1u6Znhm QMIu9Fhjtz18LwIDAQABo3kwdzAJBgNVHRMEAjAAMB0GA1UdDgQWBBTiN03yqw/B WK/jgf6FNCZ8D+SgwDAfBgNVHSMEGDAWgBSB7qznOajKywOtZypVvV7ECAsgZjAL BgNVHQ8EBAMCBaAwHQYDVR0lBBYwFAYIKwYBBQUHAwEGCCsGAQUFBwMCMA0GCSqG SIb3DQEBDQUAA4IBAQABdgPkGvuSBCwWVGO/uzFEIyRius/BF/EOZ7hMuZluaF05 /FT5PYPWg+UFPORUevB6EHyfezv+XLLpcHkj37sxhXdDKB4rrQPNDY8wzS9DAqF4 WQtGMdY8W9z0gDzajrXRbXkYLDEXnouUWA8+AblROl1Jr2GlUsVujI6NE6Yz5JcJ zDLVYx7pNZkhYcmEnKZ30+ICq6+0GNKMW+irogm1WkyFp4NHiMCQ6D2UMAIMfeI4 xsamcaGquzVMxmb+Py8gmgtjbpnO8ZAHV6x3BG04zcaHRDOqyA4g+Xhhbxp291c8 B31ZKg0R+JaGyy6ZpE5UPLVyUtLlN93V2V8n66kR -----END CERTIFICATE----- """ LAP_KEY_PEM = """-----BEGIN RSA PRIVATE KEY----- MIIEpQIBAAKCAQEAyAOELqTwWNkF8ofSYJ9QkOHAYMmkVSRjVvZU2AqFfaZYCfWL oors7EBeQrsuGyojqxCbtRUdl2NQrkPrGVw9cp4qsK54H8ntVadNsYi7KAfDW8bH QNf3hzfcpe8ycXcdVPZram6WpM9P7oS36jV2DLU59A/OGkcO5AkC0v5ESqzab3qa V3ZvELP6qSt5K4MaJmm8lZT26deHU7Nw3kR8fv41qAFe/B0NV7IT+hN+cn6uJBxG 5IdAimr4Kl+vTW9tb+/Hh+f+pQ8EzzyWyEELRp2C72MsmONarnomei0W7dVYbsgx UNFXLZiXBdtNjPCMv1u6ZnhmQMIu9Fhjtz18LwIDAQABAoIBAQCXDtDNyZQcBgwP 17RzdN8MDPOWJbQO+aRtES2S3J9k/jSPkPscj3/QDe0iyOtRaMn3cFuor4HhzAgr FPCB/sAJyJrFRX9DwuWUQv7SjkmLOhG5Rq9FsdYoMXBbggO+3g8xE8qcX1k2r7vW kDW2lRnLDzPtt+IYxoHgh02yvIYnPn1VLuryM0+7eUrTVmdHQ1IGS5RRAGvtoFjf 4QhkkwLzZzCBly/iUDtNiincwRx7wUG60c4ZYu/uBbdJKT+8NcDLnh6lZyJIpGns jjZvvYA9kgCB2QgQ0sdvm0rA31cbc72Y2lNdtE30DJHCQz/K3X7T0PlfR191NMiX E7h2I/oBAoGBAPor1TqsQK0tT5CftdN6j49gtHcPXVoJQNhPyQldKXADIy8PVGnn upG3y6wrKEb0w8BwaZgLAtqOO/TGPuLLFQ7Ln00nEVsCfWYs13IzXjCCR0daOvcF 3FCb0IT/HHym3ebtk9gvFY8Y9AcV/GMH5WkAufWxAbB7J82M//afSghPAoGBAMys g9D0FYO/BDimcBbUBpGh7ec+XLPaB2cPM6PtXzMDmkqy858sTNBLLEDLl+B9yINi FYcxpR7viNDAWtilVGKwkU3hM514k+xrEr7jJraLzd0j5mjp55dnmH0MH0APjEV0 qum+mIJmWXlkfKKIiIDgr6+FwIiF5ttSbX1NwnYhAoGAMRvjqrXfqF8prEk9xzra 7ZldM7YHbEI+wXfADh+En+FtybInrvZ3UF2VFMIQEQXBW4h1ogwfTkn3iRBVje2x v4rHRbzykjwF48XPsTJWPg2E8oPK6Wz0F7rOjx0JOYsEKm3exORRRhru5Gkzdzk4 lok29/z8SOmUIayZHo+cV88CgYEAgPsmhoOLG19A9cJNWNV83kHBfryaBu0bRSMb U+6+05MtpG1pgaGVNp5o4NxsdZhOyB0DnBL5D6m7+nF9zpFBwH+s0ftdX5sg/Rfs 1Eapmtg3f2ikRvFAdPVf7024U9J4fzyqiGsICQUe1ZUxxetsumrdzCrpzh80AHrN bO2X4oECgYEAxoVXNMdFH5vaTo3X/mOaCi0/j7tOgThvGh0bWcRVIm/6ho1HXk+o +kY8ld0vCa7VvqT+iwPt+7x96qesVPyWQN3+uLz9oL3hMOaXCpo+5w8U2Qxjinod uHnNjMTXCVxNy4tkARwLRwI+1aV5PMzFSi+HyuWmBaWOe19uz3SFbYs= -----END RSA PRIVATE KEY----- """ LUTRON_ROOT_CA_PEM = """ -----BEGIN CERTIFICATE----- MIIH5DCCBMygAwIBAgIJAKk++JqaJetSMA0GCSqGSIb3DQEBCwUAMH8xCzAJBgNV BAYTAlVTMQswCQYDVQQIDAJQQTELMAkGA1UEBwwCQ0IxHzAdBgNVBAoMFkx1dHJv biBFbGVjdHJvbmljcyBJbmMxHzAdBgNVBAsMFkx1dHJvbiBFbGVjdHJvbmljcyBJ bmMxFDASBgNVBAMMC2x1dHJvbi1yb290MB4XDTE2MDkyODE5NTk0MVoXDTM2MDky MzE5NTk0MVowfzELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAlBBMQswCQYDVQQHDAJD QjEfMB0GA1UECgwWTHV0cm9uIEVsZWN0cm9uaWNzIEluYzEfMB0GA1UECwwWTHV0 cm9uIEVsZWN0cm9uaWNzIEluYzEUMBIGA1UEAwwLbHV0cm9uLXJvb3QwggMiMA0G CSqGSIb3DQEBAQUAA4IDDwAwggMKAoIDAQDBZbMODMzm+qpsOF5hhQ272GUlOaKz n5b5YxokSAoxY4TqQApb9/uRHIBuuGLntq0QhR0Y3b0lXBeJWzWC6zscZJrheUKW +2aHVvU4ugPAAXK/WVI68adBSY1UP0BcO1paYrXONcuXQgdy2/GV1mo1b+bmjNFT zeDopkUoBxivBDZZ7B5vFfbJSgSF47Xsz8cspCEUIaV1rZbaDYBzsimdvrKusJfZ Pci+Cx71sZuKunGTCgwHduYFsBfYRgTG1ihNEASi2++Er67AcabUGaqVQr/kIrUD sS9jB6uaqPgMajjwXiZPDm82tTHobbKSav7aq+kSBNIFyvhK5y+vAWoGeZr5WK7n 9EekO3x7LXc6XSCASuhzK6zquAGUBSQNEO3c7sZ1rIdNs1lBSkCSxs+Bl8eEHO8k O20TqKzKF9bQtccNkFWtRKIhVLFxQt234P+XJtWvWKVOlkLCAo0QgDivFJQVnNKM Hr2/CIsOLC+ZSWAYl0lZEJaszt7wjR9cc7DRizq9aoKcGlPRvxzobFoQ4H0Z8vIR DQRUQWFaTTOGiEk7JKxqjXX8xuGZpoXWw8VX0gz3Y0Bz8sU58ZZbugmVjvnKKYzd ueZ/9+FsaYX6CKdJDANEJf+fqfkGXwQGt8Ns7SeG0JyCdJ4K2ECoOURYS4P1vSY1 40L9OldDjsW2qhpSBPHppfJ4rPRUu5J9Ux3AX4Mz+ibl6MS3wRpP1Rg+9TLITK5o 3AYrJO6oMsYrQkQvc+k20ocD7Iq0522iyw62/DpKMsPZXHNTT/rqzIihkaZaR8aa ZOgAKi5o398mcfsuv42f8DriYc0Gr+3btiDU7rINqM935YNIABBDtVT9Ybc5uPHa wXLmAIx2yLjqYaRDhr01Sql6WGy8Y0HcI5lM1pw4Vpx+VKWG/QdORGtZgKySGZ0+ 9bY9cRN9IBFz4J60xoqx0MsM5o6FqVDypDCB32KaobVZSAnHifwEGtJJimNIzHpY jGCTzBHSpuZcvV2dVAuPTHzck37ifpNTUFUCAwEAAaNjMGEwHQYDVR0OBBYEFErb 2SmGkh+4kYe2twSie5+xaqRsMB8GA1UdIwQYMBaAFErb2SmGkh+4kYe2twSie5+x aqRsMA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgGGMA0GCSqGSIb3DQEB CwUAA4IDAQAP9t0STrn0xENQEDNwRMQUBYTA/r7BmzXX6b0Ip6HW7QNmTkFc5pUb KT86v6g8cJ7ls96JwdTu1kRQt4Qpbyvzp2p5YlRFnm3NTVVdcffcZNo95x9Z/1Cv 5xZgw0OKODwPBJLCyq5ET4W6WrIZucRVBs035YXIN+z3EzCxBj6O1lcjOTTHSFFR jI3t3AGdkFo7tCBu5TFlNEFfmaqjse140vfGWJpRKyOT4ahzXLcVxzfg/SKRID3Q 2Rop4KqLNCddzz+UM+IiwyFkOfjrXWStW46cLUzM1k5GRrl0aBg5oqMCBY4/Eeh3 W0ZATsxxfg2Ly4FIO5d7/xiZqARFuurYe/2PSzVSPIKQrPVjDEekD+qQ0bRRQGvL KBiMhYZHwnz/OEQl21PNp5rksuFjKG4/PimQ8V96jpbzzsOZuic3aScszgNUPbdI 0LYjCQ8xCOFPpFC4x1+rGubRjKEuGCvvYErVkX9rQlFRGPOp+k8bYTlIUKZeNsuL KiZ4VH5+ZUAIf94DHayoo/SvBsQ5Qizb17KVRKil+vidUkMtndrNtjr3GWmH+nkn PhRXBlekUy3dgRvTE8RFDOG9TYAN1Bs/uMgNc8Sg5Yz0SG96SLXVer2zsjmQ7tf9 6s+UVvrr+wlL7jSJCfJo6gaUQh1sD3umPXDS+Fq+J7tiRwOvP3cejo8dLyhesDun FGIHlKmUCIwS/3Kzvd9OtAJMsmV9Q2B1dXudJloj6ADaAmVvhI/eMUncL9sXMJZH 3CCorh2OSZt0vtdA59osgDSMrsQZSMrtovrKgeFmP1Z0ENvo90Zenm7Bjn6Hw3Y/ GebIgSgoc149ElxjN4nagIqSJJHRrYq85sjTUSESvQUL1oi4R/VU+qMIRSHju/ZM bkqONDohUc7/pg5rnLTZnnaQ09KvdF0yySx3hYph7L7MZWV/tF7O7yj1egRKh7lT rgZOI7EiN4DPfTTpXoWVmIpiB/ouKp6uZ/Zrq00WthT8lUaBsFYaC3FDkkcxwdkk lJ+cvdbUdsU= -----END CERTIFICATE----- """ pylutron-caseta-0.24.0/src/pylutron_caseta/cli.py000066400000000000000000000221251476343602200220640ustar00rootroot00000000000000"""Command line interface.""" import asyncio import functools import logging import socket import ssl import urllib.parse from contextlib import asynccontextmanager from pathlib import Path from typing import Any, AsyncIterator, List, Optional, TextIO, BinaryIO from urllib.parse import urlparse import orjson import click import xdg from zeroconf import DNSQuestionType, InterfaceChoice, ServiceListener from zeroconf.asyncio import AsyncServiceBrowser, AsyncServiceInfo, AsyncZeroconf import pylutron_caseta.leap from pylutron_caseta.pairing import async_pair def _cli_main(main): """Wrap a method to run in asyncio.""" def wrapper(*args, **kwargs): logging.basicConfig() logging.getLogger("pylutron_caseta").setLevel(logging.WARN) return asyncio.run(main(*args, **kwargs)) return functools.update_wrapper(wrapper, main) class _CertOption(click.Option): """An option that accesses a certificate file for the given address.""" def __init__(self, *args, **kwargs): self.suffix = kwargs.pop("suffix") self.host = kwargs.pop("host") super().__init__(*args, **kwargs) def get_default(self, ctx: click.Context, call: bool = True) -> Optional[Path]: if not call: return None config_home = xdg.xdg_config_home() base = config_home / "pylutron_caseta" config_home.mkdir(exist_ok=True) base.mkdir(mode=0o700, exist_ok=True) return base / (self.host(ctx) + self.suffix) class _AddressCertOption(_CertOption): """An option that access a certificate file for a plain hostname.""" def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs, host=lambda ctx: ctx.params["address"]) class _ResourceCertOption(_CertOption): """An option that access a certificate file for a URL.""" def __init__(self, *args, **kwargs): super().__init__( *args, **kwargs, host=lambda ctx: ctx.params["resource"].hostname ) class _UrlParamType(click.ParamType): """A parameter that is urlparsed.""" name = "url" def convert( self, value: str, param: Optional[click.Parameter], ctx: Optional[click.Context] ) -> urllib.parse.ParseResult: if "://" not in value: value = f"leap://{value}" return urlparse(value, "leap", False) URL = _UrlParamType() @click.command() @_cli_main @click.argument("address") @click.option( "--cacert", type=click.File("w", encoding="ascii", atomic=True), cls=_AddressCertOption, suffix="-bridge.crt", help="The path to the CA certificate.", ) @click.option( "--cert", type=click.File("w", encoding="ascii", atomic=True), cls=_AddressCertOption, suffix=".crt", help="The path to the client certificate.", ) @click.option( "--key", type=click.File("w", encoding="ascii", atomic=True), cls=_AddressCertOption, suffix=".key", help="The path to the client certificate key.", ) async def lap_pair(address: str, cacert: TextIO, cert: TextIO, key: TextIO): """ Perform LAP pairing. This program connects to a Lutron bridge device and initiates the LAP pairing process. The user will be prompted to press a physical button on the bridge, and certificate files will be generated on the local computer. By default, the certificate files will be placed in $XDG_CONFIG_HOME/pylutron_caseta, named after the address of the bridge device. The leap tool will look for certificates in the same location. """ def _ready(): click.echo( "Press the small black button on the back of the bridge to complete " "pairing." ) data = await async_pair(address, _ready) cacert.write(data["ca"]) cert.write(data["cert"]) key.write(data["key"]) click.echo(f"Successfully paired with {data['version']}") @click.command() @_cli_main @click.option( "-i", "--interface", multiple=True, help=( "Limit scanned network interfaces. " "This option may be specified multiple times." ), ) @click.option( "-t", "--timeout", type=float, default=5.0, show_default=True, help="The amount of time (in seconds) to wait for replies.", ) async def leap_scan(interface: List[str], timeout: float): """ Scan for LEAP devices on the local network. This program uses MDNS to locate LEAP devices on networks connected to the local computer. """ async def _async_add_service(zeroconf, type_, name): info = AsyncServiceInfo(type_, name) await info.async_request(zeroconf, 3000, question_type=DNSQuestionType.QU) addresses = [info.server] addresses.extend(info.parsed_scoped_addresses()) click.echo(" ".join(addresses)) class _Listener(ServiceListener): def remove_service(self, *args): pass def add_service(self, zc, type_, name): asyncio.ensure_future(_async_add_service(zc, type_, name)) def update_service(self, *args): pass interfaces: Any = InterfaceChoice.All if len(interface) > 0: interfaces = interface async with AsyncZeroconf(interfaces) as azc: await azc.zeroconf.async_wait_for_start() browser = AsyncServiceBrowser(azc.zeroconf, "_lutron._tcp.local.", _Listener()) await asyncio.sleep(timeout) await browser.async_cancel() @asynccontextmanager async def _connect( resource: urllib.parse.ParseResult, cacert: str, cert: str, key: str ) -> AsyncIterator[pylutron_caseta.leap.LeapProtocol]: ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLSv1_2) ssl_context.load_verify_locations(cacert) ssl_context.load_cert_chain(cert, key) if resource.hostname is None: raise ValueError("Hostname must be specified") protocol = await pylutron_caseta.leap.open_connection( resource.hostname, resource.port or 8081, server_hostname="", ssl=ssl_context, family=socket.AF_INET, ) run = asyncio.ensure_future(protocol.run()) try: yield protocol finally: run.cancel() try: await run except asyncio.CancelledError: pass protocol.close() try: await protocol.wait_closed() except ssl.SSLError: pass @click.command() @_cli_main @click.pass_context @click.option( "-X", "--request", default="ReadRequest", help="The CommuniqueType to send.", show_default=True, ) @click.option( "--cacert", type=click.Path(True, dir_okay=False), cls=_ResourceCertOption, suffix="-bridge.crt", help="The path to the CA certificate.", ) @click.option( "-E", "--cert", type=click.Path(True, dir_okay=False), cls=_ResourceCertOption, suffix=".crt", help="The path to the client certificate.", ) @click.option( "--key", type=click.Path(True, dir_okay=False), cls=_ResourceCertOption, suffix=".key", help="The path to the client certificate key.", ) @click.option("-d", "--data", help="The JSON data to send with the request.") @click.option("-p", "--paging", help="The JSON Paging dict to send with the request.") @click.option( "-f", "--fail", is_flag=True, help="Exit when the status code does not indicate success.", ) @click.option( "-o", "--output", type=click.File("wb"), default="-", help="Save the response into a file.", ) @click.option( "-v", "--verbose", is_flag=True, help="Output the response headers as well." ) @click.argument("resource", type=URL) async def leap( ctx: click.Context, request: str, resource: urllib.parse.ParseResult, cacert: str, cert: str, key: str, data: Optional[str], paging: Optional[str], fail: bool, output: BinaryIO, verbose: bool, ): """ Make a single LEAP request. LEAP is similar to JSON over HTTP, and this tool is similar to Curl. """ async with _connect(resource, cacert, cert, key) as connection: body = orjson.loads(data) if data is not None else None paging_json = orjson.loads(paging) if paging is not None else None res = resource.path if resource.query is not None and len(resource.query) > 0: res += f"?{resource.query}" response = await connection.request(request, res, body, paging=paging_json) if ( fail and response.Header.StatusCode is not None and not response.Header.StatusCode.is_successful() ): ctx.exit(1) if verbose: # LeapProtocol discards the original JSON so reconstruct it here. message: dict = { "Header": { "StatusCode": str(response.Header.StatusCode), "Url": response.Header.Url, "MessageBodyType": response.Header.MessageBodyType, }, "CommuniqueType": response.CommuniqueType, "Body": response.Body, } if response.Header.Paging: message["Header"]["Paging"] = response.Header.Paging output.write(orjson.dumps(message)) else: output.write(orjson.dumps(response.Body)) output.write(b"\n") pylutron-caseta-0.24.0/src/pylutron_caseta/color_value.py000066400000000000000000000111571476343602200236320ustar00rootroot00000000000000"""Types for specifying colors.""" from abc import ABC, abstractmethod from typing import Optional class ColorMode(ABC): """A color for spectrum tune or white tune lights.""" @abstractmethod def get_spectrum_tuning_level_parameters(self) -> dict: """ Get the relevant parameter dictionary for the spectrum tuning level. :return: spectrum tuning level parameter dictionary """ @abstractmethod def get_white_tuning_level_parameters(self) -> dict: """ Get the relevant parameter dictionary for the white tuning level. :return: white tuning level parameter dictionary """ @staticmethod def get_color_from_leap(zone_status: dict) -> Optional["ColorMode"]: """ Get the color value from the zone status. Returns None if no color is set. :param zone_status: leap zone status dictionary :return: color value """ if zone_status is None: return None color_status = zone_status.get("ColorTuningStatus") if color_status is None: return None if "WhiteTuningLevel" in color_status: kelvin = color_status["WhiteTuningLevel"]["Kelvin"] return WarmCoolColorValue(kelvin) if "HSVTuningLevel" in color_status: hue = color_status["HSVTuningLevel"]["Hue"] saturation = color_status["HSVTuningLevel"]["Saturation"] return FullColorValue(hue, saturation) return None class FullColorValue(ColorMode): """A color specified as hue+saturation.""" def __init__(self, hue: int, saturation: int): """ Create a Full Color value. :param hue: Hue of the bulb :param saturation: Saturation of the bulb """ self.hue = hue self.saturation = saturation def get_spectrum_tuning_level_parameters(self) -> dict: return {"ColorTuningStatus": self.get_white_tuning_level_parameters()} def get_white_tuning_level_parameters(self) -> dict: return {"HSVTuningLevel": {"Hue": self.hue, "Saturation": self.saturation}} class WarmCoolColorValue(ColorMode): """A color temperature.""" def __init__(self, kelvin: int): """ Create a Warm Cool color value. :param kelvin: kelvin value of the bulb """ self.kelvin = kelvin def get_spectrum_tuning_level_parameters(self) -> dict: return {"ColorTuningStatus": self.get_white_tuning_level_parameters()} def get_white_tuning_level_parameters(self) -> dict: return {"WhiteTuningLevel": {"Kelvin": self.kelvin}} class WarmDimmingColorValue: """ A Warm Dimming value. :param enabled: enable warm dimming """ def __init__(self, enabled: bool, additional_params: Optional[dict] = None): """Create a Warm Dimming value.""" self.enabled = enabled self.additional_params = additional_params or {} @staticmethod def get_warm_dim_from_leap(zone_status: dict) -> Optional["bool"]: """ Check whether warm dimming is active for a zone status. Returns None if the zone status does not contain warm dimming information. """ if zone_status is None: return None color_status = zone_status.get("ColorTuningStatus") if color_status is None: return None curve_dimming = color_status.get("CurveDimming") if curve_dimming is None: return None return "Curve" in curve_dimming def get_leap_parameters(self) -> dict: """Get the leap parameters for applying warm dimming.""" if self.enabled: curve_dimming = {"Curve": {"href": "/curve/1"}} else: curve_dimming = None return {"CurveDimming": curve_dimming} def get_spectrum_tuning_level_parameters(self) -> dict: """ Get the relevant parameter dictionary for the spectrum tuning level. :return: spectrum tuning level parameter dictionary """ params = {"ColorTuningStatus": self.get_leap_parameters()} params.update(self.additional_params) return { "CommandType": "GoToSpectrumTuningLevel", "SpectrumTuningLevelParameters": params, } def get_white_tuning_level_parameters(self) -> dict: """ Get the relevant parameter dictionary for the white tuning level. :return: white tuning level parameter dictionary """ params = self.get_leap_parameters() params.update(self.additional_params) return {"CommandType": "GoToWarmDim", "WarmDimParameters": params} pylutron-caseta-0.24.0/src/pylutron_caseta/leap.py000066400000000000000000000151651476343602200222440ustar00rootroot00000000000000"""LEAP protocol layer.""" import asyncio import logging import re import uuid from typing import Callable, Dict, List, Optional, Tuple import orjson from . import BridgeDisconnectedError from .messages import Response _LOG = logging.getLogger(__name__) _DEFAULT_LIMIT = 2**18 def _make_tag() -> str: return str(uuid.uuid4()) class LeapProtocol: """A wrapper for making LEAP calls.""" def __init__( self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter, ): """Wrap a reader and writer with a LEAP request and response protocol.""" self._reader = reader self._writer = writer self._in_flight_requests: Dict[str, "asyncio.Future[Response]"] = {} self._tagged_subscriptions: Dict[str, Callable[[Response], None]] = {} self._unsolicited_subs: List[Callable[[Response], None]] = [] async def request( self, communique_type: str, url: str, body: Optional[dict] = None, tag: Optional[str] = None, paging: Optional[dict] = None, ) -> Response: """Make a request to the bridge and return the response.""" if tag is None: tag = _make_tag() future: asyncio.Future = asyncio.get_running_loop().create_future() cmd: dict = { "CommuniqueType": communique_type, "Header": {"ClientTag": tag, "Url": url}, } if paging is not None: cmd["Header"]["Paging"] = paging if body is not None: cmd["Body"] = body self._in_flight_requests[tag] = future # remove cancelled tasks def clean_up(future): if future.cancelled(): self._in_flight_requests.pop(tag, None) future.add_done_callback(clean_up) try: text = orjson.dumps(cmd) _LOG.debug("sending %s", text) self._writer.writelines((text, b"\r\n")) return await future finally: self._in_flight_requests.pop(tag, None) async def run(self): """Event monitoring loop.""" while not self._reader.at_eof(): received = await self._reader.readline() if received == b"": break resp_json = orjson.loads(received) if isinstance(resp_json, dict): tag = resp_json.get("Header", {}).pop("ClientTag", None) if tag is not None: in_flight = self._in_flight_requests.pop(tag, None) if in_flight is not None and not in_flight.done(): _LOG.debug("received: %s", resp_json) in_flight.set_result(Response.from_json(resp_json)) else: subscription = self._tagged_subscriptions.get(tag, None) if subscription is not None: _LOG.debug( "received for subscription %s: %s", tag, resp_json ) subscription(Response.from_json(resp_json)) else: _LOG.error( "Was not expecting message with tag %s: %s", tag, resp_json, ) else: _LOG.debug("Received message with no tag: %s", resp_json) obj = Response.from_json(resp_json) for handler in self._unsolicited_subs: try: handler(obj) except Exception: # pylint: disable=broad-except _LOG.exception( "Got exception from unsolicited message handler" ) async def subscribe( self, url: str, callback: Callable[[Response], None], body: Optional[dict] = None, communique_type: str = "SubscribeRequest", tag: Optional[str] = None, ) -> Tuple[Response, str]: """ Subscribe to events from the bridge. This is similar to a normal request, except that the bridge is expected to send additional responses with the same tag value at a later time. These additional responses will be handled by the provided callback. This returns both the response message and a string that will be required for unsubscribing (not implemented). """ if not callable(callback): raise TypeError("callback must be callable") if tag is None: tag = _make_tag() response = await self.request(communique_type, url, body, tag=tag) status = response.Header.StatusCode if status is not None and status.is_successful(): self._tagged_subscriptions[tag] = callback _LOG.debug("Subscribed to %s as %s", url, tag) return (response, tag) def subscribe_unsolicited(self, callback: Callable[[Response], None]): """ Subscribe to notifications of unsolicited events. The provided callback will be executed when the bridge sends an untagged response message. """ if not callable(callback): raise TypeError("callback must be callable") self._unsolicited_subs.append(callback) def unsubscribe_unsolicited(self, callback: Callable[[Response], None]): """Unsubscribe from notifications of unsolicited events.""" self._unsolicited_subs.remove(callback) def close(self): """Disconnect.""" self._writer.close() for request in self._in_flight_requests.values(): if not request.done(): request.set_exception(BridgeDisconnectedError()) self._in_flight_requests.clear() self._tagged_subscriptions.clear() async def wait_closed(self): """Wait for the connection to be completely closed.""" await self._writer.wait_closed() async def open_connection( host: str, port: int, *, limit: int = _DEFAULT_LIMIT, **kwds ) -> LeapProtocol: """Open a stream and wrap it with LEAP.""" reader, writer = await asyncio.open_connection(host, port, limit=limit, **kwds) return LeapProtocol(reader, writer) _HREFRE = re.compile(r"/(?:\D+)/(\d+)(?:\/\D+)?") def id_from_href(href: str) -> str: """Get an id from any kind of href. Raises ValueError if id cannot be determined from the format """ match = _HREFRE.match(href) if match is None: raise ValueError(f"Cannot find ID from href {href!r}") return match.group(1) pylutron-caseta-0.24.0/src/pylutron_caseta/messages.py000066400000000000000000000053431476343602200231270ustar00rootroot00000000000000"""Models for messages exchanged with the bridge.""" from typing import NamedTuple, Optional class ResponseStatus: """A response status split into its code and message parts.""" def __init__(self, code: Optional[int], message: str): """Create a new ResponseStatus.""" self.code = code self.message = message @classmethod def from_str(cls, data: str) -> "ResponseStatus": """Convert a str to a ResponseStatus.""" space = data.find(" ") if space == -1: code = None else: try: code = int(data[:space]) data = data[space + 1 :] except ValueError: code = None return ResponseStatus(code, data) def is_successful(self) -> bool: """Check if the status code is in the range [200, 300).""" return self.code is not None and self.code >= 200 and self.code < 300 def __repr__(self): """Get a string representation of the ResponseStatus.""" return f"ResponseStatus({self.code!r}, {self.message!r})" def __str__(self): """Format the response status as a string in the LEAP header format.""" return f"{self.code} {self.message}" def __eq__(self, other): """Check if this ResponseStatus is equal to another ResponseStatus.""" return ( isinstance(other, ResponseStatus) and self.code == other.code and self.message == other.message ) class ResponseHeader(NamedTuple): """A LEAP response header.""" # pylint: disable=invalid-name StatusCode: Optional[ResponseStatus] = None Url: Optional[str] = None MessageBodyType: Optional[str] = None Paging: Optional[dict] = None @classmethod def from_json(cls, data: dict) -> "ResponseHeader": """Convert a JSON dictionary to a ResponseHeader.""" status = data.get("StatusCode", None) StatusCode = ResponseStatus.from_str(status) if status is not None else None return ResponseHeader( StatusCode=StatusCode, Url=data.get("Url", None), MessageBodyType=data.get("MessageBodyType", None), Paging=data.get("Paging", None), ) class Response(NamedTuple): """A LEAP response.""" # pylint: disable=invalid-name Header: ResponseHeader CommuniqueType: Optional[str] = None Body: Optional[dict] = {} @classmethod def from_json(cls, data: dict) -> "Response": """Convert a JSON dictionary to a Response.""" return Response( Header=ResponseHeader.from_json(data.get("Header", {})), CommuniqueType=data.get("CommuniqueType", None), Body=data.get("Body", None), ) pylutron-caseta-0.24.0/src/pylutron_caseta/pairing.py000066400000000000000000000215421476343602200227500ustar00rootroot00000000000000"""Guide the user through pairing and save the necessary files.""" import asyncio import logging import socket import ssl import tempfile from typing import Callable, Optional, Tuple, TypedDict import os import orjson from cryptography import x509 from cryptography.hazmat.backends import default_backend from cryptography.hazmat.primitives import hashes, serialization from cryptography.hazmat.primitives.asymmetric import rsa from cryptography.x509.oid import NameOID # type: ignore from .assets import LAP_CA_PEM, LAP_CERT_PEM, LAP_KEY_PEM, LUTRON_ROOT_CA_PEM from .utils import asyncio_timeout LOGGER = logging.getLogger(__name__) CERT_COMMON_NAME = "pylutron_caseta" SOCKET_TIMEOUT = 10 BUTTON_PRESS_TIMEOUT = 180 CERT_SUBJECT = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, CERT_COMMON_NAME)]) PAIR_KEY = "key" PAIR_CERT = "cert" PAIR_CA = "ca" PAIR_VERSION = "version" class PairingData(TypedDict): """The certificate information from a successful pairing operation.""" key: str cert: str ca: str version: str class JsonSocket: """A socket that reads and writes json objects.""" def __init__(self, reader, writer): """Create a JsonSocket wrapping the provided socket.""" self._writer = writer self._reader = reader async def async_read_json(self, timeout): """Read an object.""" async with asyncio_timeout(timeout): buffer = await self._reader.readline() if buffer == b"": return None LOGGER.debug("received: %s", buffer) return orjson.loads(buffer) async def async_write_json(self, obj): """Write an object.""" buffer = orjson.dumps(obj) self._writer.writelines((buffer, b"\r\n")) LOGGER.debug("sent: %s", buffer) def __del__(self): """Cleanup when the object is deleted.""" self._writer.close() async def async_pair( server_addr: str, ready: Optional[Callable[[], None]] = None ) -> PairingData: """Pair with a lutron bridge.""" loop = asyncio.get_running_loop() csr, key_bytes_pem, ssl_context = await loop.run_in_executor( None, _generate_csr_with_ssl_context ) try: cert_pem, ca_pem = await _async_generate_certificate( server_addr, ssl_context, csr, ready ) except ssl.SSLCertVerificationError: # SSL certificate verification error - might be an RA3 processor, # try to connect using the lutron-root certificate instead of LAP_CA ssl_context.load_verify_locations(cadata=LUTRON_ROOT_CA_PEM) cert_pem, ca_pem = await _async_generate_certificate( server_addr, ssl_context, csr, ready ) # Generate certificates worked with RA3 lutron-root so bridge is RA3. # Discard the ca_pem for caseta and replace with LUTRON_ROOT_CA_PEM ca_pem = LUTRON_ROOT_CA_PEM signed_ssl_context = await loop.run_in_executor( None, _generate_signed_ssl_context, key_bytes_pem, cert_pem, ca_pem ) leap_response = await _async_verify_certificate(server_addr, signed_ssl_context) version = leap_response["Body"]["PingResponse"]["LEAPVersion"] LOGGER.debug( "Successfully connected to bridge, running LEAP Server version %s", version ) return { "key": key_bytes_pem.decode("ASCII"), "cert": cert_pem, "ca": ca_pem, "version": version, } async def _async_generate_certificate( server_addr: str, ssl_context: ssl.SSLContext, csr: x509.CertificateSigningRequest, ready: Optional[Callable[[], None]], ) -> Tuple[str, str]: async with asyncio_timeout(SOCKET_TIMEOUT): reader, writer = await asyncio.open_connection( server_addr, 8083, server_hostname="", ssl=ssl_context, family=socket.AF_INET, ) json_socket = JsonSocket(reader, writer) LOGGER.info("Press the small black button on the back of the Caseta bridge...") if ready is not None: ready() while True: message = await json_socket.async_read_json(BUTTON_PRESS_TIMEOUT) if message.get("Header", {}).get("ContentType", "").startswith( "status;" ) and "PhysicalAccess" in ( message.get("Body", {}).get("Status", {}).get("Permissions", []) ): break LOGGER.debug("Getting my certificate...") csr_text = csr.public_bytes(serialization.Encoding.PEM).decode("ASCII") await json_socket.async_write_json( { "Header": { "RequestType": "Execute", "Url": "/pair", "ClientTag": "get-cert", }, "Body": { "CommandType": "CSR", "Parameters": { "CSR": csr_text, "DisplayName": CERT_COMMON_NAME, "DeviceUID": "000000000000", "Role": "Admin", }, }, } ) while True: message = await json_socket.async_read_json(SOCKET_TIMEOUT) if message.get("Header", {}).get("ClientTag") == "get-cert": break signing_result = message["Body"]["SigningResult"] LOGGER.debug("Got certificates") return signing_result["Certificate"], signing_result["RootCertificate"] def _generate_private_key(): LOGGER.info("Generating a new private key...") return rsa.generate_private_key( public_exponent=65537, key_size=2048, backend=default_backend() ) def _convert_private_key_to_pem(private_key) -> bytes: return private_key.private_bytes( encoding=serialization.Encoding.PEM, format=serialization.PrivateFormat.PKCS8, encryption_algorithm=serialization.NoEncryption(), ) def _generate_csr(private_key) -> x509.CertificateSigningRequest: return ( x509.CertificateSigningRequestBuilder() .subject_name(CERT_SUBJECT) .sign(private_key, hashes.SHA256(), default_backend()) ) async def _async_verify_certificate(server_addr, signed_ssl_context): async with asyncio_timeout(SOCKET_TIMEOUT): reader, writer = await asyncio.open_connection( server_addr, 8081, server_hostname="", ssl=signed_ssl_context, family=socket.AF_INET, ) json_socket = JsonSocket(reader, writer) await json_socket.async_write_json( { "CommuniqueType": "ReadRequest", "Header": {"Url": "/server/1/status/ping"}, } ) while True: leap_response = await json_socket.async_read_json(SOCKET_TIMEOUT) if leap_response.get("CommuniqueType") == "ReadResponse": return leap_response def _generate_csr_with_ssl_context() -> ( Tuple[x509.CertificateSigningRequest, bytes, ssl.SSLContext] ): with tempfile.NamedTemporaryFile(delete=False) as lap_cert_temp_file: with tempfile.NamedTemporaryFile(delete=False) as lap_key_temp_file: try: private_key = _generate_private_key() key_bytes_pem = _convert_private_key_to_pem(private_key) csr = _generate_csr(private_key) lap_cert_temp_file.write(LAP_CERT_PEM.encode("ASCII")) lap_cert_temp_file.flush() lap_key_temp_file.write(LAP_KEY_PEM.encode("ASCII")) lap_key_temp_file.flush() ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLSv1_2) ssl_context.load_verify_locations(cadata=LAP_CA_PEM) ssl_context.load_cert_chain( lap_cert_temp_file.name, lap_key_temp_file.name ) ssl_context.verify_mode = ssl.CERT_REQUIRED return csr, key_bytes_pem, ssl_context finally: lap_key_temp_file.close() lap_cert_temp_file.close() os.remove(lap_key_temp_file.name) os.remove(lap_cert_temp_file.name) def _generate_signed_ssl_context(key_bytes_pem, cert_pem, ca_pem): with tempfile.NamedTemporaryFile(delete=False) as key_temp_file: key_temp_file.write(key_bytes_pem) key_temp_file.flush() with tempfile.NamedTemporaryFile(delete=False) as cert_temp_file: try: cert_temp_file.write(cert_pem.encode("ASCII")) cert_temp_file.flush() signed_ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLSv1_2) signed_ssl_context.load_verify_locations(cadata=ca_pem) signed_ssl_context.load_cert_chain( cert_temp_file.name, key_temp_file.name ) signed_ssl_context.verify_mode = ssl.CERT_REQUIRED return signed_ssl_context finally: key_temp_file.close() cert_temp_file.close() os.remove(key_temp_file.name) os.remove(cert_temp_file.name) pylutron-caseta-0.24.0/src/pylutron_caseta/smartbridge.py000066400000000000000000001517561476343602200236350ustar00rootroot00000000000000"""Provides an API to interact with the Lutron Caseta Smart Bridge & RA3 Processor.""" import asyncio import logging import math import socket import ssl from datetime import timedelta from typing import Callable, Dict, List, Optional, Tuple, Union, Coroutine, Any from .color_value import ColorMode, WarmDimmingColorValue try: from asyncio import get_running_loop as get_loop except ImportError: # For Python 3.6 and earlier, we have to use get_event_loop instead from asyncio import get_event_loop as get_loop from . import ( _LEAP_DEVICE_TYPES, BUTTON_STATUS_RELEASED, FAN_OFF, OCCUPANCY_GROUP_UNKNOWN, RA3_OCCUPANCY_SENSOR_DEVICE_TYPES, BridgeDisconnectedError, BridgeResponseError, ) from .leap import LeapProtocol, id_from_href, open_connection from .messages import Response from .utils import asyncio_timeout _LOG = logging.getLogger(__name__) LEAP_PORT = 8081 PING_INTERVAL = 60.0 CONNECT_TIMEOUT = 5.0 REQUEST_TIMEOUT = 5.0 RECONNECT_DELAY = 2.0 class Smartbridge: """ A representation of the Lutron Caseta Smart Bridge. It uses an SSL interface known as the LEAP server. """ def __init__( self, connect: Callable[[], Coroutine[Any, Any, LeapProtocol]], on_connect_callback: Optional[Callable[[], None]] = None, ) -> None: """Initialize the Smart Bridge.""" self.devices: Dict[str, dict] = {} self.buttons: Dict[str, dict] = {} self.lip_devices: Dict[int, dict] = {} self.scenes: Dict[str, dict] = {} self.occupancy_groups: Dict[str, dict] = {} self.areas: Dict[str, dict] = {} self._connect = connect self._subscribers: Dict[str, Callable[[], None]] = {} self._occupancy_subscribers: Dict[str, Callable[[], None]] = {} self._button_subscribers: Dict[str, Callable[[str], None]] = {} self._login_task: Optional[asyncio.Task] = None # Use future so we can wait before the login starts and # don't need to wait for "login" on reconnect. self._login_completed: asyncio.Future = ( asyncio.get_running_loop().create_future() ) self._leap: Optional[LeapProtocol] = None self._monitor_task: Optional[asyncio.Task] = None self._ping_task: Optional[asyncio.Task] = None self._on_connect_callback = on_connect_callback @property def logged_in(self): """Check if the bridge is connected and ready.""" return ( # are we connected? self._monitor_task is not None and not self._monitor_task.done() # are we ready? and self._login_completed.done() and not self._login_completed.cancelled() and self._login_completed.exception() is None ) async def connect(self) -> None: """Connect to the bridge.""" # reset any existing connection state if self._login_task is not None: self._login_task.cancel() self._login_task = None if self._monitor_task is not None: self._monitor_task.cancel() self._monitor_task = None if self._ping_task is not None: self._ping_task.cancel() self._ping_task = None if self._leap is not None: self._leap.close() self._leap = None if not self._login_completed.done(): self._login_completed.cancel() self._login_completed = asyncio.get_running_loop().create_future() self._monitor_task = get_loop().create_task(self._monitor()) await self._login_completed @staticmethod def _create_tls_context( keyfile: str, certfile: str, ca_certs: str ) -> ssl.SSLContext: """Create a TLS context for the Smart Bridge. This is called in the executor to avoid blocking the event loop since calling load_cert_chain and load_verify_locations does blocking disk IO. """ ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) ssl_context.minimum_version = ssl.TLSVersion.TLSv1_2 ssl_context.load_verify_locations(ca_certs) ssl_context.load_cert_chain(certfile, keyfile) ssl_context.verify_mode = ssl.CERT_REQUIRED return ssl_context @classmethod def create_tls( cls, hostname: str, keyfile: str, certfile: str, ca_certs: str, port: int = LEAP_PORT, on_connect_callback: Optional[Callable[[], None]] = None, ) -> "Smartbridge": """Initialize the Smart Bridge using TLS over IPv4.""" async def _connect() -> LeapProtocol: ssl_context = await get_loop().run_in_executor( None, cls._create_tls_context, keyfile, certfile, ca_certs ) res = await open_connection( hostname, port, server_hostname="", ssl=ssl_context, family=socket.AF_INET, ) return res return cls(_connect, on_connect_callback) def add_subscriber(self, device_id: str, callback_: Callable[[], None]): """ Add a listener to be notified of state changes. :param device_id: device id, e.g. 5 :param callback_: callback to invoke """ self._subscribers[device_id] = callback_ def add_occupancy_subscriber( self, occupancy_group_id: str, callback_: Callable[[], None] ): """ Add a listener to be notified of occupancy state changes. :param occupancy_group_id: occupancy group id, e.g., 2 :param callback_: callback to invoke """ self._occupancy_subscribers[occupancy_group_id] = callback_ def add_button_subscriber(self, button_id: str, callback_: Callable[[str], None]): """ Add a listener to be notified of button state changes. :param button_id: button id, e.g., 2 :param callback_: callback to invoke """ self._button_subscribers[button_id] = callback_ def get_devices(self) -> Dict[str, dict]: """Will return all known devices connected to the bridge/processor.""" return self.devices def get_buttons(self) -> Dict[str, dict]: """Will return all known buttons connected to the bridge/processor.""" return self.buttons def get_devices_by_domain(self, domain: str) -> List[dict]: """ Return a list of devices for the given domain. :param domain: one of 'light', 'switch', 'cover', 'fan' or 'sensor' :returns list of zero or more of the devices """ types = _LEAP_DEVICE_TYPES.get(domain, None) # return immediately if not a supported domain if types is None: return [] return self.get_devices_by_types(types) def get_devices_by_type(self, type_: str) -> List[dict]: """ Will return all devices of a given device type. :param type_: LEAP device type, e.g. WallSwitch """ return [device for device in self.devices.values() if device["type"] == type_] def get_device_by_zone_id(self, zone_id: str) -> dict: """ Return the first device associated with a given zone. Currently each device is mapped to exactly 1 zone :param zone_id: the zone id to search for :raises KeyError: if the zone id is not present """ for device in self.devices.values(): if zone_id == device.get("zone"): return device raise KeyError(f"No device associated with zone {zone_id}") def get_devices_by_types(self, types: List[str]) -> List[dict]: """ Will return all devices for a list of given device types. :param types: list of LEAP device types such as WallSwitch, WallDimmer """ return [device for device in self.devices.values() if device["type"] in types] def get_device_by_id(self, device_id: str) -> dict: """ Will return a device with the given ID. :param device_id: device id, e.g. 5 """ return self.devices[device_id] def get_scenes(self) -> Dict[str, dict]: """Will return all known scenes from the Smart Bridge.""" return self.scenes def get_scene_by_id(self, scene_id: str) -> dict: """ Will return a scene with the given scene ID. :param scene_id: scene id, e.g 23 """ return self.scenes[scene_id] def is_connected(self) -> bool: """Will return True if currently connected to the Smart Bridge.""" return self.logged_in def is_on(self, device_id: str) -> bool: """ Will return True is the device with the given ID is 'on'. :param device_id: device id, e.g. 5 :returns True if level is greater than 0 level, False otherwise """ return ( self.devices[device_id]["current_state"] > 0 or (self.devices[device_id]["fan_speed"] or FAN_OFF) != FAN_OFF ) async def _request( self, communique_type: str, url: str, body: Optional[dict] = None ) -> Response: if self._leap is None: raise BridgeDisconnectedError() # LEAP APIs support pagination, so repeat requests until fully collected responses = [] paging = None while True: async with asyncio_timeout(REQUEST_TIMEOUT): response = await self._leap.request( communique_type, url, body, paging=paging ) status = response.Header.StatusCode if status is None or not status.is_successful(): raise BridgeResponseError(response) responses.append(response) paging = response.Header.Paging if not paging: break # merge the Body of multiple paged Responses together merged = responses.pop(0) if merged.Body is not None: merged_type = next(iter(merged.Body), None) if merged_type is not None: for response in responses: if response.Body is not None: merged.Body[merged_type].extend(response.Body[merged_type]) return merged async def _subscribe( self, url: str, callback: Callable[[Response], None], communique_type: str = "SubscribeRequest", body: Optional[dict] = None, ) -> Tuple[Response, str]: if self._leap is None: raise BridgeDisconnectedError() async with asyncio_timeout(REQUEST_TIMEOUT): response, tag = await self._leap.subscribe( url, callback, communique_type=communique_type, body=body ) status = response.Header.StatusCode if status is None or not status.is_successful(): raise BridgeResponseError(response) return (response, tag) async def set_warm_dim( self, device_id: str, enabled: bool, value: Optional[int] = None, fade_time: Optional[timedelta] = None, ): """ Will set the warm dimming value for a device with the given ID. :param device_id: device id to set the value on :param enabled: enable warm dimming :param value: integer value from 0 to 100 to set (Optional if just setting color) :param fade_time: duration for the light to fade from its current value to the """ device = self.devices[device_id] zone_id = device.get("zone") if not zone_id: return params: Dict[str, Union[str, int]] = {} if value is not None: params["Level"] = value if fade_time is not None: params["FadeTime"] = _format_duration(fade_time) color_value = WarmDimmingColorValue(enabled, params) command = {} if device.get("type") == "SpectrumTune": command = color_value.get_spectrum_tuning_level_parameters() elif device.get("type") == "ColorTune": command = color_value.get_spectrum_tuning_level_parameters() elif device.get("type") == "WhiteTune": command = color_value.get_white_tuning_level_parameters() await self._request( "CreateRequest", f"/zone/{zone_id}/commandprocessor", {"Command": command}, ) async def set_value( self, device_id: str, value: Optional[int] = None, fade_time: Optional[timedelta] = None, color_value: Optional[ColorMode] = None, ): """ Will set the value for a device with the given ID. :param device_id: device id to set the value on :param value: integer value from 0 to 100 to set (Optional if just setting color) :param fade_time: duration for the light to fade from its current value to the :param color_value: color value to set the device to (only currently valid for Ketra/Lumaris devices) new value (only valid for lights) """ device = self.devices[device_id] # Handle keypad LEDs which don't have a zone ID associated if device.get("type") == "KeypadLED": target_state = "On" if value is not None and value > 0 else "Off" await self._request( "UpdateRequest", f"/led/{device_id}/status", {"LEDStatus": {"State": target_state}}, ) return # All other device types must have an associated zone ID zone_id = device.get("zone") if not zone_id: return # Handle Ketra lamps and Lumaris RGB + Tunable White Tape Light if device.get("type") in ["SpectrumTune", "ColorTune"]: spectrum_params: Dict[str, Union[str, int]] = {} if value is not None: spectrum_params["Level"] = value if color_value is not None: spectrum_params.update( color_value.get_spectrum_tuning_level_parameters() ) if fade_time is not None: spectrum_params["FadeTime"] = _format_duration(fade_time) await self._request( "CreateRequest", f"/zone/{zone_id}/commandprocessor", { "Command": { "CommandType": "GoToSpectrumTuningLevel", "SpectrumTuningLevelParameters": spectrum_params, } }, ) return # Handle Lumaris Tape Light if device.get("type") == "WhiteTune": white_params: Dict[str, Union[str, int]] = {} if value is not None: white_params["Level"] = value if color_value is not None: white_params.update(color_value.get_white_tuning_level_parameters()) if fade_time is not None: white_params["FadeTime"] = _format_duration(fade_time) await self._request( "CreateRequest", f"/zone/{zone_id}/commandprocessor", { "Command": { "CommandType": "GoToWhiteTuningLevel", "WhiteTuningLevelParameters": white_params, } }, ) return if device.get("type") in _LEAP_DEVICE_TYPES["light"] and fade_time is not None: await self._request( "CreateRequest", f"/zone/{zone_id}/commandprocessor", { "Command": { "CommandType": "GoToDimmedLevel", "DimmedLevelParameters": { "Level": value, "FadeTime": _format_duration(fade_time), }, } }, ) else: await self._request( "CreateRequest", f"/zone/{zone_id}/commandprocessor", { "Command": { "CommandType": "GoToLevel", "Parameter": [{"Type": "Level", "Value": value}], } }, ) async def _send_zone_create_request(self, device_id: str, command: str): zone_id = self._get_zone_id(device_id) if not zone_id: return await self._request( "CreateRequest", f"/zone/{zone_id}/commandprocessor", {"Command": {"CommandType": command}}, ) async def stop_cover(self, device_id: str): """Will stop a cover.""" await self._send_zone_create_request(device_id, "Stop") async def raise_cover(self, device_id: str): """Will raise a cover.""" await self._send_zone_create_request(device_id, "Raise") # If set_value is called, we get an optimistic callback right # away with the value, if we use Raise we have to set it # as one won't come unless Stop is called or something goes wrong. self.devices[device_id]["current_state"] = 100 async def lower_cover(self, device_id: str): """Will lower a cover.""" await self._send_zone_create_request(device_id, "Lower") # If set_value is called, we get an optimistic callback right # away with the value, if we use Lower we have to set it # as one won't come unless Stop is called or something goes wrong. self.devices[device_id]["current_state"] = 0 async def set_fan(self, device_id: str, value: str): """ Will set the value for a fan device with the given device ID. :param device_id: device id to set the value on :param value: string value to set the fan to: Off, Low, Medium, MediumHigh, High """ zone_id = self._get_zone_id(device_id) if zone_id: await self._request( "CreateRequest", f"/zone/{zone_id}/commandprocessor", { "Command": { "CommandType": "GoToFanSpeed", "FanSpeedParameters": {"FanSpeed": value}, } }, ) 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. :param device_id: device id to turn on :param **kwargs: additional parameters for set_value """ await self.set_value(device_id, 100, **kwargs) async def turn_off(self, device_id: str, **kwargs): """ Will turn 'off' the device with the given ID. :param device_id: device id to turn off :param **kwargs: additional parameters for set_value """ await self.set_value(device_id, 0, **kwargs) async def activate_scene(self, scene_id: str): """ Will activate the scene with the given ID. :param scene_id: scene id, e.g. 23 """ if scene_id in self.scenes: await self._request( "CreateRequest", f"/virtualbutton/{scene_id}/commandprocessor", {"Command": {"CommandType": "PressAndRelease"}}, ) async def tap_button(self, button_id: str): """ Send a press and release message for the given button ID. :param button_id: button ID, e.g. 23 """ if button_id in self.buttons: await self._request( "CreateRequest", f"/button/{button_id}/commandprocessor", {"Command": {"CommandType": "PressAndRelease"}}, ) def _get_zone_id(self, device_id: str) -> Optional[str]: """ Return the zone id for an given device. :param device_id: device id for which to retrieve a zone id """ return self.devices[device_id].get("zone") async def _monitor(self): """Event monitoring loop.""" try: while True: await self._monitor_once() except asyncio.CancelledError: pass except Exception as ex: _LOG.critical("monitor loop has exited", exc_info=1) if not self._login_completed.done(): self._login_completed.set_exception(ex) raise finally: self._login_completed.cancel() async def _monitor_once(self): """Monitor for events until an error occurs.""" try: _LOG.debug("Connecting to Smart Bridge via SSL") self._leap = await self._connect() self._leap.subscribe_unsolicited(self._handle_unsolicited) _LOG.debug("Successfully connected to Smart Bridge.") if self._on_connect_callback: self._on_connect_callback() if self._login_task is not None: self._login_task.cancel() if self._ping_task is not None: self._ping_task.cancel() self._login_task = asyncio.get_running_loop().create_task(self._login()) self._ping_task = asyncio.get_running_loop().create_task(self._ping()) await self._leap.run() _LOG.warning("LEAP session ended. Reconnecting...") await asyncio.sleep(RECONNECT_DELAY) # ignore OSError too. # sometimes you get OSError instead of ConnectionError. except ( ValueError, ConnectionError, OSError, asyncio.TimeoutError, BridgeDisconnectedError, ) as ex: _LOG.warning("Reconnecting after error: %s", ex) await asyncio.sleep(RECONNECT_DELAY) finally: if self._login_task is not None: self._login_task.cancel() self._login_task = None if self._ping_task is not None: self._ping_task.cancel() self._ping_task = None if self._leap is not None: self._leap.close() self._leap = None def _handle_one_zone_status(self, response: Response): _LOG.debug("Handling single zone status: %s", response) body = response.Body if body is None: return self._handle_zone_status(body["ZoneStatus"]) def _handle_zone_status(self, status): zone = id_from_href(status["Zone"]["href"]) level = status.get("Level", -1) fan_speed = status.get("FanSpeed", None) tilt = status.get("Tilt", None) color = ColorMode.get_color_from_leap(status) warm_dim = WarmDimmingColorValue.get_warm_dim_from_leap(status) _LOG.debug("zone=%s level=%s", zone, level) device = self.get_device_by_zone_id(zone) if level >= 0: device["current_state"] = level device["fan_speed"] = fan_speed device["tilt"] = tilt # only update color if it's not None, since color is not reported on brightness # changes if color is not None: device["color"] = color if warm_dim is not None: device["warm_dim"] = warm_dim if device["device_id"] in self._subscribers: self._subscribers[device["device_id"]]() def _handle_button_status(self, response: Response): _LOG.debug("Handling button status: %s", response) if response.Body is None: return status = response.Body["ButtonStatus"] button_id = id_from_href(status["Button"]["href"]) button_event = status["ButtonEvent"]["EventType"] if button_id in self.buttons: self.buttons[button_id]["current_state"] = button_event # Notify any subscribers of the change to button status if button_id in self._button_subscribers: self._button_subscribers[button_id](button_event) def _handle_button_led_status(self, response: Response): """ Handle events for button LED status changes. :param response: processor response with event """ _LOG.debug("Handling button LED status: %s", response) if response.Body is None: return status = response.Body["LEDStatus"] button_led_id = id_from_href(status["LED"]["href"]) state = 100 if status["State"] == "On" else 0 if button_led_id in self.devices: self.devices[button_led_id]["current_state"] = state # Notify any subscribers of the change to LED status if button_led_id in self._subscribers: self._subscribers[button_led_id]() def _handle_multi_zone_status(self, response: Response): _LOG.debug("Handling multi zone status: %s", response) if response.Body is None: return for zonestatus in response.Body["ZoneStatuses"]: self._handle_zone_status(zonestatus) def _handle_occupancy_group_status(self, response: Response): _LOG.debug("Handling occupancy group status: %s", response) if response.Body is None: return statuses = response.Body.get("OccupancyGroupStatuses", {}) for status in statuses: occgroup_id = id_from_href(status["OccupancyGroup"]["href"]) ostat = status["OccupancyStatus"] if occgroup_id not in self.occupancy_groups: if ostat != OCCUPANCY_GROUP_UNKNOWN: _LOG.warning( "Occupancy group %s has a status but no sensors", occgroup_id ) continue if ostat == OCCUPANCY_GROUP_UNKNOWN: _LOG.warning( "Occupancy group %s has sensors but no status", occgroup_id ) self.occupancy_groups[occgroup_id]["status"] = ostat # Notify any subscribers of the change to occupancy status if occgroup_id in self._occupancy_subscribers: self._occupancy_subscribers[occgroup_id]() def _handle_ra3_occupancy_group_status(self, response: Response): _LOG.debug("Handling ra3 occupancy status: %s", response) if response.Body is None: return statuses = response.Body.get("AreaStatuses", []) for status in statuses: occgroup_id = id_from_href(status["href"]) if occgroup_id.endswith("/status"): occgroup_id = occgroup_id[:-7] # Check to see if the OccupancyStatus Key exists in the response. # Sometimes in just responds swith the CurrentScene key if "OccupancyStatus" in status: ostat = status["OccupancyStatus"] if occgroup_id not in self.occupancy_groups: if ostat != OCCUPANCY_GROUP_UNKNOWN: _LOG.debug( "Occupancy group %s has a status but no sensors", occgroup_id, ) continue if ostat == OCCUPANCY_GROUP_UNKNOWN: _LOG.warning( "Occupancy group %s has sensors but no status", occgroup_id ) self.occupancy_groups[occgroup_id]["status"] = ostat # Notify any subscribers of the change to occupancy status if occgroup_id in self._occupancy_subscribers: self._occupancy_subscribers[occgroup_id]() def _handle_unsolicited(self, response: Response): if ( response.CommuniqueType == "ReadResponse" and response.Header.MessageBodyType == "OneZoneStatus" ): self._handle_one_zone_status(response) elif ( response.CommuniqueType == "ReadResponse" and response.Header.MessageBodyType == "OneLEDStatus" ): self._handle_button_led_status(response) async def _login(self): """Connect and login to the Smart Bridge LEAP server using SSL.""" try: await self._load_areas() # Read /project to determine bridge type project_json = await self._request("ReadRequest", "/project") project = project_json.Body["Project"] if ( project["ProductType"] == "Lutron RadioRA 3 Project" or project["ProductType"] == "Lutron HWQS Project" ): # RadioRa3 or HomeWorks QSX Processor device detected _LOG.debug("RA3 or QSX processor detected") # Load processor as devices[1] for compatibility with lutron_caseta HA # integration await self._load_ra3_processor() await self._load_ra3_devices() await self._subscribe_to_button_status() await self._load_ra3_occupancy_groups() await self._subscribe_to_ra3_occupancy_groups() else: # Caseta Bridge Device detected _LOG.debug("Caseta bridge detected") await self._load_devices() await self._load_buttons() await self._load_lip_devices() await self._load_scenes() await self._load_occupancy_groups() await self._subscribe_to_occupancy_groups() await self._subscribe_to_button_status() for device in self.devices.values(): if device.get("zone") is not None: _LOG.debug("Requesting zone information from %s", device) response = await self._request( "ReadRequest", f"/zone/{device['zone']}/status" ) self._handle_one_zone_status(response) if not self._login_completed.done(): self._login_completed.set_result(None) except asyncio.CancelledError: pass except Exception as ex: if not self._login_completed.done(): self._login_completed.set_exception(ex) raise async def _ping(self): """Periodically ping the LEAP server to keep the connection open.""" try: while True: await asyncio.sleep(PING_INTERVAL) await self._request("ReadRequest", "/server/1/status/ping") except asyncio.TimeoutError: _LOG.warning("ping was not answered. closing connection.") self._leap.close() except asyncio.CancelledError: pass except Exception: _LOG.warning("ping failed. closing connection.", exc_info=1) self._leap.close() raise async def _load_devices(self): """Load the device list from the SSL LEAP server interface.""" _LOG.debug("Loading devices") device_json = await self._request("ReadRequest", "/device") # If /device has no body, this probably isn't Caseta if device_json.Body is None: return for device in device_json.Body["Devices"]: _LOG.debug(device) device_id = id_from_href(device["href"]) area_id = None area_href = device.get("AssociatedArea", {}).get("href") if area_href is not None: area_id = id_from_href(area_href) device_zone = None button_groups = None occupancy_sensors = None if "LocalZones" in device: device_zone = id_from_href(device["LocalZones"][0]["href"]) if "ButtonGroups" in device: button_groups = [ id_from_href(button_group["href"]) for button_group in device["ButtonGroups"] ] if "OccupancySensors" in device: occupancy_sensors = [ id_from_href(occupancy_sensor["href"]) for occupancy_sensor in device["OccupancySensors"] ] device_name = "_".join(device["FullyQualifiedName"]) self.devices.setdefault( device_id, { "device_id": device_id, "current_state": -1, "fan_speed": None, "tilt": None, }, ).update( zone=device_zone, name=device_name, button_groups=button_groups, occupancy_sensors=occupancy_sensors, type=device["DeviceType"], model=device["ModelNumber"], serial=device["SerialNumber"], device_name=device["Name"], area=area_id, ) async def _load_ra3_devices(self): for area in self.areas.values(): await self._load_ra3_control_stations(area) await self._load_ra3_zones(area) # caseta does this by default, but we need to do it manually for RA3 await self._subscribe_to_multi_zone_status() async def _load_ra3_processor(self): # Load processor as devices[1] for compatibility with lutron_caseta HA # integration processor_json = await self._request( "ReadRequest", "/device?where=IsThisDevice:true" ) if processor_json.Body is None: return processor = processor_json.Body["Devices"][0] area_id = id_from_href(processor["AssociatedArea"]["href"]) processor_area = self.areas[area_id]["name"] level = -1 device_id = "1" fan_speed = None self.devices.setdefault( device_id, {"device_id": device_id, "current_state": level, "fan_speed": fan_speed}, ).update( zone=device_id, name=" ".join((processor_area, processor["Name"])), button_groups=None, type=processor["DeviceType"], model=processor["ModelNumber"], serial=processor["SerialNumber"], area=area_id, device_name=processor["Name"], ) async def _load_ra3_control_stations(self, area): """ Load and process the control stations for an area. :param area: data structure describing the area """ area_id = area["id"] area_name = area["name"] station_json = await self._request( "ReadRequest", f"/area/{area_id}/associatedcontrolstation" ) if station_json.Body is None: return station_json = station_json.Body["ControlStations"] for station in station_json: station_name = station["Name"] ganged_devices_json = station["AssociatedGangedDevices"] for device_json in ganged_devices_json: await self._load_ra3_station_device( area_name, station_name, device_json ) async def _load_ra3_station_device( self, control_station_area_name, control_station_name, device_json ): """ Load button groups and buttons for a control station device. :param control_station_name: the name of the control station :param device_json: data structure describing the station device """ device_id = id_from_href(device_json["Device"]["href"]) device_type = device_json["Device"]["DeviceType"] # ignore non-button devices if device_type not in _LEAP_DEVICE_TYPES.get("sensor"): return button_group_json = await self._request( "ReadRequest", f"/device/{device_id}/buttongroup/expanded" ) # ignore button devices without buttons if button_group_json.Body is None: return device_json = await self._request("ReadRequest", f"/device/{device_id}") device_name = device_json.Body["Device"]["Name"] device_model = device_json.Body["Device"]["ModelNumber"] device_type_friendly = device_type control_station_combined_name = "_".join( (control_station_area_name, control_station_name) ) if "Pico" in device_type: device_type_friendly = "Pico" elif "Keypad" in device_type: device_type_friendly = "Keypad" if "SerialNumber" in device_json.Body["Device"]: device_serial = device_json.Body["Device"]["SerialNumber"] else: device_serial = None button_groups = [ id_from_href(group["href"]) for group in button_group_json.Body["ButtonGroupsExpanded"] ] self.devices.setdefault( device_id, { "device_id": device_id, "current_state": -1, "fan_speed": None, }, ).update( zone=None, name=" ".join( (control_station_combined_name, device_name, device_type_friendly) ), button_groups=button_groups, type=device_type, model=device_model, serial=device_serial, control_station_name=control_station_name, device_name=device_name, area=id_from_href(device_json.Body["Device"]["AssociatedArea"]["href"]), ) for button_expanded_json in button_group_json.Body["ButtonGroupsExpanded"]: for button_json in button_expanded_json.get("Buttons", []): await self._load_ra3_button(button_json, self.devices[device_id]) async def _load_ra3_button(self, button_json, keypad_device): """ Create button device and load associated button LEDs. :param button_json: data structure describing this button :param device: data structure describing the keypad device """ button_id = id_from_href(button_json["href"]) button_number = button_json["ButtonNumber"] button_engraving = button_json.get("Engraving", None) parent_id = id_from_href(button_json["Parent"]["href"]) button_led = None button_led_obj = button_json.get("AssociatedLED", None) if button_led_obj is not None: button_led = id_from_href(button_led_obj["href"]) if button_engraving is not None and button_engraving["Text"]: button_name = button_engraving["Text"].replace("\n", " ") else: button_name = button_json["Name"] self.buttons.setdefault( button_id, { "device_id": button_id, "current_state": BUTTON_STATUS_RELEASED, "button_number": button_number, "button_group": parent_id, }, ).update( name=keypad_device["name"], type=keypad_device["type"], model=keypad_device["model"], serial=keypad_device["serial"], button_name=button_name, button_led=button_led, device_name=button_name, parent_device=keypad_device["device_id"], ) # Load the button LED details if button_led is not None: await self._load_ra3_button_led(button_led, button_id, keypad_device) async def _load_ra3_button_led(self, button_led, button_id, keypad_device): """ Create an LED device from a given LEAP button ID. :param button_led: LED ID of the button LED :param button_id: device ID of the associated button :param keypad_device: keypad device to which the LED belongs """ button = self.buttons[button_id] button_name = button["button_name"] keypad_name = keypad_device["name"] self.devices.setdefault( button_led, { "device_id": button_led, "current_state": -1, "fan_speed": None, }, ).update( name=" ".join((keypad_name, f"{button_name} LED")), type="KeypadLED", model="KeypadLED", serial=None, zone=None, device_name=" ".join((button_name, "LED")), parent_device=keypad_device["device_id"], ) await self._subscribe_to_button_led_status(button_led) async def _load_ra3_zones(self, area): # For each area, process zones. They will masquerade as devices area_id = area["id"] zone_json = await self._request( "ReadRequest", f"/area/{area_id}/associatedzone" ) if zone_json.Body is None: return zone_json = zone_json.Body["Zones"] for zone in zone_json: level = zone.get("Level", -1) zone_id = id_from_href(zone["href"]) fan_speed = zone.get("FanSpeed", None) zone_name = zone["Name"] zone_type = zone["ControlType"] color_tuning_properties = zone.get("ColorTuningProperties") zone_white_tuning_range = None if color_tuning_properties is not None: zone_white_tuning_range = color_tuning_properties.get( "WhiteTuningLevelRange" ) self.devices.setdefault( zone_id, {"device_id": zone_id, "current_state": level, "fan_speed": fan_speed}, ).update( zone=zone_id, name="_".join((area["name"], zone_name)), button_groups=None, type=zone_type, model=None, serial=None, area=area_id, device_name=zone_name, white_tuning_range=zone_white_tuning_range, ) async def _load_lip_devices(self): """Load the LIP device list from the SSL LEAP server interface.""" _LOG.debug("Loading LIP devices") try: device_json = await self._request("ReadRequest", "/server/2/id") except BridgeResponseError: # Only the PRO and RASelect2 hubs support getting the LIP devices return devices = device_json.Body.get("LIPIdList", {}).get("Devices", {}) _LOG.debug(devices) self.lip_devices = { device["ID"]: device for device in devices if "ID" in device and "Name" in device } async def _load_scenes(self): """ Load the scenes from the Smart Bridge. Scenes are known as virtual buttons in the SSL LEAP interface. """ _LOG.debug("Loading scenes from the Smart Bridge") scene_json = await self._request("ReadRequest", "/virtualbutton") for scene in scene_json.Body["VirtualButtons"]: _LOG.debug(scene) # If 'Name' is not a key in scene, then it is likely a scene pico # vbutton. For now, simply ignore these scenes. if scene["IsProgrammed"] and "Name" in scene: scene_id = id_from_href(scene["href"]) scene_name = scene["Name"] self.scenes[scene_id] = {"scene_id": scene_id, "name": scene_name} async def _load_buttons(self): """Load Pico button groups and button mappings.""" _LOG.debug("Loading buttons for Pico Button Groups") button_json = await self._request("ReadRequest", "/button") button_devices = { button_group: device for device in self.devices.values() if device["button_groups"] is not None for button_group in device["button_groups"] } # If there are no devices with buttons 'Buttons' will # not be present in the response for button in button_json.Body.get("Buttons", []): button_id = id_from_href(button["href"]) parent_id = id_from_href(button["Parent"]["href"]) button_device = button_devices.get(parent_id) if button_device is None: _LOG.error( "Encountered a button %s belonging to unknown button group %s", button_id, parent_id, ) continue button_number = button["ButtonNumber"] pico_name = button_device["name"] self.buttons.setdefault( button_id, { "device_id": button_id, "current_state": BUTTON_STATUS_RELEASED, "button_number": button_number, }, ).update( name=pico_name, type=button_device["type"], model=button_device["model"], serial=button_device["serial"], parent_device=button_device["device_id"], ) async def _load_areas(self): """Load the areas from the Smart Bridge.""" _LOG.debug("Loading areas from the Smart Bridge") area_json = await self._request("ReadRequest", "/area") for area in area_json.Body["Areas"]: area_id = id_from_href(area["href"]) parent_id = None if "Parent" in area: parent_id = id_from_href(area["Parent"]["href"]) self.areas.setdefault( area_id, { "id": area_id, "name": area["Name"], "parent_id": parent_id, }, ) async def _load_occupancy_groups(self): """Load the occupancy groups from the Smart Bridge.""" _LOG.debug("Loading occupancy groups from the Smart Bridge") occgroup_json = await self._request("ReadRequest", "/occupancygroup") if occgroup_json.Body is None: return occgroups = occgroup_json.Body.get("OccupancyGroups", {}) for occgroup in occgroups: self._process_occupancy_group(occgroup) def _process_occupancy_group(self, occgroup): """Process occupancy group.""" occgroup_id = id_from_href(occgroup["href"]) occsensor_ids = [] associated_sensors = occgroup.get("AssociatedSensors", []) if not associated_sensors: _LOG.debug("No sensors associated with %s", occgroup["href"]) return _LOG.debug("Found occupancy group with sensors: %s", occgroup_id) for sensor in associated_sensors: occsensor_ids.append(id_from_href(sensor["OccupancySensor"]["href"])) associated_areas = occgroup.get("AssociatedAreas", []) if not associated_areas: _LOG.error( "No associated areas found with occupancy group " "containing sensors: %s -- skipping", occgroup_id, ) return if len(associated_areas) > 1: _LOG.warning( "Occupancy group %s associated with multiple " "areas. Naming based on first area.", occgroup_id, ) occgroup_area_id = id_from_href(associated_areas[0]["Area"]["href"]) if occgroup_area_id not in self.areas: _LOG.error( "Unknown parent area for occupancy group %s: %s", occgroup_id, occgroup_area_id, ) return self.occupancy_groups.setdefault( occgroup_id, { "occupancy_group_id": occgroup_id, "status": OCCUPANCY_GROUP_UNKNOWN, "sensors": occsensor_ids, }, ).update( name=f"{self.areas[occgroup_area_id]['name']} Occupancy", device_name="Occupancy", area=occgroup_area_id, ) async def _load_ra3_occupancy_groups(self): """Load the devices from the bridge and filter for occupancy sensors.""" _LOG.debug("Finding occupancy sensors from bridge") occdevice_json = await self._request( "ReadRequest", "/device?where=IsThisDevice:false" ) if occdevice_json.Body is None: return occdevices = occdevice_json.Body.get("Devices", {}) for occdevice in occdevices: if occdevice["DeviceType"] in RA3_OCCUPANCY_SENSOR_DEVICE_TYPES: self._process_ra3_occupancy_group(occdevice) def _process_ra3_occupancy_group(self, occdevice): """Process ra3 occupancy group.""" occdevice_id = id_from_href(occdevice["href"]) associated_area = occdevice["AssociatedArea"] occgroup_area_id = id_from_href(associated_area["href"]) if occgroup_area_id not in self.areas: _LOG.error( "Unknown parent area for occupancy group %s: %s", occdevice_id, occgroup_area_id, ) return occgroup = self.occupancy_groups.setdefault( occgroup_area_id, { "occupancy_group_id": occgroup_area_id, "status": OCCUPANCY_GROUP_UNKNOWN, "sensors": [], "name": f"{self.areas[occgroup_area_id]['name']} Occupancy", "device_name": "Occupancy", "area": occgroup_area_id, }, ) occgroup["sensors"].append(occdevice_id) async def _subscribe_to_ra3_occupancy_groups(self): """Subscribe to ra3 occupancy group (area) status updates.""" _LOG.debug("Subscribing to occupancy group (ra3: area) status updates") try: response, _ = await self._subscribe( "/area/status", self._handle_ra3_occupancy_group_status ) _LOG.debug("Subscribed to occupancygroup status") except BridgeResponseError as ex: _LOG.error("Failed occupancy subscription: %s", ex.response) return self._handle_ra3_occupancy_group_status(response) async def _subscribe_to_button_status(self): """Subscribe to button status updates.""" _LOG.debug("Subscribing to button status updates") try: for button in self.buttons: response, _ = await self._subscribe( f"/button/{button}/status/event", self._handle_button_status, ) _LOG.debug("Subscribed to button %s status", button) self._handle_button_status(response) except BridgeResponseError as ex: _LOG.error("Failed device status subscription: %s", ex.response) return async def _subscribe_to_button_led_status(self, button_led_id): """Subscribe to button LED status updates.""" _LOG.debug( "Subscribing to button LED status updates for LED ID %s", button_led_id ) try: response, _ = await self._subscribe( f"/led/{button_led_id}/status", self._handle_button_led_status, ) _LOG.debug("Subscribed to button LED %s status", button_led_id) self._handle_button_led_status(response) except BridgeResponseError as ex: _LOG.error("Failed device status subscription: %s", ex.response) return async def _subscribe_to_occupancy_groups(self): """Subscribe to occupancy group status updates.""" _LOG.debug("Subscribing to occupancy group status updates") try: response, _ = await self._subscribe( "/occupancygroup/status", self._handle_occupancy_group_status ) _LOG.debug("Subscribed to occupancygroup status") except BridgeResponseError as ex: _LOG.error("Failed occupancy subscription: %s", ex.response) return self._handle_occupancy_group_status(response) async def _subscribe_to_multi_zone_status(self): """Subscribe to multi-zone status updates - RA3.""" _LOG.debug("Subscribing to multi-zone status updates") try: response, _ = await self._subscribe( "/zone/status", self._handle_multi_zone_status ) _LOG.debug("Subscribed to zone status") except BridgeResponseError as ex: _LOG.error("Failed zone subscription: %s", ex.response) return self._handle_multi_zone_status(response) async def close(self): """Disconnect from the bridge.""" _LOG.info("Processing Smartbridge.close() call") for task in (self._monitor_task, self._ping_task, self._login_task): if task is not None and not task.done(): task.cancel() def _format_duration(duration: timedelta) -> str: """Convert a timedelta to the hh:mm:ss format used in LEAP.""" total_seconds = math.floor(duration.total_seconds()) seconds = int(total_seconds % 60) total_minutes = math.floor(total_seconds / 60) minutes = int(total_minutes % 60) hours = int(total_minutes / 60) return f"{hours:02}:{minutes:02}:{seconds:02}" pylutron-caseta-0.24.0/src/pylutron_caseta/utils.py000066400000000000000000000003471476343602200224570ustar00rootroot00000000000000"""Utilities for pylutron_caseta.""" import sys if sys.version_info[:2] < (3, 11): from async_timeout import timeout as asyncio_timeout else: from asyncio import timeout as asyncio_timeout __all__ = ["asyncio_timeout"] pylutron-caseta-0.24.0/tests/000077500000000000000000000000001476343602200161005ustar00rootroot00000000000000pylutron-caseta-0.24.0/tests/responses/000077500000000000000000000000001476343602200201215ustar00rootroot00000000000000pylutron-caseta-0.24.0/tests/responses/areas.json000066400000000000000000000071121476343602200221100ustar00rootroot00000000000000{ "CommuniqueType": "ReadResponse", "Header": { "MessageBodyType": "MultipleAreaDefinition", "StatusCode": "200 OK", "Url": "/area" }, "Body": { "Areas": [ { "href": "/area/1", "Name": "root", "LoadShedding": { "href": "/area/1/loadshedding" } }, { "href": "/area/2", "Name": "Hallway", "Parent": { "href": "/area/1" }, "Category": { "Type": "CustomTransition" }, "AssociatedOccupancyGroups": [ { "href": "/occupancygroup/1" } ], "LoadShedding": { "href": "/area/2/loadshedding" }, "OccupancySettings": { "href": "/area/2/occupancysettings" }, "OccupancySensorSettings": { "href": "/area/2/occupancysensorsettings" }, "DaylightingGainSettings": { "href": "/area/2/daylightinggainsettings" } }, { "href": "/area/3", "Name": "Living Room", "Parent": { "href": "/area/1" }, "Category": { "Type": "LivingRoom" }, "AssociatedDevices": [ { "href": "/device/14" }, { "href": "/device/15" }, { "href": "/device/16" }, { "href": "/device/26" }, { "href": "/device/31" } ], "AssociatedOccupancyGroups": [ { "href": "/occupancygroup/2" } ], "LoadShedding": { "href": "/area/3/loadshedding" }, "OccupancySettings": { "href": "/area/3/occupancysettings" }, "OccupancySensorSettings": { "href": "/area/3/occupancysensorsettings" }, "DaylightingGainSettings": { "href": "/area/3/daylightinggainsettings" } }, { "href": "/area/4", "Name": "Master Bathroom", "Parent": { "href": "/area/1" }, "Category": { "Type": "Bathroom" }, "AssociatedOccupancyGroups": [ { "href": "/occupancygroup/3" } ], "LoadShedding": { "href": "/area/4/loadshedding" }, "OccupancySettings": { "href": "/area/4/occupancysettings" }, "OccupancySensorSettings": { "href": "/area/4/occupancysensorsettings" }, "DaylightingGainSettings": { "href": "/area/4/daylightinggainsettings" } } ] } } pylutron-caseta-0.24.0/tests/responses/buttons.json000066400000000000000000000023331476343602200225130ustar00rootroot00000000000000{ "CommuniqueType": "ReadResponse", "Header": { "MessageBodyType": "MultipleButtonDefinition", "StatusCode": "200 OK", "Url": "/button" }, "Body": { "Buttons": [ { "href": "/button/101", "ButtonNumber": 0, "ProgrammingModel": { "href": "/programmingmodel/127" }, "Parent": { "href": "/buttongroup/2" }, "Name": "Button 1" }, { "href": "/button/102", "ButtonNumber": 0, "ProgrammingModel": { "href": "/programmingmodel/131" }, "Parent": { "href": "/buttongroup/5" }, "Name": "Button 1" }, { "href": "/button/103", "ButtonNumber": 5, "ProgrammingModel": { "href": "/programmingmodel/136" }, "Parent": { "href": "/buttongroup/6" }, "Name": "Button 1" } ] } }pylutron-caseta-0.24.0/tests/responses/buttonsubscribe.json000066400000000000000000000002251476343602200242300ustar00rootroot00000000000000{ "CommuniqueType": "SubscribeResponse", "Header": { "StatusCode": "204 NoContent", "Url": "/button/101/status/event" } }pylutron-caseta-0.24.0/tests/responses/devices.json000066400000000000000000000225671476343602200224520ustar00rootroot00000000000000{ "CommuniqueType": "ReadResponse", "Header": { "MessageBodyType": "MultipleDeviceDefinition", "StatusCode": "200 OK", "Url": "/device" }, "Body": { "Devices": [ { "href": "/device/1", "Name": "Smart Bridge", "FullyQualifiedName": [ "Smart Bridge" ], "Parent": { "href": "/project" }, "SerialNumber": 1234, "ModelNumber": "L-BDG2-WH", "DeviceType": "SmartBridge", "RepeaterProperties": { "IsRepeater": true } }, { "href": "/device/2", "Name": "Lights", "FullyQualifiedName": [ "Hallway", "Lights" ], "Parent": { "href": "/project" }, "SerialNumber": 2345, "ModelNumber": "PD-6WCL-XX", "DeviceType": "WallDimmer", "LocalZones": [ { "href": "/zone/1" } ], "AssociatedArea": { "href": "/area/2" } }, { "href": "/device/3", "Name": "Fan", "FullyQualifiedName": [ "Hallway", "Fan" ], "Parent": { "href": "/project" }, "SerialNumber": 3456, "ModelNumber": "PD-FSQN-XX", "DeviceType": "CasetaFanSpeedController", "LocalZones": [ { "href": "/zone/2" } ], "AssociatedArea": { "href": "/area/2" } }, { "href": "/device/4", "Name": "Occupancy Sensor", "FullyQualifiedName": [ "Living Room", "Occupancy Sensor" ], "Parent": { "href": "/project" }, "SerialNumber": 4567, "ModelNumber": "LRF2-XXXXB-P-XX", "DeviceType": "RPSOccupancySensor", "AssociatedArea": { "href": "/area/3" }, "OccupancySensors": [ { "href": "/occupancysensor/2" } ], "LinkNodes": [ { "href": "/device/4/linknode/53" } ], "DeviceRules": [ { "href": "/devicerule/11" } ] }, { "href": "/device/5", "Name": "Occupancy Sensor Door", "FullyQualifiedName": [ "Master Bathroom", "Occupancy Sensor Door" ], "Parent": { "href": "/project" }, "SerialNumber": 5678, "ModelNumber": "PD-VSENS-XX", "DeviceType": "RPSOccupancySensor", "AssociatedArea": { "href": "/area/4" }, "OccupancySensors": [ { "href": "/occupancysensor/3" } ], "LinkNodes": [ { "href": "/device/5/linknode/55" } ], "DeviceRules": [ { "href": "/devicerule/123" } ] }, { "href": "/device/6", "Name": "Occupancy Sensor Tub", "FullyQualifiedName": [ "Master Bathroom", "Occupancy Sensor Tub" ], "Parent": { "href": "/project" }, "SerialNumber": 6789, "ModelNumber": "PD-OSENS-XX", "DeviceType": "RPSOccupancySensor", "AssociatedArea": { "href": "/area/4" }, "OccupancySensors": [ { "href": "/occupancysensor/4" } ], "LinkNodes": [ { "href": "/device/6/linknode/56" } ], "DeviceRules": [ { "href": "/devicerule/122" } ] }, { "href": "/device/7", "Name": "Living Shade 3", "FullyQualifiedName": [ "Living Room", "Living Shade 3" ], "Parent": { "href": "/project" }, "SerialNumber": 1234, "ModelNumber": "QSYC-J-RCVR", "DeviceType": "QsWirelessShade", "LocalZones": [ { "href": "/zone/6" } ], "AssociatedArea": { "href": "/area/3" }, "LinkNodes": [ { "href": "/device/10/linknode/9" } ], "DeviceRules": [ { "href": "/devicerule/10" } ] }, { "href": "/device/8", "Name": "Pico", "FullyQualifiedName": [ "Master Bedroom", "Pico" ], "Parent": { "href": "/project" }, "SerialNumber": 4321, "ModelNumber": "PJ2-3BRL-GXX-X01", "DeviceType": "Pico3ButtonRaiseLower", "ButtonGroups": [ { "href": "/buttongroup/2" } ], "AssociatedArea": { "href": "/area/4" }, "LinkNodes": [ { "href": "/device/3/linknode/3" } ], "DeviceRules": [ { "href": "/devicerule/25" } ] }, { "href": "/device/9", "Name": "Blinds Remote", "FullyQualifiedName": ["Living Room", "Blinds Remote"], "Parent": { "href": "/project" }, "SerialNumber": 92322656, "ModelNumber": "CS-YJ-4GC-WH", "DeviceType": "FourGroupRemote", "ButtonGroups": [{ "href": "/buttongroup/5" }, { "href": "/buttongroup/6" }], "AssociatedArea": { "href": "/area/3" }, "LinkNodes": [{ "href": "/device/9/linknode/31" }], "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/3" }, "LinkNodes": [{ "href": "/device/10/linknode/18" }], "DeviceRules": [{ "href": "/devicerule/125" }], "AddressedState": "Addressed" }, { "href": "/device/11", "Name": "WoodBlinds", "FullyQualifiedName": ["Living Room", "WoodBlinds"], "Parent": { "href": "/project" }, "SerialNumber": 4569, "ModelNumber": "TLT-EDU-B-J", "DeviceType": "Tilt", "LocalZones": [{ "href": "/zone/4" }], "AssociatedArea": { "href": "/area/3" }, "LinkNodes": [{ "href": "/device/11/linknode/19" }], "DeviceRules": [{ "href": "/devicerule/126" }], "AddressedState": "Addressed" } ] } }pylutron-caseta-0.24.0/tests/responses/hwqsx/000077500000000000000000000000001476343602200212735ustar00rootroot00000000000000pylutron-caseta-0.24.0/tests/responses/hwqsx/area/000077500000000000000000000000001476343602200222035ustar00rootroot00000000000000pylutron-caseta-0.24.0/tests/responses/hwqsx/area/1008/000077500000000000000000000000001476343602200225735ustar00rootroot00000000000000pylutron-caseta-0.24.0/tests/responses/hwqsx/area/1008/associatedzone.json000066400000000000000000000002611476343602200265000ustar00rootroot00000000000000{ "Header": { "StatusCode": "204 NoContent", "Url": "/area/1008/associatedzone", "MessageBodyType": null }, "CommuniqueType": "ReadResponse", "Body": null } pylutron-caseta-0.24.0/tests/responses/hwqsx/area/1008/controlstation.json000066400000000000000000000002731476343602200265520ustar00rootroot00000000000000{ "Header": { "StatusCode": "204 NoContent", "Url": "/area/1008/associatedcontrolstation", "MessageBodyType": null }, "CommuniqueType": "ReadResponse", "Body": null } pylutron-caseta-0.24.0/tests/responses/hwqsx/area/1020/000077500000000000000000000000001476343602200225655ustar00rootroot00000000000000pylutron-caseta-0.24.0/tests/responses/hwqsx/area/1020/associatedzone.json000066400000000000000000000014161476343602200264750ustar00rootroot00000000000000{ "Header": { "StatusCode": "200 OK", "Url": "/area/1020/associatedzone", "MessageBodyType": "MultipleZoneDefinition" }, "CommuniqueType": "ReadResponse", "Body": { "Zones": [ { "href": "/zone/1744", "Name": "Downlights", "ControlType": "Dimmed", "Category": { "Type": "", "IsLight": true }, "AssociatedArea": { "href": "/area/1020" }, "SortOrder": 3 }, { "href": "/zone/1313", "Name": "Shade Group 1", "ControlType": "Shade", "Category": { "Type": "", "IsLight": false }, "AssociatedArea": { "href": "/area/1020" }, "SortOrder": 0 } ] } } pylutron-caseta-0.24.0/tests/responses/hwqsx/area/1020/controlstation.json000066400000000000000000000012631476343602200265440ustar00rootroot00000000000000{ "Header": { "StatusCode": "200 OK", "Url": "/area/1020/associatedcontrolstation", "MessageBodyType": "MultipleControlStationDefinition" }, "CommuniqueType": "ReadResponse", "Body": { "ControlStations": [ { "href": "/controlstation/1624", "Name": "Entryway", "AssociatedArea": { "href": "/area/1020" }, "SortOrder": 0, "AssociatedGangedDevices": [ { "Device": { "DeviceType": "PalladiomKeypad", "href": "/device/1626", "AddressedState": "Unaddressed" }, "GangPosition": 0 } ] } ] } } pylutron-caseta-0.24.0/tests/responses/hwqsx/area/1032/000077500000000000000000000000001476343602200225705ustar00rootroot00000000000000pylutron-caseta-0.24.0/tests/responses/hwqsx/area/1032/associatedzone.json000066400000000000000000000014271476343602200265020ustar00rootroot00000000000000{ "Header": { "StatusCode": "200 OK", "Url": "/area/1032/associatedzone", "MessageBodyType": "MultipleZoneDefinition" }, "CommuniqueType": "ReadResponse", "Body": { "Zones": [ { "href": "/zone/1150", "Name": "Downlight", "ControlType": "Dimmed", "Category": { "Type": "", "IsLight": true }, "AssociatedArea": { "href": "/area/1032" }, "SortOrder": 0 }, { "href": "/zone/1387", "Name": "Exhaust Fan", "ControlType": "Switched", "Category": { "Type": "NotALight", "IsLight": false }, "AssociatedArea": { "href": "/area/1032" }, "SortOrder": 1 } ] } } pylutron-caseta-0.24.0/tests/responses/hwqsx/area/1032/controlstation.json000066400000000000000000000012631476343602200265470ustar00rootroot00000000000000{ "Header": { "StatusCode": "200 OK", "Url": "/area/1032/associatedcontrolstation", "MessageBodyType": "MultipleControlStationDefinition" }, "CommuniqueType": "ReadResponse", "Body": { "ControlStations": [ { "href": "/controlstation/1658", "Name": "Entryway", "AssociatedArea": { "href": "/area/1032" }, "SortOrder": 0, "AssociatedGangedDevices": [ { "Device": { "DeviceType": "PalladiomKeypad", "href": "/device/1660", "AddressedState": "Unaddressed" }, "GangPosition": 0 } ] } ] } } pylutron-caseta-0.24.0/tests/responses/hwqsx/area/1578/000077500000000000000000000000001476343602200226075ustar00rootroot00000000000000pylutron-caseta-0.24.0/tests/responses/hwqsx/area/1578/associatedzone.json000066400000000000000000000022761476343602200265240ustar00rootroot00000000000000{ "Header": { "StatusCode": "200 OK", "Url": "/area/1578/associatedzone", "MessageBodyType": "MultipleZoneDefinition" }, "CommuniqueType": "ReadResponse", "Body": { "Zones": [ { "href": "/zone/1735", "Name": "Sconces", "ControlType": "Dimmed", "Category": { "Type": "", "IsLight": true }, "AssociatedArea": { "href": "/area/1578" }, "SortOrder": 1 }, { "href": "/zone/1786", "Name": "Shade Group 1", "ControlType": "Shade", "Category": { "Type": "", "IsLight": false }, "AssociatedArea": { "href": "/area/1578" }, "SortOrder": 0 }, { "href": "/zone/1692", "Name": "Downlights", "ControlType": "SpectrumTune", "Category": { "Type": "", "IsLight": true }, "AssociatedArea": { "href": "/area/1578" }, "SortOrder": 0, "ColorTuningProperties": { "WhiteTuningLevelRange": { "Min": 1400, "Max": 10000 } } } ] } } pylutron-caseta-0.24.0/tests/responses/hwqsx/area/1578/controlstation.json000066400000000000000000000012631476343602200265660ustar00rootroot00000000000000{ "Header": { "StatusCode": "200 OK", "Url": "/area/1578/associatedcontrolstation", "MessageBodyType": "MultipleControlStationDefinition" }, "CommuniqueType": "ReadResponse", "Body": { "ControlStations": [ { "href": "/controlstation/1590", "Name": "Entryway", "AssociatedArea": { "href": "/area/1578" }, "SortOrder": 0, "AssociatedGangedDevices": [ { "Device": { "DeviceType": "PalladiomKeypad", "href": "/device/1592", "AddressedState": "Unaddressed" }, "GangPosition": 0 } ] } ] } } pylutron-caseta-0.24.0/tests/responses/hwqsx/area/3/000077500000000000000000000000001476343602200223455ustar00rootroot00000000000000pylutron-caseta-0.24.0/tests/responses/hwqsx/area/3/associatedzone.json000066400000000000000000000002561476343602200262560ustar00rootroot00000000000000{ "Header": { "StatusCode": "204 NoContent", "Url": "/area/3/associatedzone", "MessageBodyType": null }, "CommuniqueType": "ReadResponse", "Body": null } pylutron-caseta-0.24.0/tests/responses/hwqsx/area/3/controlstation.json000066400000000000000000000002701476343602200263210ustar00rootroot00000000000000{ "Header": { "StatusCode": "204 NoContent", "Url": "/area/3/associatedcontrolstation", "MessageBodyType": null }, "CommuniqueType": "ReadResponse", "Body": null } pylutron-caseta-0.24.0/tests/responses/hwqsx/area/32/000077500000000000000000000000001476343602200224275ustar00rootroot00000000000000pylutron-caseta-0.24.0/tests/responses/hwqsx/area/32/associatedzone.json000066400000000000000000000002571476343602200263410ustar00rootroot00000000000000{ "Header": { "StatusCode": "204 NoContent", "Url": "/area/32/associatedzone", "MessageBodyType": null }, "CommuniqueType": "ReadResponse", "Body": null } pylutron-caseta-0.24.0/tests/responses/hwqsx/area/32/controlstation.json000066400000000000000000000012731476343602200264070ustar00rootroot00000000000000{ "Header": { "StatusCode": "200 OK", "Url": "/area/32/associatedcontrolstation", "MessageBodyType": "MultipleControlStationDefinition" }, "CommuniqueType": "ReadResponse", "Body": { "ControlStations": [ { "href": "/controlstation/1407", "Name": "Homeowner Keypad Loc", "AssociatedArea": { "href": "/area/32" }, "SortOrder": 0, "AssociatedGangedDevices": [ { "Device": { "DeviceType": "HomeownerKeypad", "href": "/device/1409", "AddressedState": "Unaddressed" }, "GangPosition": 0 } ] } ] } } pylutron-caseta-0.24.0/tests/responses/hwqsx/area/804/000077500000000000000000000000001476343602200225165ustar00rootroot00000000000000pylutron-caseta-0.24.0/tests/responses/hwqsx/area/804/associatedzone.json000066400000000000000000000045131476343602200264270ustar00rootroot00000000000000{ "Header": { "StatusCode": "200 OK", "Url": "/area/804/associatedzone", "MessageBodyType": "MultipleZoneDefinition" }, "CommuniqueType": "ReadResponse", "Body": { "Zones": [ { "href": "/zone/816", "Name": "Ceiling Light", "ControlType": "Dimmed", "Category": { "Type": "", "IsLight": true }, "AssociatedArea": { "href": "/area/804" }, "SortOrder": 0 }, { "href": "/zone/838", "Name": "Accent Light", "ControlType": "Dimmed", "Category": { "Type": "", "IsLight": true }, "AssociatedArea": { "href": "/area/804" }, "SortOrder": 1 }, { "href": "/zone/1362", "Name": "Shade Group 1", "ControlType": "Shade", "Category": { "Type": "", "IsLight": false }, "AssociatedArea": { "href": "/area/804" }, "SortOrder": 0 }, { "href": "/zone/985", "Name": "Table Lamp", "ControlType": "SpectrumTune", "Category": { "Type": "", "IsLight": true }, "AssociatedArea": { "href": "/area/804" }, "SortOrder": 2, "ColorTuningProperties": { "WhiteTuningLevelRange": { "Min": 1400, "Max": 10000 } } }, { "href": "/zone/989", "Name": "Lumaris Tape Strip", "ControlType": "WhiteTune", "Category": { "Type": "", "IsLight": true }, "AssociatedArea": { "href": "/area/804" }, "SortOrder": 2, "ColorTuningProperties": { "WhiteTuningLevelRange": { "Min": 1400, "Max": 5000 } } }, { "href": "/zone/991", "Name": "Lumaris Tape Strip RGB+TW", "ControlType": "ColorTune", "Category": { "Type": "", "IsLight": true }, "AssociatedArea": { "href": "/area/804" }, "SortOrder": 2, "ColorTuningProperties": { "WhiteTuningLevelRange": { "Min": 1800, "Max": 5000 } } } ] } } pylutron-caseta-0.24.0/tests/responses/hwqsx/area/804/controlstation.json000066400000000000000000000016221476343602200264740ustar00rootroot00000000000000{ "Header": { "StatusCode": "200 OK", "Url": "/area/804/associatedcontrolstation", "MessageBodyType": "MultipleControlStationDefinition" }, "CommuniqueType": "ReadResponse", "Body": { "ControlStations": [ { "href": "/controlstation/849", "Name": "Front Door", "AssociatedArea": { "href": "/area/804" }, "SortOrder": 0, "AssociatedGangedDevices": [ { "Device": { "DeviceType": "PalladiomKeypad", "href": "/device/851", "AddressedState": "Unaddressed" }, "GangPosition": 0 }, { "Device": { "DeviceType": "PalladiomKeypad", "href": "/device/1512", "AddressedState": "Unaddressed" }, "GangPosition": 1 } ] } ] } } pylutron-caseta-0.24.0/tests/responses/hwqsx/area/status-subscribe.json000077500000000000000000000024261476343602200264070ustar00rootroot00000000000000{ "Header": { "StatusCode": "200 OK", "Url": "/area/status", "MessageBodyType": "MultipleAreaStatus" }, "CommuniqueType": "SubscribeResponse", "Body": { "AreaStatuses": [ { "href": "/area/3/status", "OccupancyStatus": "Unknown", "CurrentScene": null }, { "href": "/area/32/status", "OccupancyStatus": "Unknown", "CurrentScene": null }, { "href": "/area/804/status", "Level": 50, "OccupancyStatus": "Unknown", "CurrentScene": { "href": "/areascene/811" } }, { "href": "/area/1008/status", "OccupancyStatus": "Unknown", "CurrentScene": null }, { "href": "/area/1020/status", "Level": 25, "OccupancyStatus": "Unknown", "CurrentScene": { "href": "/areascene/1028" } }, { "href": "/area/1032/status", "Level": 75, "OccupancyStatus": "Unknown", "CurrentScene": { "href": "/areascene/1038" } }, { "href": "/area/1578/status", "Level": 50, "OccupancyStatus": "Unknown", "CurrentScene": { "href": "/areascene/1585" } } ] } } pylutron-caseta-0.24.0/tests/responses/hwqsx/areas.json000066400000000000000000000026131476343602200232630ustar00rootroot00000000000000{ "Header": { "StatusCode": "200 OK", "Url": "/area", "MessageBodyType": "MultipleAreaDefinition" }, "CommuniqueType": "ReadResponse", "Body": { "Areas": [ { "href": "/area/3", "Name": "LEAP Testing", "SortOrder": 0, "IsLeaf": false }, { "href": "/area/32", "Name": "Equipment Room", "Parent": { "href": "/area/3" }, "SortOrder": 0, "IsLeaf": true }, { "href": "/area/804", "Name": "Foyer", "Parent": { "href": "/area/3" }, "SortOrder": 1, "IsLeaf": true }, { "href": "/area/1008", "Name": "Primary Suite", "Parent": { "href": "/area/3" }, "SortOrder": 3, "IsLeaf": false }, { "href": "/area/1020", "Name": "Bedroom 1", "Parent": { "href": "/area/1008" }, "SortOrder": 0, "IsLeaf": true }, { "href": "/area/1032", "Name": "Bathroom 1", "Parent": { "href": "/area/1008" }, "SortOrder": 1, "IsLeaf": true }, { "href": "/area/1578", "Name": "Living Room", "Parent": { "href": "/area/3" }, "SortOrder": 2, "IsLeaf": true } ] } } pylutron-caseta-0.24.0/tests/responses/hwqsx/device-list.json000066400000000000000000000422451476343602200244050ustar00rootroot00000000000000{ "Header": { "StatusCode": "200 OK", "Url": "/device?where=IsThisDevice:false", "MessageBodyType": "MultipleDeviceDefinition" }, "CommuniqueType": "ReadResponse", "Body": { "Devices": [ { "Name": "1", "DeviceType": "KetraD3", "AssociatedArea": { "href": "/area/1578" }, "href": "/device/1693", "Parent": { "href": "/project" }, "ModelNumber": "HW-D3XXXXXXXXXXXXXXXXXXXX", "LocalZones": [ { "href": "/zone/1692" } ], "LinkNodes": [ { "href": "/device/1693/linknode/1698" } ], "DeviceClass": { "HexadecimalEncoding": "1b030101" }, "AddressedState": "Unaddressed" }, { "Name": "6", "DeviceType": "KetraD3", "AssociatedArea": { "href": "/area/1578" }, "href": "/device/1728", "Parent": { "href": "/project" }, "ModelNumber": "HW-D3XXXXXXXXXXXXXXXXXXXX", "LocalZones": [ { "href": "/zone/1692" } ], "LinkNodes": [ { "href": "/device/1728/linknode/1733" } ], "DeviceClass": { "HexadecimalEncoding": "1b030101" }, "AddressedState": "Unaddressed" }, { "Name": "Enclosure Device 001", "DeviceType": "Unknown", "AssociatedArea": { "href": "/area/32" }, "href": "/device/1397", "Parent": { "href": "/project" }, "LinkNodes": [ { "href": "/device/1397/linknode/1398" } ], "DeviceClass": { "HexadecimalEncoding": "9110101" }, "AddressedState": "Unaddressed" }, { "Name": "Enclosure Device 001", "DeviceType": "Unknown", "AssociatedArea": { "href": "/area/1020" }, "href": "/device/1830", "Parent": { "href": "/project" }, "LinkNodes": [ { "href": "/device/1830/linknode/1831" } ], "DeviceClass": { "HexadecimalEncoding": "0" }, "AddressedState": "Unaddressed" }, { "Name": "Ho Kpd", "DeviceType": "HomeownerKeypad", "AssociatedArea": { "href": "/area/32" }, "AssociatedControlStation": { "href": "/controlstation/1407", "Name": "Homeowner Keypad Loc" }, "href": "/device/1409", "Parent": { "href": "/project" }, "ModelNumber": "Homeowner Keypad", "LinkNodes": [ { "href": "/device/1409/linknode/1411" } ], "DeviceClass": { "HexadecimalEncoding": "11f0101" }, "AddressedState": "Unaddressed" }, { "Name": "3", "DeviceType": "KetraA20", "AssociatedArea": { "href": "/area/804" }, "href": "/device/986", "Parent": { "href": "/project" }, "ModelNumber": "HW-A20XXXXXXXXXXXXX", "LocalZones": [ { "href": "/zone/985" } ], "LinkNodes": [ { "href": "/device/986/linknode/991" } ], "DeviceClass": { "HexadecimalEncoding": "1b010101" }, "AddressedState": "Unaddressed" }, { "Name": "Lumaris Tape Strip", "DeviceType": "Lumaris", "AssociatedArea": { "href": "/area/804" }, "href": "/device/989", "Parent": { "href": "/project" }, "ModelNumber": "HWL-TLK-SW", "LocalZones": [ { "href": "/zone/989" } ], "LinkNodes": [ { "href": "/device/989/linknode/992" } ], "DeviceClass": { "HexadecimalEncoding": "61a0101" }, "AddressedState": "Unaddressed" }, { "Name": "Lumaris Tape Strip RGB+TW", "DeviceType": "Lumaris", "AssociatedArea": { "href": "/area/804" }, "href": "/device/991", "Parent": { "href": "/project" }, "ModelNumber": "HWL-MTK-RT-IN", "LocalZones": [ { "href": "/zone/991" } ], "LinkNodes": [ { "href": "/device/989/linknode/992" } ], "DeviceClass": { "HexadecimalEncoding": "61a0101" }, "AddressedState": "Unaddressed" }, { "Name": "Keypad 1", "DeviceType": "PalladiomKeypad", "AssociatedArea": { "href": "/area/804" }, "AssociatedControlStation": { "href": "/controlstation/849", "Name": "Front Door" }, "href": "/device/851", "Parent": { "href": "/project" }, "ModelNumber": "HQWT-U-P4W", "LinkNodes": [ { "href": "/device/851/linknode/853" } ], "DeviceClass": { "HexadecimalEncoding": "1210101" }, "AddressedState": "Unaddressed" }, { "Name": "Keypad 2", "DeviceType": "PalladiomKeypad", "AssociatedArea": { "href": "/area/804" }, "AssociatedControlStation": { "href": "/controlstation/849", "Name": "Front Door" }, "href": "/device/1512", "Parent": { "href": "/project" }, "ModelNumber": "HQWT-U-P2W", "LinkNodes": [ { "href": "/device/1512/linknode/1514" } ], "DeviceClass": { "HexadecimalEncoding": "1210101" }, "AddressedState": "Unaddressed" }, { "Name": "Device 1", "DeviceType": "PalladiomKeypad", "AssociatedArea": { "href": "/area/1032" }, "AssociatedControlStation": { "href": "/controlstation/1658", "Name": "Entryway" }, "href": "/device/1660", "Parent": { "href": "/project" }, "ModelNumber": "HQWT-U-P4W", "LinkNodes": [ { "href": "/device/1660/linknode/1662" } ], "DeviceClass": { "HexadecimalEncoding": "1210101" }, "AddressedState": "Unaddressed" }, { "Name": "Enclosure Device 001", "DeviceType": "Unknown", "AssociatedArea": { "href": "/area/1020" }, "href": "/device/1846", "Parent": { "href": "/project" }, "LinkNodes": [ { "href": "/device/1846/linknode/1847" } ], "DeviceClass": { "HexadecimalEncoding": "0" }, "AddressedState": "Unaddressed" }, { "Name": "Enclosure Device 001", "DeviceType": "Unknown", "AssociatedArea": { "href": "/area/1020" }, "href": "/device/1838", "Parent": { "href": "/project" }, "LinkNodes": [ { "href": "/device/1838/linknode/1839" } ], "DeviceClass": { "HexadecimalEncoding": "0" }, "AddressedState": "Unaddressed" }, { "Name": "Enclosure Device 001", "DeviceType": "Shade", "AssociatedArea": { "href": "/area/1020" }, "href": "/device/1324", "Parent": { "href": "/project" }, "ModelNumber": "QS Shade", "LinkNodes": [ { "href": "/device/1324/linknode/1325" } ], "DeviceClass": { "HexadecimalEncoding": "3010101" }, "AddressedState": "Unaddressed" }, { "Name": "Clear Connect X Gateway 001", "DeviceType": "HWQSProcessor", "AssociatedArea": { "href": "/area/32" }, "href": "/device/982", "Parent": { "href": "/project" }, "ModelNumber": "HQP7-RF", "OwnedLinks": [ { "href": "/link/984", "LinkType": "ClearConnectTypeX" } ], "LinkNodes": [ { "href": "/device/982/linknode/983" } ], "DeviceClass": { "HexadecimalEncoding": "8100101" }, "AddressedState": "Unaddressed" }, { "Name": "Enclosure Device 001", "DeviceType": "Unknown", "AssociatedArea": { "href": "/area/1020" }, "href": "/device/1842", "Parent": { "href": "/project" }, "LinkNodes": [ { "href": "/device/1842/linknode/1843" } ], "DeviceClass": { "HexadecimalEncoding": "0" }, "AddressedState": "Unaddressed" }, { "Name": "Device 1", "DeviceType": "PalladiomKeypad", "AssociatedArea": { "href": "/area/1578" }, "AssociatedControlStation": { "href": "/controlstation/1590", "Name": "Entryway" }, "href": "/device/1592", "Parent": { "href": "/project" }, "ModelNumber": "HQWT-U-P4W", "LinkNodes": [ { "href": "/device/1592/linknode/1594" } ], "DeviceClass": { "HexadecimalEncoding": "1210101" }, "AddressedState": "Unaddressed" }, { "Name": "Device 1", "DeviceType": "PalladiomKeypad", "AssociatedArea": { "href": "/area/1020" }, "AssociatedControlStation": { "href": "/controlstation/1624", "Name": "Entryway" }, "href": "/device/1626", "Parent": { "href": "/project" }, "ModelNumber": "HQWT-U-P4W", "LinkNodes": [ { "href": "/device/1626/linknode/1628" } ], "DeviceClass": { "HexadecimalEncoding": "1210101" }, "AddressedState": "Unaddressed" }, { "Name": "Enclosure Device 001", "DeviceType": "Shade", "AssociatedArea": { "href": "/area/1578" }, "href": "/device/1811", "Parent": { "href": "/project" }, "ModelNumber": "QS Shade", "LinkNodes": [ { "href": "/device/1811/linknode/1812" } ], "DeviceClass": { "HexadecimalEncoding": "3010101" }, "AddressedState": "Unaddressed" }, { "Name": "4", "DeviceType": "KetraD3", "AssociatedArea": { "href": "/area/1578" }, "href": "/device/1716", "Parent": { "href": "/project" }, "ModelNumber": "HW-D3XXXXXXXXXXXXXXXXXXXX", "LocalZones": [ { "href": "/zone/1692" } ], "LinkNodes": [ { "href": "/device/1716/linknode/1721" } ], "DeviceClass": { "HexadecimalEncoding": "1b030101" }, "AddressedState": "Unaddressed" }, { "Name": "5", "DeviceType": "KetraD3", "AssociatedArea": { "href": "/area/1578" }, "href": "/device/1722", "Parent": { "href": "/project" }, "ModelNumber": "HW-D3XXXXXXXXXXXXXXXXXXXX", "LocalZones": [ { "href": "/zone/1692" } ], "LinkNodes": [ { "href": "/device/1722/linknode/1727" } ], "DeviceClass": { "HexadecimalEncoding": "1b030101" }, "AddressedState": "Unaddressed" }, { "Name": "Enclosure Device 001", "DeviceType": "Shade", "AssociatedArea": { "href": "/area/804" }, "href": "/device/1373", "Parent": { "href": "/project" }, "ModelNumber": "QS Shade", "LinkNodes": [ { "href": "/device/1373/linknode/1374" } ], "DeviceClass": { "HexadecimalEncoding": "3010101" }, "AddressedState": "Unaddressed" }, { "Name": "Enclosure Device 001", "DeviceType": "Unknown", "AssociatedArea": { "href": "/area/1020" }, "href": "/device/1834", "Parent": { "href": "/project" }, "LinkNodes": [ { "href": "/device/1834/linknode/1835" } ], "DeviceClass": { "HexadecimalEncoding": "0" }, "AddressedState": "Unaddressed" }, { "Name": "Enclosure Device 001", "DeviceType": "Unknown", "AssociatedArea": { "href": "/area/1020" }, "href": "/device/1850", "Parent": { "href": "/project" }, "LinkNodes": [ { "href": "/device/1850/linknode/1851" } ], "DeviceClass": { "HexadecimalEncoding": "0" }, "AddressedState": "Unaddressed" }, { "Name": "2", "DeviceType": "KetraD3", "AssociatedArea": { "href": "/area/1578" }, "href": "/device/1704", "Parent": { "href": "/project" }, "ModelNumber": "HW-D3XXXXXXXXXXXXXXXXXXXX", "LocalZones": [ { "href": "/zone/1692" } ], "LinkNodes": [ { "href": "/device/1704/linknode/1709" } ], "DeviceClass": { "HexadecimalEncoding": "1b030101" }, "AddressedState": "Unaddressed" }, { "Name": "Enclosure Device 001", "DeviceType": "Unknown", "AssociatedArea": { "href": "/area/32" }, "href": "/device/829", "Parent": { "href": "/project" }, "LinkNodes": [ { "href": "/device/829/linknode/830" } ], "DeviceClass": { "HexadecimalEncoding": "9210101" }, "AddressedState": "Unaddressed" }, { "Name": "Enclosure Device 001", "DeviceType": "Shade", "AssociatedArea": { "href": "/area/1020" }, "href": "/device/1338", "Parent": { "href": "/project" }, "ModelNumber": "QS Shade", "LinkNodes": [ { "href": "/device/1338/linknode/1339" } ], "DeviceClass": { "HexadecimalEncoding": "3010101" }, "AddressedState": "Unaddressed" }, { "Name": "Enclosure Device 001", "DeviceType": "Shade", "AssociatedArea": { "href": "/area/1578" }, "href": "/device/1797", "Parent": { "href": "/project" }, "ModelNumber": "QS Shade", "LinkNodes": [ { "href": "/device/1797/linknode/1798" } ], "DeviceClass": { "HexadecimalEncoding": "3010101" }, "AddressedState": "Unaddressed" }, { "Name": "3", "DeviceType": "KetraD3", "AssociatedArea": { "href": "/area/1578" }, "href": "/device/1710", "Parent": { "href": "/project" }, "ModelNumber": "HW-D3XXXXXXXXXXXXXXXXXXXX", "LocalZones": [ { "href": "/zone/1692" } ], "LinkNodes": [ { "href": "/device/1710/linknode/1715" } ], "DeviceClass": { "HexadecimalEncoding": "1b030101" }, "AddressedState": "Unaddressed" }, { "Name": "Enclosure Device 001", "DeviceType": "Shade", "AssociatedArea": { "href": "/area/1020" }, "href": "/device/1352", "Parent": { "href": "/project" }, "ModelNumber": "QS Shade", "LinkNodes": [ { "href": "/device/1352/linknode/1353" } ], "DeviceClass": { "HexadecimalEncoding": "3010101" }, "AddressedState": "Unaddressed" }, { "Name": "Enclosure Device 001", "DeviceType": "Unknown", "AssociatedArea": { "href": "/area/32" }, "href": "/device/1267", "Parent": { "href": "/project" }, "LinkNodes": [ { "href": "/device/1267/linknode/1268" }, { "href": "/device/1267/linknode/1270" }, { "href": "/device/1267/linknode/1272" } ], "DeviceClass": { "HexadecimalEncoding": "92f0102" }, "AddressedState": "Unaddressed" } ] } } pylutron-caseta-0.24.0/tests/responses/hwqsx/device/000077500000000000000000000000001476343602200225325ustar00rootroot00000000000000pylutron-caseta-0.24.0/tests/responses/hwqsx/device/1409/000077500000000000000000000000001476343602200231275ustar00rootroot00000000000000pylutron-caseta-0.24.0/tests/responses/hwqsx/device/1409/buttongroup.json000066400000000000000000000100601476343602200264070ustar00rootroot00000000000000{ "Header": { "StatusCode": "200 OK", "Url": "/device/1409/buttongroup/expanded", "MessageBodyType": "MultipleButtonGroupExpandedDefinition" }, "CommuniqueType": "ReadResponse", "Body": { "ButtonGroupsExpanded": [ { "href": "/buttongroup/1421", "Parent": { "href": "/device/1409" }, "SortOrder": 0, "ProgrammingType": "Freeform", "Buttons": [ { "href": "/button/1422", "ButtonNumber": 1, "ProgrammingModel": { "href": "/programmingmodel/1423", "ProgrammingModelType": "SingleActionProgrammingModel" }, "Parent": { "href": "/buttongroup/1421" }, "Name": "Button 1", "Engraving": { "Text": "Button 1" }, "AssociatedLED": { "href": "/led/1414" } }, { "href": "/button/1425", "ButtonNumber": 2, "ProgrammingModel": { "href": "/programmingmodel/1426", "ProgrammingModelType": "SingleActionProgrammingModel" }, "Parent": { "href": "/buttongroup/1421" }, "Name": "Button 2", "Engraving": { "Text": "Button 2" }, "AssociatedLED": { "href": "/led/1415" } }, { "href": "/button/1428", "ButtonNumber": 3, "ProgrammingModel": { "href": "/programmingmodel/1429", "ProgrammingModelType": "SingleActionProgrammingModel" }, "Parent": { "href": "/buttongroup/1421" }, "Name": "Button 3", "Engraving": { "Text": "Button 3" }, "AssociatedLED": { "href": "/led/1416" } }, { "href": "/button/1431", "ButtonNumber": 4, "ProgrammingModel": { "href": "/programmingmodel/1432", "ProgrammingModelType": "SingleActionProgrammingModel" }, "Parent": { "href": "/buttongroup/1421" }, "Name": "Button 4", "Engraving": { "Text": "Button 4" }, "AssociatedLED": { "href": "/led/1417" } }, { "href": "/button/1434", "ButtonNumber": 5, "ProgrammingModel": { "href": "/programmingmodel/1435", "ProgrammingModelType": "SingleActionProgrammingModel" }, "Parent": { "href": "/buttongroup/1421" }, "Name": "Button 5", "Engraving": { "Text": "Button 5" }, "AssociatedLED": { "href": "/led/1418" } }, { "href": "/button/1437", "ButtonNumber": 6, "ProgrammingModel": { "href": "/programmingmodel/1496", "ProgrammingModelType": "AdvancedToggleProgrammingModel" }, "Parent": { "href": "/buttongroup/1421" }, "Name": "Button 6", "Engraving": { "Text": "Button 6" }, "AssociatedLED": { "href": "/led/1419" } }, { "href": "/button/1440", "ButtonNumber": 7, "ProgrammingModel": { "href": "/programmingmodel/1454", "ProgrammingModelType": "AdvancedToggleProgrammingModel" }, "Parent": { "href": "/buttongroup/1421" }, "Name": "Button 7", "Engraving": { "Text": "Vacation Mode" }, "AssociatedLED": { "href": "/led/1420" } } ] } ] } } pylutron-caseta-0.24.0/tests/responses/hwqsx/device/1409/device.json000066400000000000000000000014131476343602200252600ustar00rootroot00000000000000{ "Header": { "StatusCode": "200 OK", "Url": "/device/1409", "MessageBodyType": "OneDeviceDefinition" }, "CommuniqueType": "ReadResponse", "Body": { "Device": { "Name": "Ho Kpd", "DeviceType": "HomeownerKeypad", "AssociatedArea": { "href": "/area/32" }, "AssociatedControlStation": { "href": "/controlstation/1407", "Name": "Homeowner Keypad Loc" }, "href": "/device/1409", "Parent": { "href": "/project" }, "ModelNumber": "Homeowner Keypad", "LinkNodes": [ { "href": "/device/1409/linknode/1411" } ], "DeviceClass": { "HexadecimalEncoding": "11f0101" }, "AddressedState": "Unaddressed" } } } pylutron-caseta-0.24.0/tests/responses/hwqsx/device/1512/000077500000000000000000000000001476343602200231225ustar00rootroot00000000000000pylutron-caseta-0.24.0/tests/responses/hwqsx/device/1512/buttongroup.json000066400000000000000000000027441476343602200264140ustar00rootroot00000000000000{ "Header": { "StatusCode": "200 OK", "Url": "/device/1512/buttongroup/expanded", "MessageBodyType": "MultipleButtonGroupExpandedDefinition" }, "CommuniqueType": "ReadResponse", "Body": { "ButtonGroupsExpanded": [ { "href": "/buttongroup/1519", "Parent": { "href": "/device/1512" }, "SortOrder": 0, "ProgrammingType": "Freeform", "Buttons": [ { "href": "/button/1520", "ButtonNumber": 1, "ProgrammingModel": { "href": "/programmingmodel/1913", "ProgrammingModelType": "SingleActionProgrammingModel" }, "Parent": { "href": "/buttongroup/1519" }, "Name": "Button 1", "Engraving": { "Text": "Welcome" }, "AssociatedLED": { "href": "/led/1517" } }, { "href": "/button/1524", "ButtonNumber": 4, "ProgrammingModel": { "href": "/programmingmodel/1916", "ProgrammingModelType": "SingleActionProgrammingModel" }, "Parent": { "href": "/buttongroup/1519" }, "Name": "Button 4", "Engraving": { "Text": "Goodbye" }, "AssociatedLED": { "href": "/led/1518" } } ] } ] } } pylutron-caseta-0.24.0/tests/responses/hwqsx/device/1512/device.json000066400000000000000000000013751476343602200252620ustar00rootroot00000000000000{ "Header": { "StatusCode": "200 OK", "Url": "/device/1512", "MessageBodyType": "OneDeviceDefinition" }, "CommuniqueType": "ReadResponse", "Body": { "Device": { "Name": "Keypad 2", "DeviceType": "PalladiomKeypad", "AssociatedArea": { "href": "/area/804" }, "AssociatedControlStation": { "href": "/controlstation/849", "Name": "Front Door" }, "href": "/device/1512", "Parent": { "href": "/project" }, "ModelNumber": "HQWT-U-P2W", "LinkNodes": [ { "href": "/device/1512/linknode/1514" } ], "DeviceClass": { "HexadecimalEncoding": "1210101" }, "AddressedState": "Unaddressed" } } } pylutron-caseta-0.24.0/tests/responses/hwqsx/device/1592/000077500000000000000000000000001476343602200231325ustar00rootroot00000000000000pylutron-caseta-0.24.0/tests/responses/hwqsx/device/1592/buttongroup.json000066400000000000000000000050061476343602200264160ustar00rootroot00000000000000{ "Header": { "StatusCode": "200 OK", "Url": "/device/1592/buttongroup/expanded", "MessageBodyType": "MultipleButtonGroupExpandedDefinition" }, "CommuniqueType": "ReadResponse", "Body": { "ButtonGroupsExpanded": [ { "href": "/buttongroup/1601", "Parent": { "href": "/device/1592" }, "SortOrder": 0, "ProgrammingType": "Freeform", "Buttons": [ { "href": "/button/1602", "ButtonNumber": 1, "ProgrammingModel": { "href": "/programmingmodel/1603", "ProgrammingModelType": "AdvancedToggleProgrammingModel" }, "Parent": { "href": "/buttongroup/1601" }, "Name": "Button 1", "Engraving": { "Text": "Living Room" }, "AssociatedLED": { "href": "/led/1597" } }, { "href": "/button/1606", "ButtonNumber": 2, "ProgrammingModel": { "href": "/programmingmodel/1863", "ProgrammingModelType": "OpenStopCloseStopProgrammingModel" }, "Parent": { "href": "/buttongroup/1601" }, "Name": "Button 2", "Engraving": { "Text": "Shades" }, "AssociatedLED": { "href": "/led/1598" } }, { "href": "/button/1610", "ButtonNumber": 3, "ProgrammingModel": { "href": "/programmingmodel/1866", "ProgrammingModelType": "SingleActionProgrammingModel" }, "Parent": { "href": "/buttongroup/1601" }, "Name": "Button 3", "Engraving": { "Text": "Entertain" }, "AssociatedLED": { "href": "/led/1599" } }, { "href": "/button/1614", "ButtonNumber": 4, "ProgrammingModel": { "href": "/programmingmodel/1869", "ProgrammingModelType": "SingleActionProgrammingModel" }, "Parent": { "href": "/buttongroup/1601" }, "Name": "Button 4", "Engraving": { "Text": "Relax" }, "AssociatedLED": { "href": "/led/1600" } } ] } ] } } pylutron-caseta-0.24.0/tests/responses/hwqsx/device/1592/device.json000066400000000000000000000013751476343602200252720ustar00rootroot00000000000000{ "Header": { "StatusCode": "200 OK", "Url": "/device/1592", "MessageBodyType": "OneDeviceDefinition" }, "CommuniqueType": "ReadResponse", "Body": { "Device": { "Name": "Device 1", "DeviceType": "PalladiomKeypad", "AssociatedArea": { "href": "/area/1578" }, "AssociatedControlStation": { "href": "/controlstation/1590", "Name": "Entryway" }, "href": "/device/1592", "Parent": { "href": "/project" }, "ModelNumber": "HQWT-U-P4W", "LinkNodes": [ { "href": "/device/1592/linknode/1594" } ], "DeviceClass": { "HexadecimalEncoding": "1210101" }, "AddressedState": "Unaddressed" } } } pylutron-caseta-0.24.0/tests/responses/hwqsx/device/1626/000077500000000000000000000000001476343602200231305ustar00rootroot00000000000000pylutron-caseta-0.24.0/tests/responses/hwqsx/device/1626/buttongroup.json000066400000000000000000000050001476343602200264060ustar00rootroot00000000000000{ "Header": { "StatusCode": "200 OK", "Url": "/device/1626/buttongroup/expanded", "MessageBodyType": "MultipleButtonGroupExpandedDefinition" }, "CommuniqueType": "ReadResponse", "Body": { "ButtonGroupsExpanded": [ { "href": "/buttongroup/1635", "Parent": { "href": "/device/1626" }, "SortOrder": 0, "ProgrammingType": "Freeform", "Buttons": [ { "href": "/button/1636", "ButtonNumber": 1, "ProgrammingModel": { "href": "/programmingmodel/1637", "ProgrammingModelType": "AdvancedToggleProgrammingModel" }, "Parent": { "href": "/buttongroup/1635" }, "Name": "Button 1", "Engraving": { "Text": "Bedroom" }, "AssociatedLED": { "href": "/led/1631" } }, { "href": "/button/1640", "ButtonNumber": 2, "ProgrammingModel": { "href": "/programmingmodel/1904", "ProgrammingModelType": "OpenStopCloseStopProgrammingModel" }, "Parent": { "href": "/buttongroup/1635" }, "Name": "Button 2", "Engraving": { "Text": "Shades" }, "AssociatedLED": { "href": "/led/1632" } }, { "href": "/button/1644", "ButtonNumber": 3, "ProgrammingModel": { "href": "/programmingmodel/1896", "ProgrammingModelType": "SingleActionProgrammingModel" }, "Parent": { "href": "/buttongroup/1635" }, "Name": "Button 3", "Engraving": { "Text": "Bright" }, "AssociatedLED": { "href": "/led/1633" } }, { "href": "/button/1648", "ButtonNumber": 4, "ProgrammingModel": { "href": "/programmingmodel/1900", "ProgrammingModelType": "SingleActionProgrammingModel" }, "Parent": { "href": "/buttongroup/1635" }, "Name": "Button 4", "Engraving": { "Text": "Dimmed" }, "AssociatedLED": { "href": "/led/1634" } } ] } ] } } pylutron-caseta-0.24.0/tests/responses/hwqsx/device/1626/device.json000066400000000000000000000013751476343602200252700ustar00rootroot00000000000000{ "Header": { "StatusCode": "200 OK", "Url": "/device/1626", "MessageBodyType": "OneDeviceDefinition" }, "CommuniqueType": "ReadResponse", "Body": { "Device": { "Name": "Device 1", "DeviceType": "PalladiomKeypad", "AssociatedArea": { "href": "/area/1020" }, "AssociatedControlStation": { "href": "/controlstation/1624", "Name": "Entryway" }, "href": "/device/1626", "Parent": { "href": "/project" }, "ModelNumber": "HQWT-U-P4W", "LinkNodes": [ { "href": "/device/1626/linknode/1628" } ], "DeviceClass": { "HexadecimalEncoding": "1210101" }, "AddressedState": "Unaddressed" } } } pylutron-caseta-0.24.0/tests/responses/hwqsx/device/1660/000077500000000000000000000000001476343602200231265ustar00rootroot00000000000000pylutron-caseta-0.24.0/tests/responses/hwqsx/device/1660/buttongroup.json000066400000000000000000000047731476343602200264240ustar00rootroot00000000000000{ "Header": { "StatusCode": "200 OK", "Url": "/device/1660/buttongroup/expanded", "MessageBodyType": "MultipleButtonGroupExpandedDefinition" }, "CommuniqueType": "ReadResponse", "Body": { "ButtonGroupsExpanded": [ { "href": "/buttongroup/1669", "Parent": { "href": "/device/1660" }, "SortOrder": 0, "ProgrammingType": "Freeform", "Buttons": [ { "href": "/button/1670", "ButtonNumber": 1, "ProgrammingModel": { "href": "/programmingmodel/1671", "ProgrammingModelType": "AdvancedToggleProgrammingModel" }, "Parent": { "href": "/buttongroup/1669" }, "Name": "Button 1", "Engraving": { "Text": "Bathroom" }, "AssociatedLED": { "href": "/led/1665" } }, { "href": "/button/1674", "ButtonNumber": 2, "ProgrammingModel": { "href": "/programmingmodel/1675", "ProgrammingModelType": "AdvancedToggleProgrammingModel" }, "Parent": { "href": "/buttongroup/1669" }, "Name": "Button 2", "Engraving": { "Text": "Fan" }, "AssociatedLED": { "href": "/led/1666" } }, { "href": "/button/1678", "ButtonNumber": 3, "ProgrammingModel": { "href": "/programmingmodel/1928", "ProgrammingModelType": "SingleActionProgrammingModel" }, "Parent": { "href": "/buttongroup/1669" }, "Name": "Button 3", "Engraving": { "Text": "Bright" }, "AssociatedLED": { "href": "/led/1667" } }, { "href": "/button/1682", "ButtonNumber": 4, "ProgrammingModel": { "href": "/programmingmodel/1934", "ProgrammingModelType": "SingleActionProgrammingModel" }, "Parent": { "href": "/buttongroup/1669" }, "Name": "Button 4", "Engraving": { "Text": "Dimmed" }, "AssociatedLED": { "href": "/led/1668" } } ] } ] } } pylutron-caseta-0.24.0/tests/responses/hwqsx/device/1660/device.json000066400000000000000000000013751476343602200252660ustar00rootroot00000000000000{ "Header": { "StatusCode": "200 OK", "Url": "/device/1660", "MessageBodyType": "OneDeviceDefinition" }, "CommuniqueType": "ReadResponse", "Body": { "Device": { "Name": "Device 1", "DeviceType": "PalladiomKeypad", "AssociatedArea": { "href": "/area/1032" }, "AssociatedControlStation": { "href": "/controlstation/1658", "Name": "Entryway" }, "href": "/device/1660", "Parent": { "href": "/project" }, "ModelNumber": "HQWT-U-P4W", "LinkNodes": [ { "href": "/device/1660/linknode/1662" } ], "DeviceClass": { "HexadecimalEncoding": "1210101" }, "AddressedState": "Unaddressed" } } } pylutron-caseta-0.24.0/tests/responses/hwqsx/device/851/000077500000000000000000000000001476343602200230475ustar00rootroot00000000000000pylutron-caseta-0.24.0/tests/responses/hwqsx/device/851/buttongroup.json000066400000000000000000000047571476343602200263470ustar00rootroot00000000000000{ "Header": { "StatusCode": "200 OK", "Url": "/device/851/buttongroup/expanded", "MessageBodyType": "MultipleButtonGroupExpandedDefinition" }, "CommuniqueType": "ReadResponse", "Body": { "ButtonGroupsExpanded": [ { "href": "/buttongroup/860", "Parent": { "href": "/device/851" }, "SortOrder": 0, "ProgrammingType": "Freeform", "Buttons": [ { "href": "/button/861", "ButtonNumber": 1, "ProgrammingModel": { "href": "/programmingmodel/862", "ProgrammingModelType": "AdvancedToggleProgrammingModel" }, "Parent": { "href": "/buttongroup/860" }, "Name": "Button 1", "Engraving": { "Text": "Foyer" }, "AssociatedLED": { "href": "/led/856" } }, { "href": "/button/865", "ButtonNumber": 2, "ProgrammingModel": { "href": "/programmingmodel/1876", "ProgrammingModelType": "OpenStopCloseStopProgrammingModel" }, "Parent": { "href": "/buttongroup/860" }, "Name": "Button 2", "Engraving": { "Text": "Shades" }, "AssociatedLED": { "href": "/led/857" } }, { "href": "/button/869", "ButtonNumber": 3, "ProgrammingModel": { "href": "/programmingmodel/929", "ProgrammingModelType": "SingleActionProgrammingModel" }, "Parent": { "href": "/buttongroup/860" }, "Name": "Button 3", "Engraving": { "Text": "Entertain" }, "AssociatedLED": { "href": "/led/858" } }, { "href": "/button/873", "ButtonNumber": 4, "ProgrammingModel": { "href": "/programmingmodel/926", "ProgrammingModelType": "SingleActionProgrammingModel" }, "Parent": { "href": "/buttongroup/860" }, "Name": "Button 4", "Engraving": { "Text": "Dimmed" }, "AssociatedLED": { "href": "/led/859" } } ] } ] } } pylutron-caseta-0.24.0/tests/responses/hwqsx/device/851/device.json000066400000000000000000000013711476343602200252030ustar00rootroot00000000000000{ "Header": { "StatusCode": "200 OK", "Url": "/device/851", "MessageBodyType": "OneDeviceDefinition" }, "CommuniqueType": "ReadResponse", "Body": { "Device": { "Name": "Keypad 1", "DeviceType": "PalladiomKeypad", "AssociatedArea": { "href": "/area/804" }, "AssociatedControlStation": { "href": "/controlstation/849", "Name": "Front Door" }, "href": "/device/851", "Parent": { "href": "/project" }, "ModelNumber": "HQWT-U-P4W", "LinkNodes": [ { "href": "/device/851/linknode/853" } ], "DeviceClass": { "HexadecimalEncoding": "1210101" }, "AddressedState": "Unaddressed" } } } pylutron-caseta-0.24.0/tests/responses/hwqsx/devices.json000066400000000000000000000001771476343602200236150ustar00rootroot00000000000000{ "CommuniqueType": "ReadResponse", "Header": { "StatusCode": "204 NoContent", "Url": "/device" } }pylutron-caseta-0.24.0/tests/responses/hwqsx/ledsubscribe.json000066400000000000000000000004641476343602200246400ustar00rootroot00000000000000{ "Header": { "StatusCode": "200 OK", "Url": "/led/1631/status", "MessageBodyType": "OneLEDStatus" }, "CommuniqueType": "SubscribeResponse", "Body": { "LEDStatus": { "href": "/led/1631/status", "LED": { "href": "/led/1631" }, "State": "On" } } } pylutron-caseta-0.24.0/tests/responses/hwqsx/processor.json000066400000000000000000000031201476343602200242010ustar00rootroot00000000000000{ "Header": { "StatusCode": "200 OK", "Url": "/device?where=IsThisDevice:true", "MessageBodyType": "MultipleDeviceDefinition" }, "CommuniqueType": "ReadResponse", "Body": { "Devices": [ { "Name": "Enclosure Device 001", "DeviceType": "HWQSProcessor", "AssociatedArea": { "href": "/area/32" }, "href": "/device/799", "SerialNumber": 12345678, "NetworkInterfaces": [ { "MACAddress": "aa:bb:cc:dd:ee:ff" } ], "Parent": { "href": "/project" }, "ModelNumber": "HQP7-1", "OwnedLinks": [ { "href": "/link/835", "LinkType": "QS" } ], "LinkNodes": [ { "href": "/device/799/linknode/800" } ], "FirmwareImage": { "Firmware": { "DisplayName": "22.02.17f000" }, "Installed": { "Year": 2022, "Month": 7, "Day": 1, "Hour": 15, "Minute": 55, "Second": 38, "Utc": "-7:00:00" } }, "DeviceFirmwarePackage": { "Package": { "DisplayName": "002.002.003r000" } }, "Databases": [ { "href": "/database/@Project", "Type": "Project" } ], "DeviceClass": { "HexadecimalEncoding": "8110201" }, "AddressedState": "Addressed", "IsThisDevice": true } ] } } pylutron-caseta-0.24.0/tests/responses/hwqsx/project.json000066400000000000000000000015101476343602200236310ustar00rootroot00000000000000{ "Header": { "StatusCode": "200 OK", "Url": "/project", "MessageBodyType": "OneProjectDefinition" }, "CommuniqueType": "ReadResponse", "Body": { "Project": { "href": "/project", "Name": "LEAP Testing", "ProductType": "Lutron HWQS Project", "MasterDeviceList": { "Devices": [ { "href": "/device/799" }, { "href": "/device/982" } ] }, "Contacts": [ { "href": "/contactinfo/89" } ], "TimeclockEventRules": { "href": "/project/timeclockeventrules" }, "ProjectModifiedTimestamp": { "Year": 2022, "Month": 8, "Day": 13, "Hour": 22, "Minute": 58, "Second": 16, "Utc": "0" } } } } pylutron-caseta-0.24.0/tests/responses/hwqsx/zonestatus.json000066400000000000000000000045101476343602200244050ustar00rootroot00000000000000{ "ZoneStatuses": [ { "href": "/zone/816/status", "Level": 0, "Zone": { "href": "/zone/816" }, "StatusAccuracy": "Good" }, { "href": "/zone/838/status", "Level": 0, "Zone": { "href": "/zone/838" }, "StatusAccuracy": "Good" }, { "href": "/zone/1150/status", "Level": 75, "Zone": { "href": "/zone/1150" }, "StatusAccuracy": "Good" }, { "href": "/zone/1387/status", "Level": 100, "SwitchedLevel": "On", "Zone": { "href": "/zone/1387" }, "StatusAccuracy": "Good" }, { "href": "/zone/1735/status", "Level": 50, "Zone": { "href": "/zone/1735" }, "StatusAccuracy": "Good" }, { "href": "/zone/1744/status", "Level": 25, "Zone": { "href": "/zone/1744" }, "StatusAccuracy": "Good" }, { "href": "/zone/1313/status", "Level": 50, "Zone": { "href": "/zone/1313" }, "StatusAccuracy": "Good" }, { "href": "/zone/1362/status", "Level": 0, "Zone": { "href": "/zone/1362" }, "StatusAccuracy": "Good" }, { "href": "/zone/1786/status", "Level": 0, "Zone": { "href": "/zone/1786" }, "StatusAccuracy": "Bad" }, { "href": "/zone/985/status", "Level": 0, "Vibrancy": 25, "ColorTuningStatus": { "XYTuningLevel": { "X": 0.5745, "Y": 0.3984 }, "HSVTuningLevel": { "Hue": 32, "Saturation": 81 }, "CurveDimming": { "Curve": { "href": "/curve/1" } } }, "Zone": { "href": "/zone/985" }, "StatusAccuracy": "Good" }, { "href": "/zone/1692/status", "Level": 50, "Vibrancy": 25, "ColorTuningStatus": { "XYTuningLevel": { "X": 0.482, "Y": 0.4152 }, "HSVTuningLevel": { "Hue": 39, "Saturation": 56 }, "CurveDimming": { "Curve": { "href": "/curve/1" } } }, "Zone": { "href": "/zone/1692" }, "StatusAccuracy": "Good" } ] } pylutron-caseta-0.24.0/tests/responses/lip.json000066400000000000000000000011301476343602200215730ustar00rootroot00000000000000{ "CommuniqueType": "ReadResponse", "Header": { "MessageBodyType": "OneLIPIdListDefinition", "StatusCode": "200 OK", "Url": "/server/2/id" }, "Body": { "LIPIdList": { "Devices": [{ "Name": "Pico", "ID": 33, "Area": { "Name": "Kitchen" }, "Buttons": [{ "Number": 2 }, { "Number": 3 }, { "Number": 4 }, { "Number": 5 }, { "Number": 6 }] }, { "Name": "Left Pico", "ID": 36, "Area": { "Name": "Master Bedroom" }, "Buttons": [{ "Number": 2 }, { "Number": 4 }] }] } } }pylutron-caseta-0.24.0/tests/responses/occupancygroups.json000066400000000000000000000042141476343602200242410ustar00rootroot00000000000000{ "CommuniqueType": "ReadResponse", "Header": { "MessageBodyType": "MultipleOccupancyGroupDefinition", "StatusCode": "200 OK", "Url": "/occupancygroup" }, "Body": { "OccupancyGroups": [ { "href": "/occupancygroup/1", "AssociatedAreas": [ { "Area": { "href": "/area/2" } } ], "ProgrammingType": "Freeform", "ProgrammingModel": { "href": "/programmingmodel/193" } }, { "href": "/occupancygroup/2", "AssociatedSensors": [ { "OccupancySensor": { "href": "/occupancysensor/1" } } ], "AssociatedAreas": [ { "Area": { "href": "/area/3" } } ], "ProgrammingType": "Freeform", "ProgrammingModel": { "href": "/programmingmodel/194" } }, { "href": "/occupancygroup/3", "AssociatedSensors": [ { "OccupancySensor": { "href": "/occupancysensor/2" } }, { "OccupancySensor": { "href": "/occupancysensor/3" } } ], "AssociatedAreas": [ { "Area": { "href": "/area/4" } } ], "ProgrammingType": "Freeform", "ProgrammingModel": { "href": "/programmingmodel/195" } } ] } }pylutron-caseta-0.24.0/tests/responses/occupancygroupsubscribe.json000066400000000000000000000016741476343602200257670ustar00rootroot00000000000000{ "CommuniqueType": "SubscribeResponse", "Header": { "MessageBodyType": "MultipleOccupancyGroupStatus", "StatusCode": "200 OK", "Url": "/occupancygroup/status" }, "Body": { "OccupancyGroupStatuses": [ { "href": "/occupancygroup/1/status", "OccupancyGroup": { "href": "/occupancygroup/1" }, "OccupancyStatus": "Unknown" }, { "href": "/occupancygroup/2/status", "OccupancyGroup": { "href": "/occupancygroup/2" }, "OccupancyStatus": "Occupied" }, { "href": "/occupancygroup/3/status", "OccupancyGroup": { "href": "/occupancygroup/3" }, "OccupancyStatus": "Unoccupied" } ] } }pylutron-caseta-0.24.0/tests/responses/project.json000077500000000000000000000014441476343602200224700ustar00rootroot00000000000000{ "CommuniqueType": "ReadResponse", "Header": { "MessageBodyType": "OneProjectDefinition", "StatusCode": "200 OK", "Url": "/project" }, "Body": { "Project": { "href": "/project", "Name": "Smart Bridge Project", "ProductType": "Lutron Smart Bridge Project", "MasterDeviceList": { "Devices": [ { "href": "/device/1" } ] }, "GUID": "0000000000000000000000000000000000000000", "TimeclockEventRules": { "href": "/project/timeclockeventrules" }, "DeviceRules": { "href": "/project/devicerule/1" } } } }pylutron-caseta-0.24.0/tests/responses/ra3/000077500000000000000000000000001476343602200206065ustar00rootroot00000000000000pylutron-caseta-0.24.0/tests/responses/ra3/area/000077500000000000000000000000001476343602200215165ustar00rootroot00000000000000pylutron-caseta-0.24.0/tests/responses/ra3/area/2796/000077500000000000000000000000001476343602200221255ustar00rootroot00000000000000pylutron-caseta-0.24.0/tests/responses/ra3/area/2796/associatedzone.json000066400000000000000000000011611476343602200260320ustar00rootroot00000000000000{ "CommuniqueType": "ReadResponse", "Header": { "MessageBodyType": "MultipleZoneDefinition", "StatusCode": "200 OK", "Url": "/area/2796/associatedzone" }, "Body": { "Zones": [ { "href": "/zone/2010", "Name": "Porch", "ControlType": "Dimmed", "Category": { "Type": "", "IsLight": true }, "AssociatedArea": { "href": "/area/2796" }, "SortOrder": 0 } ] } }pylutron-caseta-0.24.0/tests/responses/ra3/area/2796/controlstation.json000066400000000000000000000002331476343602200261000ustar00rootroot00000000000000{ "CommuniqueType": "ReadResponse", "Header": { "StatusCode": "204 NoContent", "Url": "/area/2796/associatedcontrolstation" } }pylutron-caseta-0.24.0/tests/responses/ra3/area/2796/ra3-area2796.json000066400000000000000000000007401476343602200247440ustar00rootroot00000000000000{ "CommuniqueType": "ReadResponse", "Header": { "MessageBodyType": "OneAreaDefinition", "StatusCode": "200 OK", "Url": "/area/2796" }, "Body": { "Area": { "href": "/area/2796", "Name": "Porch", "Parent": { "href": "/area/3" }, "AssociatedZones": [ { "href": "/zone/2010" } ] } } }pylutron-caseta-0.24.0/tests/responses/ra3/area/3/000077500000000000000000000000001476343602200216605ustar00rootroot00000000000000pylutron-caseta-0.24.0/tests/responses/ra3/area/3/associatedzone.json000077500000000000000000000002161476343602200255700ustar00rootroot00000000000000{ "CommuniqueType": "ReadResponse", "Header": { "StatusCode": "204 NoContent", "Url": "/area/3/associatedzone" } }pylutron-caseta-0.24.0/tests/responses/ra3/area/3/controlstation.json000077500000000000000000000002301476343602200256330ustar00rootroot00000000000000{ "CommuniqueType": "ReadResponse", "Header": { "StatusCode": "204 NoContent", "Url": "/area/3/associatedcontrolstation" } }pylutron-caseta-0.24.0/tests/responses/ra3/area/3/ra3-area3.json000077500000000000000000000004621476343602200242360ustar00rootroot00000000000000{ "CommuniqueType": "ReadResponse", "Header": { "MessageBodyType": "OneAreaDefinition", "StatusCode": "200 OK", "Url": "/area/3" }, "Body": { "Area": { "href": "/area/3", "Name": "Home", "IsLeaf": "False" } } }pylutron-caseta-0.24.0/tests/responses/ra3/area/547/000077500000000000000000000000001476343602200220355ustar00rootroot00000000000000pylutron-caseta-0.24.0/tests/responses/ra3/area/547/associatedzone.json000066400000000000000000000026121476343602200257440ustar00rootroot00000000000000{ "CommuniqueType": "ReadResponse", "Header": { "MessageBodyType": "MultipleZoneDefinition", "StatusCode": "200 OK", "Url": "/area/547/associatedzone" }, "Body": { "Zones": [ { "href": "/zone/1361", "Name": "Vanities", "ControlType": "Dimmed", "Category": { "Type": "", "IsLight": true }, "AssociatedArea": { "href": "/area/547" }, "SortOrder": 0 }, { "href": "/zone/1377", "Name": "Shower \u0026 Tub", "ControlType": "Dimmed", "Category": { "Type": "", "IsLight": true }, "AssociatedArea": { "href": "/area/547" }, "SortOrder": 1 }, { "href": "/zone/1393", "Name": "Vent", "ControlType": "Switched", "Category": { "Type": "ExhaustFan", "IsLight": false }, "AssociatedArea": { "href": "/area/547" }, "SortOrder": 2 } ] } }pylutron-caseta-0.24.0/tests/responses/ra3/area/547/controlstation.json000066400000000000000000000046711476343602200260220ustar00rootroot00000000000000{ "CommuniqueType": "ReadResponse", "Header": { "MessageBodyType": "MultipleControlStationDefinition", "StatusCode": "200 OK", "Url": "/area/547/associatedcontrolstation" }, "Body": { "ControlStations": [ { "href": "/controlstation/1352", "Name": "Entry", "AssociatedArea": { "href": "/area/547" }, "SortOrder": 0, "AssociatedGangedDevices": [ { "Device": { "href": "/device/1354", "DeviceType": "SunnataDimmer", "AddressedState": "Unaddressed" }, "GangPosition": 0 }, { "Device": { "href": "/device/1370", "DeviceType": "SunnataDimmer", "AddressedState": "Unaddressed" }, "GangPosition": 1 }, { "Device": { "href": "/device/1386", "DeviceType": "SunnataSwitch", "AddressedState": "Unaddressed" }, "GangPosition": 2 }, { "Device": { "href": "/device/1488", "DeviceType": "Pico3ButtonRaiseLower", "AddressedState": "Unaddressed" }, "GangPosition": 3 } ] }, { "href": "/controlstation/2937", "Name": "Vanity", "AssociatedArea": { "href": "/area/547" }, "SortOrder": 1, "AssociatedGangedDevices": [ { "Device": { "href": "/device/2939", "DeviceType": "Pico3ButtonRaiseLower", "AddressedState": "Unaddressed" }, "GangPosition": 0 } ] } ] } }pylutron-caseta-0.24.0/tests/responses/ra3/area/547/ra3-area547.json000066400000000000000000000015521476343602200245660ustar00rootroot00000000000000{ "CommuniqueType": "ReadResponse", "Header": { "MessageBodyType": "OneAreaDefinition", "StatusCode": "200 OK", "Url": "/area/547" }, "Body": { "Area": { "href": "/area/547", "Name": "Primary Bath", "Parent": { "href": "/area/3" }, "AssociatedZones": [ { "href": "/zone/1361" }, { "href": "/zone/1377" }, { "href": "/zone/1393" } ], "AssociatedControlStations": [ { "href": "/controlstation/1352" }, { "href": "/controlstation/2937" } ] } } }pylutron-caseta-0.24.0/tests/responses/ra3/area/766/000077500000000000000000000000001476343602200220405ustar00rootroot00000000000000pylutron-caseta-0.24.0/tests/responses/ra3/area/766/associatedzone.json000066400000000000000000000017661476343602200257600ustar00rootroot00000000000000{ "CommuniqueType": "ReadResponse", "Header": { "MessageBodyType": "MultipleZoneDefinition", "StatusCode": "200 OK", "Url": "/area/766/associatedzone" }, "Body": { "Zones": [ { "href": "/zone/2091", "Name": "Overhead", "ControlType": "Dimmed", "Category": { "Type": "", "IsLight": true }, "AssociatedArea": { "href": "/area/766" }, "SortOrder": 0 }, { "href": "/zone/2107", "Name": "Landscape", "ControlType": "Dimmed", "Category": { "Type": "", "IsLight": true }, "AssociatedArea": { "href": "/area/766" }, "SortOrder": 1 } ] } }pylutron-caseta-0.24.0/tests/responses/ra3/area/766/controlstation.json000066400000000000000000000054001476343602200260140ustar00rootroot00000000000000{ "CommuniqueType": "ReadResponse", "Header": { "MessageBodyType": "MultipleControlStationDefinition", "StatusCode": "200 OK", "Url": "/area/766/associatedcontrolstation" }, "Body": { "ControlStations": [ { "href": "/controlstation/2001", "Name": "Entry by Door", "AssociatedArea": { "href": "/area/766" }, "SortOrder": 0, "AssociatedGangedDevices": [ { "Device": { "href": "/device/2003", "DeviceType": "SunnataDimmer", "AddressedState": "Unaddressed" }, "GangPosition": 1 }, { "Device": { "href": "/device/2084", "DeviceType": "SunnataDimmer", "AddressedState": "Unaddressed" }, "GangPosition": 2 }, { "Device": { "href": "/device/2100", "DeviceType": "SunnataDimmer", "AddressedState": "Unaddressed" }, "GangPosition": 0 } ] }, { "href": "/controlstation/2132", "Name": "Entry by Living Room", "AssociatedArea": { "href": "/area/766" }, "SortOrder": 1, "AssociatedGangedDevices": [ { "Device": { "href": "/device/2139", "DeviceType": "SunnataKeypad", "AddressedState": "Unaddressed" }, "GangPosition": 1 }, { "Device": { "href": "/device/2171", "DeviceType": "SunnataKeypad", "AddressedState": "Unaddressed" }, "GangPosition": 2 }, { "Device": { "href": "/device/2203", "DeviceType": "SunnataDimmer", "AddressedState": "Unaddressed" }, "GangPosition": 3 } ] } ] } }pylutron-caseta-0.24.0/tests/responses/ra3/area/766/ra3-area766.json000066400000000000000000000014251476343602200245730ustar00rootroot00000000000000{ "CommuniqueType": "ReadResponse", "Header": { "MessageBodyType": "OneAreaDefinition", "StatusCode": "200 OK", "Url": "/area/766" }, "Body": { "Area": { "href": "/area/766", "Name": "Entry", "Parent": { "href": "/area/3" }, "AssociatedZones": [ { "href": "/zone/2091" }, { "href": "/zone/2107" } ], "AssociatedControlStations": [ { "href": "/controlstation/2001" }, { "href": "/controlstation/2132" } ] } } }pylutron-caseta-0.24.0/tests/responses/ra3/area/83/000077500000000000000000000000001476343602200217505ustar00rootroot00000000000000pylutron-caseta-0.24.0/tests/responses/ra3/area/83/associatedzone.json000066400000000000000000000011751476343602200256620ustar00rootroot00000000000000{ "CommuniqueType": "ReadResponse", "Header": { "MessageBodyType": "MultipleZoneDefinition", "StatusCode": "200 OK", "Url": "/area/83/associatedzone" }, "Body": { "Zones": [ { "href": "/zone/536", "Name": "Overhead", "ControlType": "Switched", "Category": { "Type": "OtherAmbient", "IsLight": true }, "AssociatedArea": { "href": "/area/83" }, "SortOrder": 0 } ] } }pylutron-caseta-0.24.0/tests/responses/ra3/area/83/controlstation.json000066400000000000000000000027541476343602200257350ustar00rootroot00000000000000{ "CommuniqueType": "ReadResponse", "Header": { "MessageBodyType": "MultipleControlStationDefinition", "StatusCode": "200 OK", "Url": "/area/83/associatedcontrolstation" }, "Body": { "ControlStations": [ { "href": "/controlstation/527", "Name": "Entry", "AssociatedArea": { "href": "/area/83" }, "SortOrder": 0, "AssociatedGangedDevices": [ { "Device": { "href": "/device/529", "DeviceType": "SunnataSwitch", "AddressedState": "Unaddressed" }, "GangPosition": 0 } ] }, { "href": "/controlstation/5339", "Name": "TestingPico", "AssociatedArea": { "href": "/area/83" }, "SortOrder": 1, "AssociatedGangedDevices": [ { "Device": { "href": "/device/5341", "DeviceType": "Pico3ButtonRaiseLower", "AddressedState": "Addressed" }, "GangPosition": 0 } ] } ] } }pylutron-caseta-0.24.0/tests/responses/ra3/area/83/ra3-area83.json000066400000000000000000000011641476343602200244130ustar00rootroot00000000000000{ "CommuniqueType": "ReadResponse", "Header": { "MessageBodyType": "OneAreaDefinition", "StatusCode": "200 OK", "Url": "/area/83" }, "Body": { "Area": { "href": "/area/83", "Name": "Equipment Room", "Parent": { "href": "/area/3" }, "AssociatedZones": [ { "href": "/zone/536" } ], "AssociatedControlStations": [ { "href": "/controlstation/527" } ] } } }pylutron-caseta-0.24.0/tests/responses/ra3/area/status-subscribe.json000077500000000000000000000023411476343602200257160ustar00rootroot00000000000000{ "CommuniqueType" : "SubscribeResponse", "Header" : { "MessageBodyType" : "MultipleAreaStatus", "StatusCode" : "200 OK", "Url" : "/area/status" } , "Body" : { "AreaStatuses" : [ { "href" : "/area/3/status", "OccupancyStatus" : "Unknown", "CurrentScene" : null } , { "href" : "/area/83/status", "OccupancyStatus" : "Unknown", "CurrentScene" : null } , { "href" : "/area/547/status", "Level" : 100, "OccupancyStatus" : "Unoccupied", "CurrentScene" : { "href" : "/areascene/827" } } , { "href" : "/area/766/status", "OccupancyStatus" : "Unknown", "CurrentScene" : null } , { "href" : "/area/2796/status", "Level" : 48, "OccupancyStatus" : "Unknown", "CurrentScene" : null } ] } }pylutron-caseta-0.24.0/tests/responses/ra3/area/status.json000077500000000000000000000017771476343602200237530ustar00rootroot00000000000000{ "CommuniqueType":"ReadResponse", "Header":{ "MessageBodyType":"MultipleAreaStatus", "StatusCode":"200 OK", "Url":"/area/status" }, "Body":{ "AreaStatuses":[ { "href":"/area/3/status", "OccupancyStatus":"Unknown", "CurrentScene":null }, { "href":"/area/83/status", "OccupancyStatus":"Unknown", "CurrentScene":null }, { "href":"/area/547/status", "Level":100, "OccupancyStatus":"Unoccupied", "CurrentScene":{ "href":"/areascene/827" } }, { "href":"/area/766/status", "OccupancyStatus":"Unknown", "CurrentScene":null }, { "href":"/area/2796/status", "Level":48, "OccupancyStatus":"Unknown", "CurrentScene":null } ] } }pylutron-caseta-0.24.0/tests/responses/ra3/areas.json000066400000000000000000000025521476343602200226000ustar00rootroot00000000000000{ "CommuniqueType": "ReadResponse", "Header": { "MessageBodyType": "MultipleAreaDefinition", "StatusCode": "200 OK", "Url": "/area" }, "Body": { "Areas": [ { "href": "/area/3", "Name": "Home", "SortOrder": 0, "IsLeaf": false }, { "href": "/area/83", "Name": "Equipment Room", "Parent": { "href": "/area/3" }, "SortOrder": 0, "IsLeaf": true }, { "href": "/area/547", "Name": "Primary Bath", "Parent": { "href": "/area/3" }, "SortOrder": 2, "IsLeaf": true }, { "href": "/area/766", "Name": "Entry", "Parent": { "href": "/area/3" }, "SortOrder": 15, "IsLeaf": true }, { "href": "/area/2796", "Name": "Porch", "Parent": { "href": "/area/3" }, "SortOrder": 28, "IsLeaf": true } ] } }pylutron-caseta-0.24.0/tests/responses/ra3/device-list.json000077500000000000000000000061201476343602200237130ustar00rootroot00000000000000{ "CommuniqueType":"ReadResponse", "Header":{ "MessageBodyType":"MultipleDeviceDefinition", "StatusCode":"200 OK", "Url":"/device?where=IsThisDevice:false" }, "Body":{ "Devices":[ { "Name":"Position 1", "DeviceType":"RPSOccupancySensor", "AssociatedArea":{ "href":"/area/766" }, "AssociatedControlStation":{ "href":"/controlstation/1868" }, "href":"/device/1870", "SerialNumber":52679166, "Parent":{ "href":"/project" }, "ModelNumber":"LRF2-OKLB-P", "LinkNodes":[ { "href":"/device/1870/linknode/1872" } ], "DeviceClass":{ "HexadecimalEncoding":"6080101" }, "AddressedState":"Addressed" }, { "Name":"Position 2", "DeviceType":"RPSOccupancySensor", "AssociatedArea":{ "href":"/area/766" }, "AssociatedControlStation":{ "href":"/controlstation/1868" }, "href":"/device/1888", "SerialNumber":52671036, "Parent":{ "href":"/project" }, "ModelNumber":"LRF2-OKLB-P", "LinkNodes":[ { "href":"/device/1868/linknode/1870" } ], "DeviceClass":{ "HexadecimalEncoding":"6080101" }, "AddressedState":"Addressed" }, { "Name":"Position 3", "DeviceType":"RPSOccupancySensor", "AssociatedArea":{ "href":"/area/2796" }, "AssociatedControlStation":{ "href":"/controlstation/1868" }, "href":"/device/1970", "SerialNumber":52671033, "Parent":{ "href":"/project" }, "ModelNumber":"LRF2-OKLB-P", "LinkNodes":[ { "href":"/device/1970/linknode/1972" } ], "DeviceClass":{ "HexadecimalEncoding":"6080101" }, "AddressedState":"Addressed" }, { "href": "/device/2139", "Name": "Scene Keypad", "Parent": { "href": "/project" }, "ModelNumber": "RRST-W4B-XX", "DeviceType": "SunnataKeypad", "AssociatedArea": { "href": "/area/766" }, "LinkNodes": [ { "href": "/device/2139/linknode/2141" } ], "FirmwareImage": { "href": "/firmwareimage/2139" }, "DeviceClass": { "HexadecimalEncoding": "1270101" }, "AddressedState": "Unaddressed" } ] } }pylutron-caseta-0.24.0/tests/responses/ra3/device/000077500000000000000000000000001476343602200220455ustar00rootroot00000000000000pylutron-caseta-0.24.0/tests/responses/ra3/device/1488/000077500000000000000000000000001476343602200224515ustar00rootroot00000000000000pylutron-caseta-0.24.0/tests/responses/ra3/device/1488/buttongroup.json000066400000000000000000000073451476343602200257450ustar00rootroot00000000000000{ "CommuniqueType": "ReadResponse", "Header": { "MessageBodyType": "MultipleButtonGroupExpandedDefinition", "StatusCode": "200 OK", "Url": "/device/1488/buttongroup/expanded" }, "Body": { "ButtonGroupsExpanded": [ { "href": "/buttongroup/1491", "Parent": { "href": "/device/1488" }, "SortOrder": 0, "Category": { "Type": "Audio" }, "ProgrammingType": "Freeform", "Buttons": [ { "href": "/button/1492", "ButtonNumber": 0, "ProgrammingModel": { "href": "/programmingmodel/1493", "ProgrammingModelType": "SingleActionProgrammingModel" }, "Parent": { "href": "/buttongroup/1491" }, "Name": "Button 0", "Engraving": { "Text": "Play/Pause" } }, { "href": "/button/1495", "ButtonNumber": 1, "ProgrammingModel": { "href": "/programmingmodel/1496", "ProgrammingModelType": "SingleActionProgrammingModel" }, "Parent": { "href": "/buttongroup/1491" }, "Name": "Button 1", "Engraving": { "Text": "Favorite" } }, { "href": "/button/1498", "ButtonNumber": 2, "ProgrammingModel": { "href": "/programmingmodel/1499", "ProgrammingModelType": "SingleActionProgrammingModel" }, "Parent": { "href": "/buttongroup/1491" }, "Name": "Button 2", "Engraving": { "Text": "Next Track" } }, { "href": "/button/1501", "ButtonNumber": 3, "ProgrammingModel": { "href": "/programmingmodel/1502", "ProgrammingModelType": "SingleSceneRaiseProgrammingModel" }, "Parent": { "href": "/buttongroup/1491" }, "Name": "Button 3", "Engraving": { "Text": "Volume Up" } }, { "href": "/button/1504", "ButtonNumber": 4, "ProgrammingModel": { "href": "/programmingmodel/1505", "ProgrammingModelType": "SingleSceneLowerProgrammingModel" }, "Parent": { "href": "/buttongroup/1491" }, "Name": "Button 4", "Engraving": { "Text": "Volume Down" } } ] } ] } }pylutron-caseta-0.24.0/tests/responses/ra3/device/1488/device.json000066400000000000000000000014671476343602200246130ustar00rootroot00000000000000{ "CommuniqueType": "ReadResponse", "Header": { "MessageBodyType": "OneDeviceDefinition", "StatusCode": "200 OK", "Url": "/device/1488" }, "Body": { "Device": { "href": "/device/1488", "Name": "Audio Pico", "Parent": { "href": "/project" }, "ModelNumber": "PJ2-3BRL-XXX-A02", "DeviceType": "Pico3ButtonRaiseLower", "AssociatedArea": { "href": "/area/547" }, "LinkNodes": [ { "href": "/device/1488/linknode/1490" } ], "DeviceClass": { "HexadecimalEncoding": "1070101" }, "AddressedState": "Unaddressed" } } }pylutron-caseta-0.24.0/tests/responses/ra3/device/2139/000077500000000000000000000000001476343602200224435ustar00rootroot00000000000000pylutron-caseta-0.24.0/tests/responses/ra3/device/2139/buttongroup.json000066400000000000000000000067331476343602200257370ustar00rootroot00000000000000{ "CommuniqueType": "ReadResponse", "Header": { "MessageBodyType": "MultipleButtonGroupExpandedDefinition", "StatusCode": "200 OK", "Url": "/device/2139/buttongroup/expanded" }, "Body": { "ButtonGroupsExpanded": [ { "href": "/buttongroup/2148", "Parent": { "href": "/device/2139" }, "SortOrder": 0, "ProgrammingType": "Freeform", "Buttons": [ { "href": "/button/2149", "ButtonNumber": 1, "ProgrammingModel": { "href": "/programmingmodel/4398", "ProgrammingModelType": "SingleActionProgrammingModel" }, "Parent": { "href": "/buttongroup/2148" }, "Name": "Button 1", "Engraving": { "Text": "Bright" }, "AssociatedLED": { "href": "/led/2144" } }, { "href": "/button/2153", "ButtonNumber": 2, "ProgrammingModel": { "href": "/programmingmodel/4400", "ProgrammingModelType": "SingleActionProgrammingModel" }, "Parent": { "href": "/buttongroup/2148" }, "Name": "Button 2", "Engraving": { "Text": "Entertain" }, "AssociatedLED": { "href": "/led/2145" } }, { "href": "/button/2157", "ButtonNumber": 3, "ProgrammingModel": { "href": "/programmingmodel/4402", "ProgrammingModelType": "SingleActionProgrammingModel" }, "Parent": { "href": "/buttongroup/2148" }, "Name": "Button 3", "Engraving": { "Text": "Dining" }, "AssociatedLED": { "href": "/led/2146" } }, { "href": "/button/2161", "ButtonNumber": 4, "ProgrammingModel": { "href": "/programmingmodel/4404", "ProgrammingModelType": "SingleActionProgrammingModel" }, "Parent": { "href": "/buttongroup/2148" }, "Name": "Button 4", "Engraving": { "Text": "Off" }, "AssociatedLED": { "href": "/led/2147" } } ] } ] } }pylutron-caseta-0.24.0/tests/responses/ra3/device/2139/device.json000066400000000000000000000016101476343602200245730ustar00rootroot00000000000000{ "CommuniqueType": "ReadResponse", "Header": { "MessageBodyType": "OneDeviceDefinition", "StatusCode": "200 OK", "Url": "/device/2139" }, "Body": { "Device": { "href": "/device/2139", "Name": "Scene Keypad", "Parent": { "href": "/project" }, "ModelNumber": "RRST-W4B-XX", "DeviceType": "SunnataKeypad", "AssociatedArea": { "href": "/area/766" }, "LinkNodes": [ { "href": "/device/2139/linknode/2141" } ], "FirmwareImage": { "href": "/firmwareimage/2139" }, "DeviceClass": { "HexadecimalEncoding": "1270101" }, "AddressedState": "Unaddressed" } } }pylutron-caseta-0.24.0/tests/responses/ra3/device/2171/000077500000000000000000000000001476343602200224375ustar00rootroot00000000000000pylutron-caseta-0.24.0/tests/responses/ra3/device/2171/buttongroup.json000066400000000000000000000067271476343602200257360ustar00rootroot00000000000000{ "CommuniqueType": "ReadResponse", "Header": { "MessageBodyType": "MultipleButtonGroupExpandedDefinition", "StatusCode": "200 OK", "Url": "/device/2171/buttongroup/expanded" }, "Body": { "ButtonGroupsExpanded": [ { "href": "/buttongroup/2180", "Parent": { "href": "/device/2171" }, "SortOrder": 0, "ProgrammingType": "Freeform", "Buttons": [ { "href": "/button/2181", "ButtonNumber": 1, "ProgrammingModel": { "href": "/programmingmodel/4406", "ProgrammingModelType": "SingleActionProgrammingModel" }, "Parent": { "href": "/buttongroup/2180" }, "Name": "Button 1", "Engraving": { "Text": "Fan High" }, "AssociatedLED": { "href": "/led/2176" } }, { "href": "/button/2185", "ButtonNumber": 2, "ProgrammingModel": { "href": "/programmingmodel/4408", "ProgrammingModelType": "SingleActionProgrammingModel" }, "Parent": { "href": "/buttongroup/2180" }, "Name": "Button 2", "Engraving": { "Text": "Medium" }, "AssociatedLED": { "href": "/led/2177" } }, { "href": "/button/2189", "ButtonNumber": 3, "ProgrammingModel": { "href": "/programmingmodel/4410", "ProgrammingModelType": "SingleActionProgrammingModel" }, "Parent": { "href": "/buttongroup/2180" }, "Name": "Button 3", "Engraving": { "Text": "Low" }, "AssociatedLED": { "href": "/led/2178" } }, { "href": "/button/2193", "ButtonNumber": 4, "ProgrammingModel": { "href": "/programmingmodel/4412", "ProgrammingModelType": "SingleActionProgrammingModel" }, "Parent": { "href": "/buttongroup/2180" }, "Name": "Button 4", "Engraving": { "Text": "Off" }, "AssociatedLED": { "href": "/led/2179" } } ] } ] } }pylutron-caseta-0.24.0/tests/responses/ra3/device/2171/device.json000066400000000000000000000016061476343602200245740ustar00rootroot00000000000000{ "CommuniqueType": "ReadResponse", "Header": { "MessageBodyType": "OneDeviceDefinition", "StatusCode": "200 OK", "Url": "/device/2171" }, "Body": { "Device": { "href": "/device/2171", "Name": "Fan Keypad", "Parent": { "href": "/project" }, "ModelNumber": "RRST-W4B-XX", "DeviceType": "SunnataKeypad", "AssociatedArea": { "href": "/area/766" }, "LinkNodes": [ { "href": "/device/2171/linknode/2173" } ], "FirmwareImage": { "href": "/firmwareimage/2171" }, "DeviceClass": { "HexadecimalEncoding": "1270101" }, "AddressedState": "Unaddressed" } } }pylutron-caseta-0.24.0/tests/responses/ra3/device/2939/000077500000000000000000000000001476343602200224535ustar00rootroot00000000000000pylutron-caseta-0.24.0/tests/responses/ra3/device/2939/buttongroup.json000066400000000000000000000073451476343602200257470ustar00rootroot00000000000000{ "CommuniqueType": "ReadResponse", "Header": { "MessageBodyType": "MultipleButtonGroupExpandedDefinition", "StatusCode": "200 OK", "Url": "/device/2939/buttongroup/expanded" }, "Body": { "ButtonGroupsExpanded": [ { "href": "/buttongroup/2942", "Parent": { "href": "/device/2939" }, "SortOrder": 0, "Category": { "Type": "Audio" }, "ProgrammingType": "Freeform", "Buttons": [ { "href": "/button/2943", "ButtonNumber": 0, "ProgrammingModel": { "href": "/programmingmodel/2944", "ProgrammingModelType": "SingleActionProgrammingModel" }, "Parent": { "href": "/buttongroup/2942" }, "Name": "Button 0", "Engraving": { "Text": "Play/Pause" } }, { "href": "/button/2946", "ButtonNumber": 1, "ProgrammingModel": { "href": "/programmingmodel/2947", "ProgrammingModelType": "SingleActionProgrammingModel" }, "Parent": { "href": "/buttongroup/2942" }, "Name": "Button 1", "Engraving": { "Text": "Favorite" } }, { "href": "/button/2949", "ButtonNumber": 2, "ProgrammingModel": { "href": "/programmingmodel/2950", "ProgrammingModelType": "SingleActionProgrammingModel" }, "Parent": { "href": "/buttongroup/2942" }, "Name": "Button 2", "Engraving": { "Text": "Next Track" } }, { "href": "/button/2952", "ButtonNumber": 3, "ProgrammingModel": { "href": "/programmingmodel/2953", "ProgrammingModelType": "SingleSceneRaiseProgrammingModel" }, "Parent": { "href": "/buttongroup/2942" }, "Name": "Button 3", "Engraving": { "Text": "Volume Up" } }, { "href": "/button/2955", "ButtonNumber": 4, "ProgrammingModel": { "href": "/programmingmodel/2956", "ProgrammingModelType": "SingleSceneLowerProgrammingModel" }, "Parent": { "href": "/buttongroup/2942" }, "Name": "Button 4", "Engraving": { "Text": "Volume Down" } } ] } ] } }pylutron-caseta-0.24.0/tests/responses/ra3/device/2939/device.json000066400000000000000000000014671476343602200246150ustar00rootroot00000000000000{ "CommuniqueType": "ReadResponse", "Header": { "MessageBodyType": "OneDeviceDefinition", "StatusCode": "200 OK", "Url": "/device/2939" }, "Body": { "Device": { "href": "/device/2939", "Name": "Audio Pico", "Parent": { "href": "/project" }, "ModelNumber": "PJ2-3BRL-XXX-A02", "DeviceType": "Pico3ButtonRaiseLower", "AssociatedArea": { "href": "/area/547" }, "LinkNodes": [ { "href": "/device/2939/linknode/2941" } ], "DeviceClass": { "HexadecimalEncoding": "1070101" }, "AddressedState": "Unaddressed" } } }pylutron-caseta-0.24.0/tests/responses/ra3/device/5341/000077500000000000000000000000001476343602200224415ustar00rootroot00000000000000pylutron-caseta-0.24.0/tests/responses/ra3/device/5341/buttongroup.json000066400000000000000000000073151476343602200257320ustar00rootroot00000000000000{ "CommuniqueType": "ReadResponse", "Header": { "MessageBodyType": "MultipleButtonGroupExpandedDefinition", "StatusCode": "200 OK", "Url": "/device/5341/buttongroup/expanded" }, "Body": { "ButtonGroupsExpanded": [ { "href": "/buttongroup/5344", "Parent": { "href": "/device/5341" }, "SortOrder": 0, "Category": { "Type": "Lights" }, "ProgrammingType": "Freeform", "Buttons": [ { "href": "/button/5345", "ButtonNumber": 0, "ProgrammingModel": { "href": "/programmingmodel/5346", "ProgrammingModelType": "SingleActionProgrammingModel" }, "Parent": { "href": "/buttongroup/5344" }, "Name": "Button 0", "Engraving": { "Text": "On" } }, { "href": "/button/5348", "ButtonNumber": 1, "ProgrammingModel": { "href": "/programmingmodel/5349", "ProgrammingModelType": "SingleActionProgrammingModel" }, "Parent": { "href": "/buttongroup/5344" }, "Name": "Button 1", "Engraving": { "Text": "Favorite" } }, { "href": "/button/5351", "ButtonNumber": 2, "ProgrammingModel": { "href": "/programmingmodel/5352", "ProgrammingModelType": "SingleActionProgrammingModel" }, "Parent": { "href": "/buttongroup/5344" }, "Name": "Button 2", "Engraving": { "Text": "Off" } }, { "href": "/button/5354", "ButtonNumber": 3, "ProgrammingModel": { "href": "/programmingmodel/5355", "ProgrammingModelType": "SingleSceneRaiseProgrammingModel" }, "Parent": { "href": "/buttongroup/5344" }, "Name": "Button 3", "Engraving": { "Text": "Raise" } }, { "href": "/button/5357", "ButtonNumber": 4, "ProgrammingModel": { "href": "/programmingmodel/5358", "ProgrammingModelType": "SingleSceneLowerProgrammingModel" }, "Parent": { "href": "/buttongroup/5344" }, "Name": "Button 4", "Engraving": { "Text": "Lower" } } ] } ] } }pylutron-caseta-0.24.0/tests/responses/ra3/device/5341/device.json000066400000000000000000000015361476343602200246000ustar00rootroot00000000000000{ "CommuniqueType": "ReadResponse", "Header": { "MessageBodyType": "OneDeviceDefinition", "StatusCode": "200 OK", "Url": "/device/5341" }, "Body": { "Device": { "href": "/device/5341", "Name": "TestingPicoDev", "Parent": { "href": "/project" }, "SerialNumber": 68130838, "ModelNumber": "PJ2-3BRL-XXX-L01", "DeviceType": "Pico3ButtonRaiseLower", "AssociatedArea": { "href": "/area/83" }, "LinkNodes": [ { "href": "/device/5341/linknode/5343" } ], "DeviceClass": { "HexadecimalEncoding": "1070201" }, "AddressedState": "Addressed" } } }pylutron-caseta-0.24.0/tests/responses/ra3/devices.json000066400000000000000000000001771476343602200231300ustar00rootroot00000000000000{ "CommuniqueType": "ReadResponse", "Header": { "StatusCode": "204 NoContent", "Url": "/device" } }pylutron-caseta-0.24.0/tests/responses/ra3/processor.json000077500000000000000000000027221476343602200235260ustar00rootroot00000000000000{ "CommuniqueType": "ReadResponse", "Header": { "MessageBodyType": "MultipleDeviceDefinition", "StatusCode": "200 OK", "Url": "/device?where=IsThisDevice:true" }, "Body": { "Devices": [ { "href": "/device/104", "Name": "Enclosure Device 001", "Parent": { "href": "/project" }, "SerialNumber": 11111111, "ModelNumber": "JanusProcRA3", "DeviceType": "RadioRa3Processor", "AssociatedArea": { "href": "/area/83" }, "OwnedLinks": [ { "href": "/link/108", "LinkType": "RF" }, { "href": "/link/106", "LinkType": "ClearConnectTypeX" } ], "LinkNodes": [ { "href": "/device/104/linknode/107" }, { "href": "/device/104/linknode/105" } ], "FirmwareImage": { "Firmware": { "DisplayName": "21.07.18f000" }, "Installed": { "Year": 2022, "Month": 1, "Day": 1, "Hour": 0, "Minute": 0, "Second": 0, "Utc": "-5:00:00" } }, "DeviceFirmwarePackage": { "Package": { "DisplayName": "001.016.000r000" } }, "NetworkInterfaces": [{ "MACAddress": "00:00:00:00:00:00" }], "Databases": [{ "href": "/database/@Project", "Type": "Project" }], "DeviceClass": { "HexadecimalEncoding": "81b0101" }, "AddressedState": "Addressed", "IsThisDevice": true } ] } }pylutron-caseta-0.24.0/tests/responses/ra3/project.json000066400000000000000000000017611476343602200231540ustar00rootroot00000000000000{ "CommuniqueType": "ReadResponse", "Header": { "MessageBodyType": "OneProjectDefinition", "StatusCode": "200 OK", "Url": "/project" }, "Body": { "Project": { "href": "/project", "Name": "Home", "ProductType": "Lutron RadioRA 3 Project", "MasterDeviceList": { "Devices": [ { "href": "/device/96" } ] }, "Contacts": [ { "href": "/contactinfo/81" } ], "TimeclockEventRules": { "href": "/project/timeclockeventrules" }, "ProjectModifiedTimestamp": { "Year": 2022, "Month": 2, "Day": 8, "Hour": 0, "Minute": 39, "Second": 20, "Utc": "0" } } } } pylutron-caseta-0.24.0/tests/responses/ra3/zonestatus.json000066400000000000000000000035741476343602200237310ustar00rootroot00000000000000{ "CommuniqueType": "SubscribeResponse", "Header": { "MessageBodyType": "MultipleZoneStatus", "StatusCode": "200 OK", "Url": "/zone/status" }, "Body": { "ZoneStatuses": [ { "href": "/zone/536/status", "Level": 0, "SwitchedLevel": "Off", "Zone": { "href": "/zone/536" }, "StatusAccuracy": "Good" }, { "href": "/zone/1361/status", "Level": 0, "Zone": { "href": "/zone/1361" }, "StatusAccuracy": "Good" }, { "href": "/zone/1377/status", "Level": 0, "Zone": { "href": "/zone/1377" }, "StatusAccuracy": "Good" }, { "href": "/zone/1393/status", "Level": 0, "SwitchedLevel": "Off", "Zone": { "href": "/zone/1393" }, "StatusAccuracy": "Good" }, { "href": "/zone/2091/status", "Level": 0, "Zone": { "href": "/zone/2091" }, "StatusAccuracy": "Good" }, { "href": "/zone/2107/status", "Level": 0, "Zone": { "href": "/zone/2107" }, "StatusAccuracy": "Good" }, { "href": "/zone/2010/status", "Level": 0, "Zone": { "href": "/zone/2010" }, "StatusAccuracy": "Good" } ] } }pylutron-caseta-0.24.0/tests/responses/scenes.json000066400000000000000000000026631476343602200223030ustar00rootroot00000000000000{ "CommuniqueType": "ReadResponse", "Header": { "MessageBodyType": "MultipleVirtualButtonDefinition", "StatusCode": "200 OK", "Url": "/virtualbutton" }, "Body": { "VirtualButtons": [ { "href": "/virtualbutton/1", "Name": "scene 1", "ButtonNumber": 0, "ProgrammingModel": { "href": "/programmingmodel/1" }, "Parent": { "href": "/project" }, "IsProgrammed": true }, { "href": "/virtualbutton/2", "Name": "Button 2", "ButtonNumber": 1, "ProgrammingModel": { "href": "/programmingmodel/2" }, "Parent": { "href": "/project" }, "IsProgrammed": false }, { "href": "/vbutton/1", "ButtonNumber": 1, "ProgrammingModel": { "href": "/programmingmodel/200" }, "Parent": { "href": "/area/9" }, "IsProgrammed": true, "Category": { "Type": "LivingRoom", "SubType": "Bright" } } ] } }pylutron-caseta-0.24.0/tests/test_leap.py000066400000000000000000000222251476343602200204350ustar00rootroot00000000000000"""Tests to validate low-level network interactions.""" import asyncio import orjson from typing import AsyncGenerator, Iterable, NamedTuple, Tuple import pytest import pytest_asyncio from pylutron_caseta import BridgeDisconnectedError from pylutron_caseta.leap import LeapProtocol from pylutron_caseta.messages import Response, ResponseHeader, ResponseStatus class Pipe(NamedTuple): """A LeapProtocol that communicates to a stream reader/writer pair.""" leap: LeapProtocol leap_loop: asyncio.Task test_reader: asyncio.StreamReader test_writer: asyncio.StreamWriter class _PipeTransport(asyncio.Transport): def __init__(self): super().__init__() self._closing = False self._extra = {} self.other = None self._protocol = None def close(self): self._closing = True self.other.get_protocol().connection_lost(None) def is_closing(self) -> bool: return self._closing def pause_reading(self): self.other.get_protocol().pause_writing() def resume_reading(self): self.other.get_protocol().resume_writing() def abort(self): self.close() def can_write_eof(self) -> bool: return False def get_write_buffer_size(self) -> int: return 0 def get_write_buffer_limits(self) -> Tuple[int, int]: """Return (0, 0).""" return (0, 0) def set_write_buffer_limits(self, high=None, low=None): raise NotImplementedError() def write(self, data: bytes): self.other.get_protocol().data_received(data) def writelines(self, list_of_data: Iterable[bytes]): for line in list_of_data: self.write(line) def write_eof(self): raise NotImplementedError() def set_protocol(self, protocol: asyncio.BaseProtocol): self._protocol = protocol def get_protocol(self) -> asyncio.BaseProtocol: return self._protocol @pytest_asyncio.fixture(name="pipe") async def fixture_pipe() -> AsyncGenerator[Pipe, None]: """Create linked readers and writers for tests.""" test_reader = asyncio.StreamReader() impl_reader = asyncio.StreamReader() test_protocol = asyncio.StreamReaderProtocol(test_reader) impl_protocol = asyncio.StreamReaderProtocol(impl_reader) test_pipe = _PipeTransport() impl_pipe = _PipeTransport() test_pipe.other = impl_pipe impl_pipe.other = test_pipe test_pipe.set_protocol(test_protocol) impl_pipe.set_protocol(impl_protocol) test_protocol.connection_made(test_pipe) impl_protocol.connection_made(impl_pipe) test_writer = asyncio.StreamWriter( test_pipe, test_protocol, test_reader, asyncio.get_running_loop() ) impl_writer = asyncio.StreamWriter( impl_pipe, impl_protocol, impl_reader, asyncio.get_running_loop() ) leap = LeapProtocol(impl_reader, impl_writer) leap_task = asyncio.create_task(leap.run()) yield Pipe(leap, leap_task, test_reader, test_writer) leap_task.cancel() @pytest.mark.asyncio async def test_call(pipe: Pipe): """Test basic call and response.""" task = asyncio.create_task(pipe.leap.request("ReadRequest", "/test")) received = orjson.loads(await pipe.test_reader.readline()) # message should contain ClientTag tag = received.get("Header", {}).pop("ClientTag", None) assert tag assert isinstance(tag, str) assert received == {"CommuniqueType": "ReadRequest", "Header": {"Url": "/test"}} response_obj = { "CommuniqueType": "ReadResponse", "Header": {"ClientTag": tag, "StatusCode": "200 OK", "Url": "/test"}, "Body": {"ok": True}, } response_bytes = orjson.dumps(response_obj) + b"\r\n" pipe.test_writer.write(response_bytes) result = await task assert result == Response( Header=ResponseHeader(StatusCode=ResponseStatus(200, "OK"), Url="/test"), CommuniqueType="ReadResponse", Body={"ok": True}, ) @pytest.mark.asyncio async def test_read_eof(pipe): """Test reading when EOF is encountered.""" pipe.test_writer.close() await pipe.leap_loop @pytest.mark.asyncio async def test_read_invalid(pipe): """Test reading when invalid data is received.""" pipe.test_writer.write(b"?\r\n") with pytest.raises(orjson.JSONDecodeError): await pipe.leap_loop @pytest.mark.asyncio async def test_busy_close(pipe): """Test closing the session while there are in-flight requests.""" task = asyncio.create_task(pipe.leap.request("ReadRequest", "/test")) await pipe.test_reader.readline() pipe.test_writer.close() await pipe.leap_loop pipe.leap.close() with pytest.raises(BridgeDisconnectedError): await task @pytest.mark.asyncio async def test_unsolicited(pipe): """Test subscribing and unsubscribing unsolicited message handlers.""" handler1_message = None handler2_message = None handler2_called = asyncio.Event() def handler1(response): nonlocal handler1_message handler1_message = response def handler2(response): nonlocal handler2_message handler2_message = response handler2_called.set() pipe.leap.subscribe_unsolicited(handler1) pipe.leap.subscribe_unsolicited(handler2) response_dict = { "CommuniqueType": "ReadResponse", "Header": {"StatusCode": "200 OK", "Url": "/test"}, "Body": {"Index": 0}, } response_bytes = orjson.dumps(response_dict) + b"\r\n" pipe.test_writer.write(response_bytes) response = Response.from_json(response_dict) await asyncio.wait_for(handler2_called.wait(), 1.0) handler2_called.clear() assert handler1_message == response, "handler1 did not receive correct message" assert handler2_message == response, "handler2 did not receive correct message" pipe.leap.unsubscribe_unsolicited(handler1) response_dict["Body"]["Index"] = 1 response_bytes = orjson.dumps(response_dict) + b"\r\n" pipe.test_writer.write(response_bytes) response = Response.from_json(response_dict) await asyncio.wait_for(handler2_called.wait(), 1.0) assert handler1_message != response, "handler1 was not unsubscribed" assert handler2_message == response, "handler2 did not receive correct message" @pytest.mark.asyncio async def test_subscribe_tagged(pipe: Pipe): """ Test subscribing to a topic and receiving responses. Unlike with unsolicited subscriptions, when the client sends a SubscribeRequest, the server sends back all events related to that subscription with the same tag value. """ handler_message = None handler_called = asyncio.Event() def handler(response): nonlocal handler_message handler_message = response handler_called.set() task = asyncio.create_task(pipe.leap.subscribe("/test", handler)) received = orjson.loads(await pipe.test_reader.readline()) # message should contain ClientTag tag = received.get("Header", {}).pop("ClientTag", None) assert tag assert isinstance(tag, str) assert received == { "CommuniqueType": "SubscribeRequest", "Header": {"Url": "/test"}, } response_obj = { "CommuniqueType": "SubscribeResponse", "Header": {"ClientTag": tag, "StatusCode": "200 OK", "Url": "/test"}, "Body": {"ok": True}, } response_bytes = orjson.dumps(response_obj) + b"\r\n" pipe.test_writer.write(response_bytes) result, received_tag = await task assert result == Response( Header=ResponseHeader(StatusCode=ResponseStatus(200, "OK"), Url="/test"), CommuniqueType="SubscribeResponse", Body={"ok": True}, ) assert received_tag == tag # Now that the client has subscribed, send an event to the handler. response_obj = { "CommuniqueType": "ReadResponse", "Header": {"ClientTag": tag, "StatusCode": "200 OK", "Url": "/test"}, "Body": {"ok": True}, } response_bytes = orjson.dumps(response_obj) + b"\r\n" pipe.test_writer.write(response_bytes) await asyncio.wait_for(handler_called.wait(), 1.0) assert handler_message == Response( Header=ResponseHeader(StatusCode=ResponseStatus(200, "OK"), Url="/test"), CommuniqueType="ReadResponse", Body={"ok": True}, ) @pytest.mark.asyncio async def test_subscribe_tagged_404(pipe: Pipe): """Test subscribing to a topic that does not exist.""" def _handler(_: Response): pass task = asyncio.create_task(pipe.leap.subscribe("/test", _handler)) received = orjson.loads(await pipe.test_reader.readline()) tag = received.get("Header", {}).pop("ClientTag", None) response_obj = { "CommuniqueType": "SubscribeResponse", "Header": {"ClientTag": tag, "StatusCode": "404 Not Found", "Url": "/test"}, } response_bytes = orjson.dumps(response_obj) + b"\r\n" pipe.test_writer.write(response_bytes) result, _ = await task assert result == Response( Header=ResponseHeader(StatusCode=ResponseStatus(404, "Not Found"), Url="/test"), CommuniqueType="SubscribeResponse", Body=None, ) # The subscription should not be registered. assert {} == pipe.leap._tagged_subscriptions # pylint: disable=protected-access pylutron-caseta-0.24.0/tests/test_smartbridge.py000066400000000000000000003022411476343602200220160ustar00rootroot00000000000000"""Tests to validate ssl interactions.""" import asyncio from collections import defaultdict from datetime import timedelta import orjson import logging import os import re from typing import ( Any, AsyncGenerator, Callable, Coroutine, Dict, List, NamedTuple, Optional, Tuple, TypeVar, ) import pytest import pytest_asyncio from pylutron_caseta.leap import id_from_href from pylutron_caseta.messages import Response, ResponseHeader, ResponseStatus from pylutron_caseta import ( _LEAP_DEVICE_TYPES, FAN_MEDIUM, OCCUPANCY_GROUP_OCCUPIED, OCCUPANCY_GROUP_UNOCCUPIED, OCCUPANCY_GROUP_UNKNOWN, BUTTON_STATUS_PRESSED, BridgeDisconnectedError, smartbridge, color_value, ) logging.getLogger().setLevel(logging.DEBUG) _LOG = logging.getLogger(__name__) CASETA_PROCESSOR = "Caseta" RA3_PROCESSOR = "RA3" HWQSX_PROCESSOR = "QSX" RESPONSE_PATH = { CASETA_PROCESSOR: "", RA3_PROCESSOR: "ra3/", HWQSX_PROCESSOR: "hwqsx/", } def response_from_json_file(filename: str) -> Response: """Fetch a response from a saved JSON file.""" responsedir = os.path.join(os.path.split(__file__)[0], "responses") with open(os.path.join(responsedir, filename), "r", encoding="utf-8") as ifh: return Response.from_json(orjson.loads(ifh.read())) class Request(NamedTuple): """An in-flight LEAP request.""" communique_type: str url: str body: Optional[dict] = None paging: Optional[dict] = None class _FakeLeap: def __init__(self) -> None: self.requests: "asyncio.Queue[Tuple[Request, asyncio.Future[Response]]]" = ( asyncio.Queue() ) self.running = None self._subscriptions: Dict[str, List[Callable[[Response], None]]] = defaultdict( list ) self._unsolicited: List[Callable[[Response], None]] = [] async def request( self, communique_type: str, url: str, body: Optional[dict] = None, paging: Optional[dict] = None, ) -> Response: """Make a request to the bridge and return the response.""" future: asyncio.Future = asyncio.get_running_loop().create_future() obj = Request( communique_type=communique_type, url=url, body=body, paging=paging ) await self.requests.put((obj, future)) return await future async def subscribe( self, url: str, callback: Callable[[Response], None], body: Optional[dict] = None, communique_type: str = "SubscribeRequest", ) -> Tuple[Response, str]: """Subscribe to events from the bridge.""" response = await self.request(communique_type, url, body) self._subscriptions[url].append(callback) return (response, "not-implemented") async def run(self): """Event monitoring loop.""" self.running = asyncio.get_running_loop().create_future() await self.running def subscribe_unsolicited(self, callback: Callable[[Response], None]): """Subscribe to unsolicited responses.""" if not callable(callback): raise TypeError("callback must be callable") self._unsolicited.append(callback) def unsubscribe_unsolicited(self, callback: Callable[[Response], None]): """Unsubscribe from unsolicited responses.""" self._unsolicited.remove(callback) def send_unsolicited(self, response: Response): """Send an unsolicited response message to SmartBridge.""" for handler in self._unsolicited: handler(response) def send_to_subscribers(self, response: Response): """Send an response message to topic subscribers.""" url = response.Header.Url if url is None: raise TypeError("url must not be None") for handler in self._subscriptions[url]: handler(response) def close(self): """Disconnect.""" if self.running is not None and not self.running.done(): self.running.set_result(None) self.running = None while not self.requests.empty(): (_, response) = self.requests.get_nowait() if not response.done(): response.set_exception(BridgeDisconnectedError()) self.requests.task_done() T = TypeVar("T") class Bridge: """A test harness around SmartBridge.""" def __init__(self, on_connect_callback=None): """Create a new Bridge in a disconnected state.""" self.connections = asyncio.Queue() self.leap = None self.button_list_result = response_from_json_file("buttons.json") self.occupancy_group_list_result = response_from_json_file( "occupancygroups.json" ) self.occupancy_group_subscription_data_result = response_from_json_file( "occupancygroupsubscribe.json" ) self.button_subscription_data_result = response_from_json_file( "buttonsubscribe.json" ) self.button_led_subscription_data_result = response_from_json_file( f"{RESPONSE_PATH[HWQSX_PROCESSOR]}ledsubscribe.json" ) self.ra3_button_list = [] self.ra3_button_led_list = [] self.qsx_button_list = [] self.qsx_button_led_list = [] async def fake_connect(): """Open a fake LEAP connection for the test.""" leap = _FakeLeap() await self.connections.put(leap) return leap self.target = smartbridge.Smartbridge(fake_connect, on_connect_callback) async def initialize(self, processor=CASETA_PROCESSOR): """Perform the initial connection with SmartBridge.""" loop = asyncio.get_running_loop() running_tasks: set[asyncio.Task] = set() try: connect_task = loop.create_task(self.target.connect()) async def wait(coro: Coroutine[Any, Any, T]) -> T: # abort if SmartBridge reports it has finished connecting early task = loop.create_task(coro) running_tasks.add(task) race = await asyncio.wait( (connect_task, task), timeout=10, return_when=asyncio.FIRST_COMPLETED, ) done, _ = race assert len(done) > 0, "operation timed out" if len(done) == 1 and connect_task in done: task.cancel() running_tasks.remove(task) raise connect_task.exception() result = await task running_tasks.remove(task) return result fake_leap = await self.connections.get() if processor == CASETA_PROCESSOR: await self._accept_connection(fake_leap, wait) elif processor == RA3_PROCESSOR: await self._accept_connection_ra3(fake_leap, wait) elif processor == HWQSX_PROCESSOR: await self._accept_connection_qsx(fake_leap, wait) await connect_task self.leap = fake_leap finally: self.connections.task_done() for task in (connect_task, *running_tasks): task.cancel() async def _accept_connection(self, leap, wait): """Accept a connection from SmartBridge (implementation).""" # Read request on /areas request, response = await wait(leap.requests.get()) assert request == Request(communique_type="ReadRequest", url="/area") response.set_result(response_from_json_file("areas.json")) leap.requests.task_done() # Read request on /project request, response = await wait(leap.requests.get()) assert request == Request(communique_type="ReadRequest", url="/project") response.set_result(response_from_json_file("project.json")) leap.requests.task_done() # Read request on /device request, response = await wait(leap.requests.get()) assert request == Request(communique_type="ReadRequest", url="/device") response.set_result(response_from_json_file("devices.json")) leap.requests.task_done() # Read request on /button request, response = await wait(leap.requests.get()) assert request == Request(communique_type="ReadRequest", url="/button") response.set_result(self.button_list_result) leap.requests.task_done() # Read request on /server/2/id request, response = await wait(leap.requests.get()) assert request == Request(communique_type="ReadRequest", url="/server/2/id") response.set_result(response_from_json_file("lip.json")) leap.requests.task_done() # Read request on /virtualbutton request, response = await wait(leap.requests.get()) assert request == Request(communique_type="ReadRequest", url="/virtualbutton") response.set_result(response_from_json_file("scenes.json")) leap.requests.task_done() # Read request on /occupancygroup request, response = await wait(leap.requests.get()) assert request == Request(communique_type="ReadRequest", url="/occupancygroup") response.set_result(self.occupancy_group_list_result) leap.requests.task_done() # Subscribe request on /occupancygroup/status request, response = await wait(leap.requests.get()) assert request == Request( communique_type="SubscribeRequest", url="/occupancygroup/status" ) response.set_result(self.occupancy_group_subscription_data_result) leap.requests.task_done() # Subscribe request on /button/{button}/status/event for button in ( re.sub(r".*/", "", button["href"]) for button in self.button_list_result.Body.get("Buttons", []) ): request, response = await wait(leap.requests.get()) assert request == Request( communique_type="SubscribeRequest", url=f"/button/{button}/status/event" ) response.set_result(self.button_subscription_data_result) leap.requests.task_done() # Check the zone status on each zone requested_zones = [] for _ in range(0, 5): request, response = await wait(leap.requests.get()) logging.info("Read %s", request) assert request.communique_type == "ReadRequest" requested_zones.append(request.url) response.set_result( Response( CommuniqueType="ReadResponse", Header=ResponseHeader( MessageBodyType="OneZoneStatus", StatusCode=ResponseStatus(200, "OK"), Url=request.url, ), Body={ "ZoneStatus": { "href": request.url, "Zone": {"href": request.url.replace("/status", "")}, "StatusAccuracy": "Good", } }, ) ) leap.requests.task_done() requested_zones.sort() assert requested_zones == [ "/zone/1/status", "/zone/2/status", "/zone/3/status", "/zone/4/status", "/zone/6/status", ] async def _process_station(self, result, leap, wait, bridge_type): if result.Body is None: return response_path = RESPONSE_PATH[bridge_type] for station in result.Body.get("ControlStations", []): for device in station.get("AssociatedGangedDevices", []): if device["Device"]["DeviceType"] not in _LEAP_DEVICE_TYPES.get( "sensor" ): continue device_id = re.sub(r".*/", "", device["Device"]["href"]) request, response = await wait(leap.requests.get()) assert request == Request( communique_type="ReadRequest", url=f"/device/{device_id}/buttongroup/expanded", ) button_group_result = response_from_json_file( f"{response_path}device/{device_id}/buttongroup.json" ) response.set_result(button_group_result) leap.requests.task_done() request, response = await wait(leap.requests.get()) assert request == Request( communique_type="ReadRequest", url=f"/device/{device_id}" ) response.set_result( response_from_json_file( f"{response_path}device/{device_id}/device.json" ) ) leap.requests.task_done() for group in button_group_result.Body["ButtonGroupsExpanded"]: for button in group["Buttons"]: if button.get("AssociatedLED", None) is not None: led_id = id_from_href(button["AssociatedLED"]["href"]) request, response = await wait(leap.requests.get()) assert request == Request( communique_type="SubscribeRequest", url=f"/led/{led_id}/status", ) response.set_result(self.button_subscription_data_result) leap.requests.task_done() self._populate_button_list_from_buttongroups( button_group_result.Body["ButtonGroupsExpanded"], bridge_type ) self._populate_button_led_list_from_buttongroups( button_group_result.Body["ButtonGroupsExpanded"], bridge_type ) def _populate_button_list_from_buttongroups(self, buttongroups, bridge_type): """Add buttons from a set of buttongroups to the proper processor list to support subscribe tests Args: buttongroups: A set of buttongroups bridge_type: The bridge or processor type """ buttons = [] buttons.extend( [ id_from_href(button["href"]) for group in buttongroups for button in group["Buttons"] ] ) if bridge_type == RA3_PROCESSOR: self.ra3_button_list.extend(buttons) elif bridge_type == HWQSX_PROCESSOR: self.qsx_button_list.extend(buttons) def _populate_button_led_list_from_buttongroups(self, buttongroups, bridge_type): """Add button LEDs from a set of buttongroups to the proper processor list to support subscribe tests Args: buttongroups: A set of buttongroups bridge_type: The bridge or processor type """ button_leds = [] for group in buttongroups: for button in group["Buttons"]: if button.get("AssociatedLED", None) is not None: button_leds.append(id_from_href(button["AssociatedLED"]["href"])) if bridge_type == RA3_PROCESSOR: self.ra3_button_led_list.extend(button_leds) elif bridge_type == HWQSX_PROCESSOR: self.qsx_button_led_list.extend(button_leds) async def _accept_connection_ra3(self, leap, wait): """Accept a connection from SmartBridge (implementation).""" ra3_response_path = RESPONSE_PATH[RA3_PROCESSOR] # Read request on /areas ra3_area_list_result = response_from_json_file(f"{ra3_response_path}areas.json") request, response = await wait(leap.requests.get()) assert request == Request(communique_type="ReadRequest", url="/area") response.set_result(ra3_area_list_result) leap.requests.task_done() # Read request on /project request, response = await wait(leap.requests.get()) assert request == Request(communique_type="ReadRequest", url="/project") response.set_result(response_from_json_file(f"{ra3_response_path}project.json")) leap.requests.task_done() # Read request on /device?where=IsThisDevice:true request, response = await wait(leap.requests.get()) assert request == Request( communique_type="ReadRequest", url="/device?where=IsThisDevice:true" ) response.set_result( response_from_json_file(f"{ra3_response_path}/processor.json") ) leap.requests.task_done() # Read request on each area's control stations & zones for area_id in ( re.sub(r".*/", "", area["href"]) for area in ra3_area_list_result.Body.get("Areas", []) ): request, response = await wait(leap.requests.get()) assert request == Request( communique_type="ReadRequest", url=f"/area/{area_id}/associatedcontrolstation", ) station_result = response_from_json_file( f"{ra3_response_path}area/{area_id}/controlstation.json" ) response.set_result(station_result) leap.requests.task_done() await self._process_station(station_result, leap, wait, RA3_PROCESSOR) request, response = await wait(leap.requests.get()) assert request == Request( communique_type="ReadRequest", url=f"/area/{area_id}/associatedzone" ) zone_result = response_from_json_file( f"{ra3_response_path}area/{area_id}/associatedzone.json" ) response.set_result(zone_result) leap.requests.task_done() # Read request on /zone/status request, response = await wait(leap.requests.get()) assert request == Request( communique_type="SubscribeRequest", url="/zone/status" ) response.set_result( response_from_json_file(f"{ra3_response_path}zonestatus.json") ) leap.requests.task_done() # Subscribe request on /button/{button}/status/event for button in self.ra3_button_list: request, response = await wait(leap.requests.get()) assert request == Request( communique_type="SubscribeRequest", url=f"/button/{button}/status/event" ) response.set_result(self.button_subscription_data_result) leap.requests.task_done() # Read request on /device?where=IsThisDevice:false request, response = await wait(leap.requests.get()) assert request == Request( communique_type="ReadRequest", url="/device?where=IsThisDevice:false" ) response.set_result( response_from_json_file(f"{ra3_response_path}device-list.json") ) leap.requests.task_done() # Subscribe request on /area/status request, response = await wait(leap.requests.get()) assert request == Request( communique_type="SubscribeRequest", url="/area/status" ) response.set_result( response_from_json_file(f"{ra3_response_path}area/status-subscribe.json") ) leap.requests.task_done() async def _accept_connection_qsx(self, leap, wait): """Accept a connection as a mock QSX processor (implementation).""" hwqsx_response_path = RESPONSE_PATH[HWQSX_PROCESSOR] # Read request on /areas qsx_area_list_result = response_from_json_file( f"{hwqsx_response_path}areas.json" ) request, response = await wait(leap.requests.get()) assert request == Request(communique_type="ReadRequest", url="/area") response.set_result(qsx_area_list_result) leap.requests.task_done() # Read request on /project request, response = await wait(leap.requests.get()) assert request == Request(communique_type="ReadRequest", url="/project") response.set_result( response_from_json_file(f"{hwqsx_response_path}project.json") ) leap.requests.task_done() # Read request on /device?where=IsThisDevice:true request, response = await wait(leap.requests.get()) assert request == Request( communique_type="ReadRequest", url="/device?where=IsThisDevice:true" ) response.set_result( response_from_json_file(f"{hwqsx_response_path}processor.json") ) leap.requests.task_done() # Read request on each area's control stations & zones for area_id in ( re.sub(r".*/", "", area["href"]) for area in qsx_area_list_result.Body.get("Areas", []) ): request, response = await wait(leap.requests.get()) assert request == Request( communique_type="ReadRequest", url=f"/area/{area_id}/associatedcontrolstation", ) station_result = response_from_json_file( f"{hwqsx_response_path}area/{area_id}/controlstation.json" ) response.set_result(station_result) leap.requests.task_done() await self._process_station( station_result, leap, wait, bridge_type=HWQSX_PROCESSOR ) request, response = await wait(leap.requests.get()) assert request == Request( communique_type="ReadRequest", url=f"/area/{area_id}/associatedzone" ) zone_result = response_from_json_file( f"{hwqsx_response_path}area/{area_id}/associatedzone.json" ) response.set_result(zone_result) leap.requests.task_done() # Read request on /zone/status request, response = await wait(leap.requests.get()) assert request == Request( communique_type="SubscribeRequest", url="/zone/status" ) response.set_result( response_from_json_file(f"{hwqsx_response_path}zonestatus.json") ) leap.requests.task_done() # Subscribe request on /button/{button}/status/event for button in self.qsx_button_list: request, response = await wait(leap.requests.get()) assert request == Request( communique_type="SubscribeRequest", url=f"/button/{button}/status/event" ) response.set_result(self.button_subscription_data_result) leap.requests.task_done() # Read request on /device?where=IsThisDevice:false request, response = await wait(leap.requests.get()) assert request == Request( communique_type="ReadRequest", url="/device?where=IsThisDevice:false" ) response.set_result( response_from_json_file(f"{hwqsx_response_path}device-list.json") ) leap.requests.task_done() # Subscribe request on /area/status request, response = await wait(leap.requests.get()) assert request == Request( communique_type="SubscribeRequest", url="/area/status" ) response.set_result( response_from_json_file(f"{hwqsx_response_path}area/status-subscribe.json") ) leap.requests.task_done() def disconnect(self, exception=None): """Disconnect SmartBridge.""" if exception is None: self.leap.running.set_result(None) else: self.leap.running.set_exception(exception) async def accept_connection(self): """Wait for SmartBridge to reconnect.""" leap = await self.connections.get() async def wait(coro): # nothing special result = await coro return result await self._accept_connection(leap, wait) self.leap = leap self.connections.task_done() @pytest_asyncio.fixture(name="bridge_uninit") async def fixture_bridge_uninit() -> AsyncGenerator[Bridge, None]: """ Create a bridge attached to a fake reader and writer but not yet initialized. This is used for tests that need to customize the virtual devices present during initialization. """ harness = Bridge() yield harness await harness.target.close() @pytest_asyncio.fixture(name="bridge") async def fixture_bridge(bridge_uninit: Bridge) -> AsyncGenerator[Bridge, None]: """Create a bridge attached to a fake reader and writer.""" await bridge_uninit.initialize(CASETA_PROCESSOR) yield bridge_uninit @pytest_asyncio.fixture(name="ra3_bridge") async def fixture_bridge_ra3(bridge_uninit: Bridge) -> AsyncGenerator[Bridge, None]: """Create a RA3 bridge attached to a fake reader and writer.""" await bridge_uninit.initialize(RA3_PROCESSOR) yield bridge_uninit @pytest_asyncio.fixture(name="qsx_processor") async def fixture_bridge_qsx(bridge_uninit: Bridge) -> AsyncGenerator[Bridge, None]: """Create a QSX processor attached to a fake reader and writer.""" await bridge_uninit.initialize(HWQSX_PROCESSOR) yield bridge_uninit @pytest.mark.asyncio async def test_notifications(bridge: Bridge): """Test notifications are sent to subscribers.""" notified = False def callback(): nonlocal notified notified = True bridge.target.add_subscriber("2", callback) bridge.leap.send_unsolicited( Response( CommuniqueType="ReadResponse", Header=ResponseHeader( MessageBodyType="OneZoneStatus", StatusCode=ResponseStatus(200, "OK"), Url="/zone/1/status", ), Body={"ZoneStatus": {"Level": 100, "Zone": {"href": "/zone/1"}}}, ) ) await asyncio.wait_for(bridge.leap.requests.join(), 10) assert notified @pytest.mark.asyncio async def test_device_list(bridge: Bridge): """Test methods getting devices.""" devices = bridge.target.get_devices() expected_devices = { "1": { "area": None, "device_id": "1", "device_name": "Smart Bridge", "name": "Smart Bridge", "type": "SmartBridge", "zone": None, "current_state": -1, "fan_speed": None, "model": "L-BDG2-WH", "serial": 1234, "button_groups": None, "tilt": None, "occupancy_sensors": None, }, "2": { "area": "2", "device_id": "2", "device_name": "Lights", "name": "Hallway_Lights", "type": "WallDimmer", "zone": "1", "model": "PD-6WCL-XX", "serial": 2345, "current_state": -1, "fan_speed": None, "button_groups": None, "tilt": None, "occupancy_sensors": None, }, "3": { "area": "2", "device_id": "3", "device_name": "Fan", "name": "Hallway_Fan", "type": "CasetaFanSpeedController", "zone": "2", "model": "PD-FSQN-XX", "serial": 3456, "current_state": -1, "fan_speed": None, "button_groups": None, "tilt": None, "occupancy_sensors": None, }, "4": { "area": "3", "device_id": "4", "device_name": "Occupancy Sensor", "name": "Living Room_Occupancy Sensor", "type": "RPSOccupancySensor", "model": "LRF2-XXXXB-P-XX", "serial": 4567, "current_state": -1, "fan_speed": None, "zone": None, "button_groups": None, "tilt": None, "occupancy_sensors": ["2"], }, "5": { "area": "4", "device_id": "5", "device_name": "Occupancy Sensor Door", "name": "Master Bathroom_Occupancy Sensor Door", "type": "RPSOccupancySensor", "model": "PD-VSENS-XX", "serial": 5678, "current_state": -1, "fan_speed": None, "zone": None, "button_groups": None, "tilt": None, "occupancy_sensors": ["3"], }, "6": { "area": "4", "device_id": "6", "device_name": "Occupancy Sensor Tub", "name": "Master Bathroom_Occupancy Sensor Tub", "type": "RPSOccupancySensor", "model": "PD-OSENS-XX", "serial": 6789, "current_state": -1, "fan_speed": None, "zone": None, "button_groups": None, "tilt": None, "occupancy_sensors": ["4"], }, "7": { "area": "3", "device_id": "7", "device_name": "Living Shade 3", "name": "Living Room_Living Shade 3", "type": "QsWirelessShade", "model": "QSYC-J-RCVR", "serial": 1234, "current_state": -1, "fan_speed": None, "zone": "6", "button_groups": None, "tilt": None, "occupancy_sensors": None, }, "8": { "area": "4", "device_id": "8", "device_name": "Pico", "name": "Master Bedroom_Pico", "type": "Pico3ButtonRaiseLower", "model": "PJ2-3BRL-GXX-X01", "serial": 4321, "current_state": -1, "fan_speed": None, "button_groups": ["2"], "zone": None, "tilt": None, "occupancy_sensors": None, }, "9": { "area": "3", "button_groups": ["5", "6"], "current_state": -1, "device_id": "9", "device_name": "Blinds Remote", "fan_speed": None, "model": "CS-YJ-4GC-WH", "name": "Living Room_Blinds Remote", "serial": 92322656, "type": "FourGroupRemote", "zone": None, "tilt": None, "occupancy_sensors": None, }, "10": { "area": "3", "device_id": "10", "device_name": "Blinds", "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, "occupancy_sensors": None, }, "11": { "area": "3", "device_id": "11", "device_name": "WoodBlinds", "name": "Living Room_WoodBlinds", "type": "Tilt", "zone": "4", "model": "TLT-EDU-B-J", "serial": 4569, "current_state": -1, "fan_speed": None, "button_groups": None, "tilt": None, "occupancy_sensors": None, }, } assert devices == expected_devices bridge.leap.send_unsolicited( Response( CommuniqueType="ReadResponse", Header=ResponseHeader( MessageBodyType="OneZoneStatus", StatusCode=ResponseStatus(200, "OK"), Url="/zone/1/status", ), Body={"ZoneStatus": {"Level": 100, "Zone": {"href": "/zone/1"}}}, ) ) bridge.leap.send_unsolicited( Response( CommuniqueType="ReadResponse", Header=ResponseHeader( MessageBodyType="OneZoneStatus", StatusCode=ResponseStatus(200, "OK"), Url="/zone/2/status", ), 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"}}}, ) ) bridge.leap.send_unsolicited( Response( CommuniqueType="ReadResponse", Header=ResponseHeader( MessageBodyType="OneZoneStatus", StatusCode=ResponseStatus(200, "OK"), Url="/zone/4/status", ), Body={"ZoneStatus": {"Tilt": 40, "Zone": {"href": "/zone/4"}}}, ) ) 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 assert devices["11"]["current_state"] == -1 assert devices["11"]["tilt"] == 40 devices = bridge.target.get_devices_by_domain("light") assert len(devices) == 1 assert devices[0]["device_id"] == "2" devices = bridge.target.get_devices_by_type("WallDimmer") assert len(devices) == 1 assert devices[0]["device_id"] == "2" devices = bridge.target.get_devices_by_types(("SmartBridge", "WallDimmer")) assert len(devices) == 2 device = bridge.target.get_device_by_id("2") assert device["device_id"] == "2" devices = bridge.target.get_devices_by_domain("fan") assert len(devices) == 1 assert devices[0]["device_id"] == "3" devices = bridge.target.get_devices_by_type("CasetaFanSpeedController") 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", "11"] devices = bridge.target.get_devices_by_type("SerenaTiltOnlyWoodBlind") assert [device["device_id"] for device in devices] == ["10"] devices = bridge.target.get_devices_by_type("Tilt") assert [device["device_id"] for device in devices] == ["11"] @pytest.mark.asyncio async def test_lip_device_list(bridge: Bridge): """Test methods getting devices.""" devices = bridge.target.lip_devices expected_devices = { 33: { "Name": "Pico", "ID": 33, "Area": {"Name": "Kitchen"}, "Buttons": [ {"Number": 2}, {"Number": 3}, {"Number": 4}, {"Number": 5}, {"Number": 6}, ], }, 36: { "Name": "Left Pico", "ID": 36, "Area": {"Name": "Master Bedroom"}, "Buttons": [{"Number": 2}, {"Number": 4}], }, } assert devices == expected_devices def test_scene_list(bridge: Bridge): """Test methods getting scenes.""" scenes = bridge.target.get_scenes() assert scenes == {"1": {"scene_id": "1", "name": "scene 1"}} scene = bridge.target.get_scene_by_id("1") assert scene == {"scene_id": "1", "name": "scene 1"} @pytest.mark.asyncio async def test_is_connected(bridge: Bridge): """Test the is_connected method returns connection state.""" assert bridge.target.is_connected() is True async def connect(): raise NotImplementedError() other = smartbridge.Smartbridge(connect) assert other.is_connected() is False @pytest.mark.asyncio async def test_area_list(bridge: Bridge): """Test the list of areas loaded by the bridge.""" expected_areas = { "1": {"id": "1", "name": "root", "parent_id": None}, "2": {"id": "2", "name": "Hallway", "parent_id": "1"}, "3": {"id": "3", "name": "Living Room", "parent_id": "1"}, "4": {"id": "4", "name": "Master Bathroom", "parent_id": "1"}, } assert bridge.target.areas == expected_areas @pytest.mark.asyncio async def test_occupancy_group_list(bridge: Bridge): """Test the list of occupancy groups loaded by the bridge.""" # Occupancy group 1 has no sensors, so it shouldn't appear here expected_groups = { "2": { "area": "3", "device_name": "Occupancy", "occupancy_group_id": "2", "name": "Living Room Occupancy", "status": OCCUPANCY_GROUP_OCCUPIED, "sensors": ["1"], }, "3": { "area": "4", "device_name": "Occupancy", "occupancy_group_id": "3", "name": "Master Bathroom Occupancy", "status": OCCUPANCY_GROUP_UNOCCUPIED, "sensors": ["2", "3"], }, } assert bridge.target.occupancy_groups == expected_groups @pytest.mark.asyncio async def test_initialization_without_buttons(bridge_uninit: Bridge): """Test the that the bridge initializes even if no button status is returned.""" bridge = bridge_uninit # Apparently if a user has no buttons the list of buttons is omitted. # See #87. bridge.button_list_result = Response( Header=ResponseHeader( StatusCode=ResponseStatus(code=200, message="OK"), Url="/button", MessageBodyType="MultipleButtonDefinition", ), CommuniqueType="ReadResponse", Body={}, ) await bridge.initialize() assert bridge.target.buttons == {} @pytest.mark.asyncio async def test_occupancy_no_bodies(bridge_uninit: Bridge): """Test the that the bridge initializes even if no occupancy status is returned.""" bridge = bridge_uninit # unconfirmed: user says sometimes they get back a response where the body is None. # It's unclear if there is some other indication via StatusCode or MessageBodyType # that the body is missing. # See #61 bridge.occupancy_group_list_result = Response( Header=ResponseHeader( StatusCode=ResponseStatus(code=200, message="OK"), Url="/occupancygroup", MessageBodyType="MultipleOccupancyGroupDefinition", ), CommuniqueType="ReadResponse", Body=None, ) bridge.occupancy_group_subscription_data_result = Response( Header=ResponseHeader( StatusCode=ResponseStatus(code=200, message="OK"), Url="/occupancygroup/status", MessageBodyType="MultipleOccupancyGroupStatus", ), CommuniqueType="SubscribeResponse", Body=None, ) await bridge.initialize() assert bridge.target.occupancy_groups == {} @pytest.mark.asyncio async def test_occupancy_group_status_change(bridge: Bridge): """Test that the status is updated when occupancy changes.""" bridge.leap.send_to_subscribers( Response( CommuniqueType="ReadResponse", Header=ResponseHeader( MessageBodyType="MultipleOccupancyGroupStatus", StatusCode=ResponseStatus(200, "OK"), Url="/occupancygroup/status", ), Body={ "OccupancyGroupStatuses": [ { "href": "/occupancygroup/2/status", "OccupancyGroup": {"href": "/occupancygroup/2"}, "OccupancyStatus": "Unoccupied", } ] }, ) ) new_status = bridge.target.occupancy_groups["2"]["status"] assert new_status == OCCUPANCY_GROUP_UNOCCUPIED @pytest.mark.asyncio async def test_occupancy_group_status_change_notification(bridge: Bridge): """Test that occupancy status changes send notifications.""" notified = False def notify(): nonlocal notified notified = True bridge.target.add_occupancy_subscriber("2", notify) bridge.leap.send_to_subscribers( Response( CommuniqueType="ReadResponse", Header=ResponseHeader( MessageBodyType="MultipleOccupancyGroupStatus", StatusCode=ResponseStatus(200, "OK"), Url="/occupancygroup/status", ), Body={ "OccupancyGroupStatuses": [ { "href": "/occupancygroup/2/status", "OccupancyGroup": {"href": "/occupancygroup/2"}, "OccupancyStatus": "Unoccupied", } ] }, ) ) assert notified @pytest.mark.asyncio async def test_button_status_change(bridge: Bridge): """Test that the status is updated when Pico button is pressed.""" bridge.leap.send_to_subscribers( Response( CommuniqueType="ReadResponse", Header=ResponseHeader( MessageBodyType="OneButtonStatusEvent", StatusCode=ResponseStatus(200, "OK"), Url="/button/101/status/event", ), Body={ "ButtonStatus": { "Button": {"href": "/button/101"}, "ButtonEvent": {"EventType": "Press"}, } }, ) ) new_status = bridge.target.buttons["101"]["current_state"] assert new_status == BUTTON_STATUS_PRESSED @pytest.mark.asyncio async def test_button_status_change_notification(bridge: Bridge): """Test that button status changes send notifications.""" notified = False def notify(status): assert status == BUTTON_STATUS_PRESSED nonlocal notified notified = True bridge.target.add_button_subscriber("101", notify) bridge.leap.send_to_subscribers( Response( CommuniqueType="ReadResponse", Header=ResponseHeader( MessageBodyType="OneButtonStatusEvent", StatusCode=ResponseStatus(200, "OK"), Url="/button/101/status/event", ), Body={ "ButtonStatus": { "Button": {"href": "/button/101"}, "ButtonEvent": {"EventType": "Press"}, } }, ) ) assert notified @pytest.mark.asyncio async def test_is_on(bridge: Bridge): """Test the is_on method returns device state.""" bridge.leap.send_unsolicited( Response( CommuniqueType="ReadResponse", Header=ResponseHeader( MessageBodyType="OneZoneStatus", StatusCode=ResponseStatus(200, "OK"), Url="/zone/1/status", ), Body={"ZoneStatus": {"Level": 50, "Zone": {"href": "/zone/1"}}}, ) ) assert bridge.target.is_on("2") is True bridge.leap.send_unsolicited( Response( CommuniqueType="ReadResponse", Header=ResponseHeader( MessageBodyType="OneZoneStatus", StatusCode=ResponseStatus(200, "OK"), Url="/zone/1/status", ), Body={"ZoneStatus": {"Level": 0, "Zone": {"href": "/zone/1"}}}, ) ) assert bridge.target.is_on("2") is False @pytest.mark.asyncio async def test_is_on_fan(bridge: Bridge): """Test the is_on method returns device state for fans.""" bridge.leap.send_unsolicited( Response( CommuniqueType="ReadResponse", Header=ResponseHeader( MessageBodyType="OneZoneStatus", StatusCode=ResponseStatus(200, "OK"), Url="/zone/1/status", ), Body={"ZoneStatus": {"FanSpeed": "Medium", "Zone": {"href": "/zone/1"}}}, ) ) assert bridge.target.is_on("2") is True bridge.leap.send_unsolicited( Response( CommuniqueType="ReadResponse", Header=ResponseHeader( MessageBodyType="OneZoneStatus", StatusCode=ResponseStatus(200, "OK"), Url="/zone/1/status", ), Body={"ZoneStatus": {"FanSpeed": "Off", "Zone": {"href": "/zone/1"}}}, ) ) assert bridge.target.is_on("2") is False @pytest.mark.asyncio async def test_set_value(bridge: Bridge): """Test that setting values produces the right commands.""" task = asyncio.get_running_loop().create_task(bridge.target.set_value("2", 50)) command, response = await bridge.leap.requests.get() assert command == Request( communique_type="CreateRequest", url="/zone/1/commandprocessor", body={ "Command": { "CommandType": "GoToLevel", "Parameter": [{"Type": "Level", "Value": 50}], } }, ) response.set_result( Response( CommuniqueType="CreateResponse", Header=ResponseHeader( MessageBodyType="OneZoneStatus", StatusCode=ResponseStatus(201, "Created"), Url="/zone/1/commandprocessor", ), Body={ "ZoneStatus": { "href": "/zone/1/status", "Level": 50, "Zone": {"href": "/zone/1"}, } }, ) ) bridge.leap.requests.task_done() await task task = asyncio.get_running_loop().create_task(bridge.target.turn_on("2")) command, response = await bridge.leap.requests.get() assert command == Request( communique_type="CreateRequest", url="/zone/1/commandprocessor", body={ "Command": { "CommandType": "GoToLevel", "Parameter": [{"Type": "Level", "Value": 100}], } }, ) response.set_result( Response( CommuniqueType="CreateResponse", Header=ResponseHeader( MessageBodyType="OneZoneStatus", StatusCode=ResponseStatus(201, "Created"), Url="/zone/1/commandprocessor", ), Body={ "ZoneStatus": { "href": "/zone/1/status", "Level": 100, "Zone": {"href": "/zone/1"}, } }, ), ) bridge.leap.requests.task_done() await task task = asyncio.get_running_loop().create_task(bridge.target.turn_off("2")) command, response = await bridge.leap.requests.get() assert command == Request( communique_type="CreateRequest", url="/zone/1/commandprocessor", body={ "Command": { "CommandType": "GoToLevel", "Parameter": [{"Type": "Level", "Value": 0}], } }, ) response.set_result( Response( CommuniqueType="CreateResponse", Header=ResponseHeader( MessageBodyType="OneZoneStatus", StatusCode=ResponseStatus(201, "Created"), Url="/zone/1/commandprocessor", ), Body={ "ZoneStatus": { "href": "/zone/1/status", "Level": 0, "Zone": {"href": "/zone/1"}, } }, ), ) bridge.leap.requests.task_done() await task @pytest.mark.asyncio async def test_set_value_with_fade(bridge: Bridge): """Test that setting values with fade_time produces the right commands.""" task = asyncio.get_running_loop().create_task( bridge.target.set_value("2", 50, fade_time=timedelta(seconds=4)) ) command, _ = await bridge.leap.requests.get() assert command == Request( communique_type="CreateRequest", url="/zone/1/commandprocessor", body={ "Command": { "CommandType": "GoToDimmedLevel", "DimmedLevelParameters": {"Level": 50, "FadeTime": "00:00:04"}, } }, ) bridge.leap.requests.task_done() task.cancel() @pytest.mark.asyncio async def test_set_fan(bridge: Bridge): """Test that setting fan speed produces the right commands.""" task = asyncio.get_running_loop().create_task( bridge.target.set_fan("2", FAN_MEDIUM) ) command, _ = await bridge.leap.requests.get() assert command == Request( communique_type="CreateRequest", url="/zone/1/commandprocessor", body={ "Command": { "CommandType": "GoToFanSpeed", "FanSpeedParameters": {"FanSpeed": "Medium"}, } }, ) bridge.leap.requests.task_done() task.cancel() @pytest.mark.asyncio async def test_set_tilt(bridge: Bridge): """Test that setting tilt produces the right commands.""" task = asyncio.get_running_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): """Test that lowering a cover produces the right commands.""" devices = bridge.target.get_devices() task = asyncio.get_running_loop().create_task(bridge.target.lower_cover("7")) command, response = await bridge.leap.requests.get() assert command == Request( communique_type="CreateRequest", url="/zone/6/commandprocessor", body={"Command": {"CommandType": "Lower"}}, ) # the real response probably contains more data response.set_result( Response( CommuniqueType="CreateResponse", Header=ResponseHeader( StatusCode=ResponseStatus(201, "Created"), Url="/zone/6/commandprocessor", ), ), ) bridge.leap.requests.task_done() await task assert devices["7"]["current_state"] == 0 @pytest.mark.asyncio async def test_raise_cover(bridge: Bridge): """Test that raising a cover produces the right commands.""" devices = bridge.target.get_devices() task = asyncio.get_running_loop().create_task(bridge.target.raise_cover("7")) command, response = await bridge.leap.requests.get() assert command == Request( communique_type="CreateRequest", url="/zone/6/commandprocessor", body={"Command": {"CommandType": "Raise"}}, ) # the real response probably contains more data response.set_result( Response( CommuniqueType="CreateResponse", Header=ResponseHeader( StatusCode=ResponseStatus(201, "Created"), Url="/zone/6/commandprocessor", ), ), ) bridge.leap.requests.task_done() await task assert devices["7"]["current_state"] == 100 @pytest.mark.asyncio async def test_stop_cover(bridge: Bridge): """Test that stopping a cover produces the right commands.""" task = asyncio.get_running_loop().create_task(bridge.target.stop_cover("7")) command, _ = await bridge.leap.requests.get() assert command == Request( communique_type="CreateRequest", url="/zone/6/commandprocessor", body={"Command": {"CommandType": "Stop"}}, ) bridge.leap.requests.task_done() task.cancel() @pytest.mark.asyncio async def test_activate_scene(bridge: Bridge): """Test that activating scenes produces the right commands.""" task = asyncio.get_running_loop().create_task(bridge.target.activate_scene("1")) command, _ = await bridge.leap.requests.get() assert command == Request( communique_type="CreateRequest", url="/virtualbutton/1/commandprocessor", body={"Command": {"CommandType": "PressAndRelease"}}, ) bridge.leap.requests.task_done() task.cancel() @pytest.mark.asyncio async def test_reconnect_eof(bridge: Bridge): """Test that SmartBridge can reconnect on disconnect.""" time = 0.0 asyncio.get_running_loop().time = lambda: time # type: ignore [method-assign] bridge.disconnect() await asyncio.sleep(0.0) time += smartbridge.RECONNECT_DELAY await bridge.accept_connection() task = asyncio.get_running_loop().create_task(bridge.target.set_value("2", 50)) command, _ = await bridge.leap.requests.get() assert command is not None bridge.leap.requests.task_done() task.cancel() @pytest.mark.asyncio async def test_connect_error(): """Test that SmartBridge can retry failed connections.""" time = 0.0 asyncio.get_running_loop().time = lambda: time tried = asyncio.Event() async def fake_connect(): """Simulate connection error for the test.""" tried.set() raise OSError() target = smartbridge.Smartbridge(fake_connect) connect_task = asyncio.get_running_loop().create_task(target.connect()) await tried.wait() tried.clear() time += smartbridge.RECONNECT_DELAY await tried.wait() connect_task.cancel() @pytest.mark.asyncio async def test_reconnect_error(bridge: Bridge): """Test that SmartBridge can reconnect on error.""" time = 0.0 asyncio.get_running_loop().time = lambda: time # type: ignore [method-assign] bridge.disconnect() await asyncio.sleep(0.0) time += smartbridge.RECONNECT_DELAY await bridge.accept_connection() task = asyncio.get_running_loop().create_task(bridge.target.set_value("2", 50)) command, _ = await bridge.leap.requests.get() assert command is not None bridge.leap.requests.task_done() task.cancel() @pytest.mark.asyncio async def test_reconnect_timeout(): """Test that SmartBridge can reconnect if the remote does not respond.""" bridge = Bridge() time = 0.0 asyncio.get_running_loop().time = lambda: time await bridge.initialize() time = smartbridge.PING_INTERVAL ping, _ = await bridge.leap.requests.get() assert ping == Request(communique_type="ReadRequest", url="/server/1/status/ping") bridge.leap.requests.task_done() time += smartbridge.REQUEST_TIMEOUT await bridge.leap.running time += smartbridge.RECONNECT_DELAY await bridge.accept_connection() task = asyncio.get_running_loop().create_task(bridge.target.set_value("2", 50)) command, _ = await bridge.leap.requests.get() assert command is not None bridge.leap.requests.task_done() task.cancel() await bridge.target.close() @pytest.mark.asyncio async def test_is_ra3_connected(ra3_bridge: Bridge): """Test the is_connected method returns connection state.""" assert ra3_bridge.target.is_connected() is True async def connect(): raise NotImplementedError() other = smartbridge.Smartbridge(connect) assert other.is_connected() is False await ra3_bridge.target.close() @pytest.mark.asyncio async def test_ra3_notifications(ra3_bridge: Bridge): """Test notifications are sent to subscribers.""" notified = False def callback(): nonlocal notified notified = True ra3_bridge.target.add_subscriber("1377", callback) ra3_bridge.leap.send_unsolicited( Response( CommuniqueType="ReadResponse", Header=ResponseHeader( MessageBodyType="OneZoneStatus", StatusCode=ResponseStatus(200, "OK"), Url="/zone/1337/status", ), Body={"ZoneStatus": {"Level": 100, "Zone": {"href": "/zone/1377"}}}, ) ) await asyncio.wait_for(ra3_bridge.leap.requests.join(), 10) assert notified await ra3_bridge.target.close() @pytest.mark.asyncio async def test_ra3_device_list(ra3_bridge: Bridge): """Test methods getting devices.""" devices = ra3_bridge.target.get_devices() expected_devices = { "1": { "area": "83", "button_groups": None, "current_state": -1, "device_id": "1", "device_name": "Enclosure Device 001", "fan_speed": None, "model": "JanusProcRA3", "name": "Equipment Room Enclosure Device 001", "serial": 11111111, "type": "RadioRa3Processor", "zone": "1", }, "1361": { "area": "547", "button_groups": None, "current_state": 0, "device_id": "1361", "device_name": "Vanities", "fan_speed": None, "model": None, "name": "Primary Bath_Vanities", "serial": None, "tilt": None, "type": "Dimmed", "zone": "1361", "white_tuning_range": None, }, "1377": { "area": "547", "button_groups": None, "current_state": 0, "device_id": "1377", "device_name": "Shower & Tub", "fan_speed": None, "model": None, "name": "Primary Bath_Shower & Tub", "serial": None, "tilt": None, "type": "Dimmed", "zone": "1377", "white_tuning_range": None, }, "1393": { "area": "547", "button_groups": None, "current_state": 0, "device_id": "1393", "device_name": "Vent", "fan_speed": None, "model": None, "name": "Primary Bath_Vent", "serial": None, "tilt": None, "type": "Switched", "zone": "1393", "white_tuning_range": None, }, "1488": { "area": "547", "button_groups": ["1491"], "control_station_name": "Entry", "current_state": -1, "device_id": "1488", "device_name": "Audio Pico", "fan_speed": None, "model": "PJ2-3BRL-XXX-A02", "name": "Primary Bath_Entry Audio Pico Pico", "serial": None, "type": "Pico3ButtonRaiseLower", "zone": None, }, "2010": { "area": "2796", "button_groups": None, "current_state": 0, "device_id": "2010", "device_name": "Porch", "fan_speed": None, "model": None, "name": "Porch_Porch", "serial": None, "tilt": None, "type": "Dimmed", "zone": "2010", "white_tuning_range": None, }, "2091": { "area": "766", "button_groups": None, "current_state": 0, "device_id": "2091", "device_name": "Overhead", "fan_speed": None, "model": None, "name": "Entry_Overhead", "serial": None, "tilt": None, "type": "Dimmed", "zone": "2091", "white_tuning_range": None, }, "2107": { "area": "766", "button_groups": None, "current_state": 0, "device_id": "2107", "device_name": "Landscape", "fan_speed": None, "model": None, "name": "Entry_Landscape", "serial": None, "tilt": None, "type": "Dimmed", "zone": "2107", "white_tuning_range": None, }, "2139": { "area": "766", "button_groups": ["2148"], "control_station_name": "Entry by Living Room", "current_state": -1, "device_id": "2139", "device_name": "Scene Keypad", "fan_speed": None, "model": "RRST-W4B-XX", "name": "Entry_Entry by Living Room Scene Keypad Keypad", "serial": None, "type": "SunnataKeypad", "zone": None, }, "2144": { "current_state": -1, "device_id": "2144", "device_name": "Bright LED", "fan_speed": None, "model": "KeypadLED", "name": "Entry_Entry by Living Room Scene Keypad Keypad Bright LED", "parent_device": "2139", "serial": None, "type": "KeypadLED", "zone": None, }, "2145": { "current_state": -1, "device_id": "2145", "device_name": "Entertain LED", "fan_speed": None, "model": "KeypadLED", "name": "Entry_Entry by Living Room Scene Keypad Keypad Entertain LED", "parent_device": "2139", "serial": None, "type": "KeypadLED", "zone": None, }, "2146": { "current_state": -1, "device_id": "2146", "device_name": "Dining LED", "fan_speed": None, "model": "KeypadLED", "name": "Entry_Entry by Living Room Scene Keypad Keypad Dining LED", "parent_device": "2139", "serial": None, "type": "KeypadLED", "zone": None, }, "2147": { "current_state": -1, "device_id": "2147", "device_name": "Off LED", "fan_speed": None, "model": "KeypadLED", "name": "Entry_Entry by Living Room Scene Keypad Keypad Off LED", "parent_device": "2139", "serial": None, "type": "KeypadLED", "zone": None, }, "2171": { "area": "766", "button_groups": ["2180"], "control_station_name": "Entry by Living Room", "current_state": -1, "device_id": "2171", "device_name": "Fan Keypad", "fan_speed": None, "model": "RRST-W4B-XX", "name": "Entry_Entry by Living Room Fan Keypad Keypad", "serial": None, "type": "SunnataKeypad", "zone": None, }, "2176": { "current_state": -1, "device_id": "2176", "device_name": "Fan High LED", "fan_speed": None, "model": "KeypadLED", "name": "Entry_Entry by Living Room Fan Keypad Keypad Fan High LED", "parent_device": "2171", "serial": None, "type": "KeypadLED", "zone": None, }, "2177": { "current_state": -1, "device_id": "2177", "device_name": "Medium LED", "fan_speed": None, "model": "KeypadLED", "name": "Entry_Entry by Living Room Fan Keypad Keypad Medium LED", "parent_device": "2171", "serial": None, "type": "KeypadLED", "zone": None, }, "2178": { "current_state": -1, "device_id": "2178", "device_name": "Low LED", "fan_speed": None, "model": "KeypadLED", "name": "Entry_Entry by Living Room Fan Keypad Keypad Low LED", "parent_device": "2171", "serial": None, "type": "KeypadLED", "zone": None, }, "2179": { "current_state": -1, "device_id": "2179", "device_name": "Off LED", "fan_speed": None, "model": "KeypadLED", "name": "Entry_Entry by Living Room Fan Keypad Keypad Off LED", "parent_device": "2171", "serial": None, "type": "KeypadLED", "zone": None, }, "2939": { "area": "547", "button_groups": ["2942"], "control_station_name": "Vanity", "current_state": -1, "device_id": "2939", "device_name": "Audio Pico", "fan_speed": None, "model": "PJ2-3BRL-XXX-A02", "name": "Primary Bath_Vanity Audio Pico Pico", "serial": None, "type": "Pico3ButtonRaiseLower", "zone": None, }, "5341": { "area": "83", "button_groups": ["5344"], "control_station_name": "TestingPico", "current_state": -1, "device_id": "5341", "device_name": "TestingPicoDev", "fan_speed": None, "model": "PJ2-3BRL-XXX-L01", "name": "Equipment Room_TestingPico TestingPicoDev Pico", "serial": 68130838, "type": "Pico3ButtonRaiseLower", "zone": None, }, "536": { "area": "83", "button_groups": None, "current_state": 0, "device_id": "536", "device_name": "Overhead", "fan_speed": None, "model": None, "name": "Equipment Room_Overhead", "serial": None, "tilt": None, "type": "Switched", "zone": "536", "white_tuning_range": None, }, } assert devices == expected_devices ra3_bridge.leap.send_unsolicited( Response( CommuniqueType="ReadResponse", Header=ResponseHeader( MessageBodyType="OneZoneStatus", StatusCode=ResponseStatus(200, "OK"), Url="/zone/1377/status", ), Body={"ZoneStatus": {"Level": 100, "Zone": {"href": "/zone/1377"}}}, ) ) devices = ra3_bridge.target.get_devices() assert devices["1377"]["current_state"] == 100 assert devices["1488"]["current_state"] == -1 devices = ra3_bridge.target.get_devices_by_domain("light") assert len(devices) == 5 assert devices[0]["device_id"] == "1361" devices = ra3_bridge.target.get_devices_by_type("Dimmed") assert len(devices) == 5 assert devices[0]["device_id"] == "1361" devices = ra3_bridge.target.get_devices_by_types( ("Pico3ButtonRaiseLower", "Dimmed") ) assert len(devices) == 8 device = ra3_bridge.target.get_device_by_id("2939") assert device["device_id"] == "2939" devices = ra3_bridge.target.get_devices_by_domain("fan") assert len(devices) == 0 devices = ra3_bridge.target.get_devices_by_type("CasetaFanSpeedController") assert len(devices) == 0 await ra3_bridge.target.close() @pytest.mark.asyncio async def test_ra3_area_list(ra3_bridge: Bridge): """Test the list of areas loaded by the bridge.""" expected_areas = { "3": {"id": "3", "name": "Home", "parent_id": None}, "2796": {"id": "2796", "name": "Porch", "parent_id": "3"}, "547": {"id": "547", "name": "Primary Bath", "parent_id": "3"}, "766": {"id": "766", "name": "Entry", "parent_id": "3"}, "83": {"id": "83", "name": "Equipment Room", "parent_id": "3"}, } assert ra3_bridge.target.areas == expected_areas await ra3_bridge.target.close() @pytest.mark.asyncio async def test_ra3_button_status_change(ra3_bridge: Bridge): """Test that the status is updated when Pico button is pressed.""" ra3_bridge.leap.send_to_subscribers( Response( CommuniqueType="ReadResponse", Header=ResponseHeader( MessageBodyType="OneButtonStatusEvent", StatusCode=ResponseStatus(200, "OK"), Url="/button/2946/status/event", ), Body={ "ButtonStatus": { "Button": {"href": "/button/2946"}, "ButtonEvent": {"EventType": "Press"}, } }, ) ) new_status = ra3_bridge.target.buttons["2946"]["current_state"] assert new_status == BUTTON_STATUS_PRESSED await ra3_bridge.target.close() @pytest.mark.asyncio async def test_ra3_button_status_change_notification(ra3_bridge: Bridge): """Test that button status changes send notifications.""" notified = False def notify(status): assert status == BUTTON_STATUS_PRESSED nonlocal notified notified = True ra3_bridge.target.add_button_subscriber("2946", notify) ra3_bridge.leap.send_to_subscribers( Response( CommuniqueType="ReadResponse", Header=ResponseHeader( MessageBodyType="OneButtonStatusEvent", StatusCode=ResponseStatus(200, "OK"), Url="/button/2946/status/event", ), Body={ "ButtonStatus": { "Button": {"href": "/button/2946"}, "ButtonEvent": {"EventType": "Press"}, } }, ) ) assert notified await ra3_bridge.target.close() @pytest.mark.asyncio async def test_ra3_is_on(ra3_bridge: Bridge): """Test the is_on method returns device state.""" ra3_bridge.leap.send_unsolicited( Response( CommuniqueType="ReadResponse", Header=ResponseHeader( MessageBodyType="OneZoneStatus", StatusCode=ResponseStatus(200, "OK"), Url="/zone/2107/status", ), Body={"ZoneStatus": {"Level": 50, "Zone": {"href": "/zone/2107"}}}, ) ) assert ra3_bridge.target.is_on("2107") is True ra3_bridge.leap.send_unsolicited( Response( CommuniqueType="ReadResponse", Header=ResponseHeader( MessageBodyType="OneZoneStatus", StatusCode=ResponseStatus(200, "OK"), Url="/zone/2107/status", ), Body={"ZoneStatus": {"Level": 0, "Zone": {"href": "/zone/2107"}}}, ) ) assert ra3_bridge.target.is_on("2107") is False await ra3_bridge.target.close() @pytest.mark.asyncio async def test_ra3_set_value(ra3_bridge: Bridge): """Test that setting values produces the right commands.""" task = asyncio.get_running_loop().create_task( ra3_bridge.target.set_value("2107", 50) ) command, response = await ra3_bridge.leap.requests.get() assert command == Request( communique_type="CreateRequest", url="/zone/2107/commandprocessor", body={ "Command": { "CommandType": "GoToLevel", "Parameter": [{"Type": "Level", "Value": 50}], } }, ) response.set_result( Response( CommuniqueType="CreateResponse", Header=ResponseHeader( MessageBodyType="OneZoneStatus", StatusCode=ResponseStatus(201, "Created"), Url="/zone/2107/commandprocessor", ), Body={ "ZoneStatus": { "href": "/zone/2107/status", "Level": 50, "Zone": {"href": "/zone/2107"}, } }, ) ) ra3_bridge.leap.requests.task_done() await task task = asyncio.get_running_loop().create_task(ra3_bridge.target.turn_on("2107")) command, response = await ra3_bridge.leap.requests.get() assert command == Request( communique_type="CreateRequest", url="/zone/2107/commandprocessor", body={ "Command": { "CommandType": "GoToLevel", "Parameter": [{"Type": "Level", "Value": 100}], } }, ) response.set_result( Response( CommuniqueType="CreateResponse", Header=ResponseHeader( MessageBodyType="OneZoneStatus", StatusCode=ResponseStatus(201, "Created"), Url="/zone/2107/commandprocessor", ), Body={ "ZoneStatus": { "href": "/zone/2107/status", "Level": 100, "Zone": {"href": "/zone/2107"}, } }, ), ) ra3_bridge.leap.requests.task_done() await task task = asyncio.get_running_loop().create_task(ra3_bridge.target.turn_off("2107")) command, response = await ra3_bridge.leap.requests.get() assert command == Request( communique_type="CreateRequest", url="/zone/2107/commandprocessor", body={ "Command": { "CommandType": "GoToLevel", "Parameter": [{"Type": "Level", "Value": 0}], } }, ) response.set_result( Response( CommuniqueType="CreateResponse", Header=ResponseHeader( MessageBodyType="OneZoneStatus", StatusCode=ResponseStatus(201, "Created"), Url="/zone/2107/commandprocessor", ), Body={ "ZoneStatus": { "href": "/zone/2107/status", "Level": 0, "Zone": {"href": "/zone/2107"}, } }, ), ) ra3_bridge.leap.requests.task_done() await task await ra3_bridge.target.close() @pytest.mark.asyncio async def test_ra3_set_value_with_fade(ra3_bridge: Bridge): """Test that setting values with fade_time produces the right commands.""" task = asyncio.get_running_loop().create_task( ra3_bridge.target.set_value("2107", 50, fade_time=timedelta(seconds=4)) ) command, _ = await ra3_bridge.leap.requests.get() assert command == Request( communique_type="CreateRequest", url="/zone/2107/commandprocessor", body={ "Command": { "CommandType": "GoToDimmedLevel", "DimmedLevelParameters": {"Level": 50, "FadeTime": "00:00:04"}, } }, ) ra3_bridge.leap.requests.task_done() task.cancel() await ra3_bridge.target.close() @pytest.mark.asyncio async def test_qsx_set_keypad_led_value(qsx_processor: Bridge): """Test that setting the value of a keypad LED produces the right command.""" task = asyncio.get_running_loop().create_task( qsx_processor.target.set_value("1631", 50) ) command, _ = await qsx_processor.leap.requests.get() assert command == Request( communique_type="UpdateRequest", url="/led/1631/status", body={"LEDStatus": {"State": "On"}}, ) qsx_processor.leap.requests.task_done() task.cancel() await qsx_processor.target.close() @pytest.mark.asyncio async def test_qsx_set_whitetune_level(qsx_processor: Bridge): """ Test that setting the level of a White Tune zone without a fade time produces the right command. """ task = asyncio.get_running_loop().create_task( qsx_processor.target.set_value("989", 50) ) command, _ = await qsx_processor.leap.requests.get() assert command == Request( communique_type="CreateRequest", url="/zone/989/commandprocessor", body={ "Command": { "CommandType": "GoToWhiteTuningLevel", "WhiteTuningLevelParameters": {"Level": 50}, } }, ) qsx_processor.leap.requests.task_done() task.cancel() await qsx_processor.target.close() @pytest.mark.asyncio async def test_qsx_set_whitetune_temperature(qsx_processor: Bridge): """ Test that setting the temperature of a lumaris device produces the right command. """ kelvin = 2700 color = color_value.WarmCoolColorValue(kelvin) task = asyncio.get_running_loop().create_task( qsx_processor.target.set_value("989", color_value=color) ) command, _ = await qsx_processor.leap.requests.get() assert command == Request( communique_type="CreateRequest", url="/zone/989/commandprocessor", body={ "Command": { "CommandType": "GoToWhiteTuningLevel", "WhiteTuningLevelParameters": {"WhiteTuningLevel": {"Kelvin": kelvin}}, } }, ) qsx_processor.leap.requests.task_done() task.cancel() task = asyncio.get_running_loop().create_task( qsx_processor.target.set_warm_dim("989", True) ) command, _ = await qsx_processor.leap.requests.get() assert command == Request( communique_type="CreateRequest", url="/zone/989/commandprocessor", body={ "Command": { "CommandType": "GoToWarmDim", "WarmDimParameters": {"CurveDimming": {"Curve": {"href": "/curve/1"}}}, } }, ) qsx_processor.leap.requests.task_done() task.cancel() await qsx_processor.target.close() @pytest.mark.asyncio async def test_qsx_set_ketra_level(qsx_processor: Bridge): """ Test that setting the level of a Ketra lamp without a fade time produces the right command. """ task = asyncio.get_running_loop().create_task( qsx_processor.target.set_value("985", 50) ) command, _ = await qsx_processor.leap.requests.get() assert command == Request( communique_type="CreateRequest", url="/zone/985/commandprocessor", body={ "Command": { "CommandType": "GoToSpectrumTuningLevel", "SpectrumTuningLevelParameters": {"Level": 50}, } }, ) qsx_processor.leap.requests.task_done() task.cancel() await qsx_processor.target.close() @pytest.mark.asyncio async def test_qsx_set_ketra_color(qsx_processor: Bridge): """ Test that setting the color of a Ketra lamp produces the right command. """ hue = 150 saturation = 30 full_color = color_value.FullColorValue(hue, saturation) task = asyncio.get_running_loop().create_task( qsx_processor.target.set_value("985", color_value=full_color) ) command, _ = await qsx_processor.leap.requests.get() assert command == Request( communique_type="CreateRequest", url="/zone/985/commandprocessor", body={ "Command": { "CommandType": "GoToSpectrumTuningLevel", "SpectrumTuningLevelParameters": { "ColorTuningStatus": { "HSVTuningLevel": {"Hue": hue, "Saturation": saturation} } }, } }, ) qsx_processor.leap.requests.task_done() task.cancel() kelvin = 2700 warm_color = color_value.WarmCoolColorValue(kelvin) task = asyncio.get_running_loop().create_task( qsx_processor.target.set_value("985", color_value=warm_color) ) command, _ = await qsx_processor.leap.requests.get() assert command == Request( communique_type="CreateRequest", url="/zone/985/commandprocessor", body={ "Command": { "CommandType": "GoToSpectrumTuningLevel", "SpectrumTuningLevelParameters": { "ColorTuningStatus": {"WhiteTuningLevel": {"Kelvin": kelvin}} }, } }, ) qsx_processor.leap.requests.task_done() task.cancel() task = asyncio.get_running_loop().create_task( qsx_processor.target.set_warm_dim("985", True) ) command, _ = await qsx_processor.leap.requests.get() assert command == Request( communique_type="CreateRequest", url="/zone/985/commandprocessor", body={ "Command": { "CommandType": "GoToSpectrumTuningLevel", "SpectrumTuningLevelParameters": { "ColorTuningStatus": { "CurveDimming": {"Curve": {"href": "/curve/1"}} } }, } }, ) qsx_processor.leap.requests.task_done() task.cancel() await qsx_processor.target.close() @pytest.mark.asyncio async def test_qsx_set_ketra_level_with_fade(qsx_processor: Bridge): """ Test that setting the level of a Ketra lamp with a fade time produces the right command. """ task = asyncio.get_running_loop().create_task( qsx_processor.target.set_value("985", 50, fade_time=timedelta(seconds=4)) ) command, _ = await qsx_processor.leap.requests.get() assert command == Request( communique_type="CreateRequest", url="/zone/985/commandprocessor", body={ "Command": { "CommandType": "GoToSpectrumTuningLevel", "SpectrumTuningLevelParameters": {"Level": 50, "FadeTime": "00:00:04"}, } }, ) qsx_processor.leap.requests.task_done() task.cancel() await qsx_processor.target.close() @pytest.mark.asyncio async def test_qsx_set_LumarisRGB_color(qsx_processor: Bridge): """ Test that setting the color of a Lumaris RGB produces the right command. """ hue = 150 saturation = 30 full_color = color_value.FullColorValue(hue, saturation) task = asyncio.get_running_loop().create_task( qsx_processor.target.set_value("991", color_value=full_color) ) command, _ = await qsx_processor.leap.requests.get() assert command == Request( communique_type="CreateRequest", url="/zone/991/commandprocessor", body={ "Command": { "CommandType": "GoToSpectrumTuningLevel", "SpectrumTuningLevelParameters": { "ColorTuningStatus": { "HSVTuningLevel": {"Hue": hue, "Saturation": saturation} } }, } }, ) qsx_processor.leap.requests.task_done() task.cancel() kelvin = 2700 warm_color = color_value.WarmCoolColorValue(kelvin) task = asyncio.get_running_loop().create_task( qsx_processor.target.set_value("991", color_value=warm_color) ) command, _ = await qsx_processor.leap.requests.get() assert command == Request( communique_type="CreateRequest", url="/zone/991/commandprocessor", body={ "Command": { "CommandType": "GoToSpectrumTuningLevel", "SpectrumTuningLevelParameters": { "ColorTuningStatus": {"WhiteTuningLevel": {"Kelvin": kelvin}} }, } }, ) qsx_processor.leap.requests.task_done() task.cancel() task = asyncio.get_running_loop().create_task( qsx_processor.target.set_warm_dim("991", True) ) command, _ = await qsx_processor.leap.requests.get() assert command == Request( communique_type="CreateRequest", url="/zone/991/commandprocessor", body={ "Command": { "CommandType": "GoToSpectrumTuningLevel", "SpectrumTuningLevelParameters": { "ColorTuningStatus": { "CurveDimming": {"Curve": {"href": "/curve/1"}} } }, } }, ) qsx_processor.leap.requests.task_done() task.cancel() await qsx_processor.target.close() @pytest.mark.asyncio async def test_qsx_tap_button(qsx_processor: Bridge): """Test that tapping a keypad button produces the right command.""" task = asyncio.get_running_loop().create_task( qsx_processor.target.tap_button("1422") ) command, _ = await qsx_processor.leap.requests.get() assert command == Request( communique_type="CreateRequest", url="/button/1422/commandprocessor", body={ "Command": { "CommandType": "PressAndRelease", } }, ) qsx_processor.leap.requests.task_done() task.cancel() await qsx_processor.target.close() @pytest.mark.asyncio async def test_qsx_button_led_notification(qsx_processor: Bridge): """Test button LED status events are sent to subscribers.""" notified = False def callback(): nonlocal notified notified = True qsx_processor.target.add_subscriber("1631", callback) qsx_processor.leap.send_unsolicited( Response( CommuniqueType="ReadResponse", Header=ResponseHeader( MessageBodyType="OneLEDStatus", StatusCode=ResponseStatus(200, "OK"), Url="/led/1631/status", ), Body={ "LEDStatus": { "href": "/led/1631/status", "LED": {"href": "/led/1631"}, "State": "On", } }, ) ) await asyncio.wait_for(qsx_processor.leap.requests.join(), 10) assert notified @pytest.mark.asyncio async def test_qsx_get_buttons(qsx_processor: Bridge): """Test that the get_buttons function returns the expected value.""" buttons = qsx_processor.target.get_buttons() expected_buttons = { "1422": { "button_group": "1421", "button_led": "1414", "button_name": "Button 1", "button_number": 1, "current_state": "Release", "device_id": "1422", "device_name": "Button 1", "model": "Homeowner Keypad", "name": "Equipment Room_Homeowner Keypad Loc Ho Kpd Keypad", "parent_device": "1409", "serial": None, "type": "HomeownerKeypad", }, "1425": { "button_group": "1421", "button_led": "1415", "button_name": "Button 2", "button_number": 2, "current_state": "Release", "device_id": "1425", "device_name": "Button 2", "model": "Homeowner Keypad", "name": "Equipment Room_Homeowner Keypad Loc Ho Kpd Keypad", "parent_device": "1409", "serial": None, "type": "HomeownerKeypad", }, "1428": { "button_group": "1421", "button_led": "1416", "button_name": "Button 3", "button_number": 3, "current_state": "Release", "device_id": "1428", "device_name": "Button 3", "model": "Homeowner Keypad", "name": "Equipment Room_Homeowner Keypad Loc Ho Kpd Keypad", "parent_device": "1409", "serial": None, "type": "HomeownerKeypad", }, "1431": { "button_group": "1421", "button_led": "1417", "button_name": "Button 4", "button_number": 4, "current_state": "Release", "device_id": "1431", "device_name": "Button 4", "model": "Homeowner Keypad", "name": "Equipment Room_Homeowner Keypad Loc Ho Kpd Keypad", "parent_device": "1409", "serial": None, "type": "HomeownerKeypad", }, "1434": { "button_group": "1421", "button_led": "1418", "button_name": "Button 5", "button_number": 5, "current_state": "Release", "device_id": "1434", "device_name": "Button 5", "model": "Homeowner Keypad", "name": "Equipment Room_Homeowner Keypad Loc Ho Kpd Keypad", "parent_device": "1409", "serial": None, "type": "HomeownerKeypad", }, "1437": { "button_group": "1421", "button_led": "1419", "button_name": "Button 6", "button_number": 6, "current_state": "Release", "device_id": "1437", "device_name": "Button 6", "model": "Homeowner Keypad", "name": "Equipment Room_Homeowner Keypad Loc Ho Kpd Keypad", "parent_device": "1409", "serial": None, "type": "HomeownerKeypad", }, "1440": { "button_group": "1421", "button_led": "1420", "button_name": "Vacation Mode", "button_number": 7, "current_state": "Release", "device_id": "1440", "device_name": "Vacation Mode", "model": "Homeowner Keypad", "name": "Equipment Room_Homeowner Keypad Loc Ho Kpd Keypad", "parent_device": "1409", "serial": None, "type": "HomeownerKeypad", }, "1520": { "button_group": "1519", "button_led": "1517", "button_name": "Welcome", "button_number": 1, "current_state": "Release", "device_id": "1520", "device_name": "Welcome", "model": "HQWT-U-P2W", "name": "Foyer_Front Door Keypad 2 Keypad", "parent_device": "1512", "serial": None, "type": "PalladiomKeypad", }, "1524": { "button_group": "1519", "button_led": "1518", "button_name": "Goodbye", "button_number": 4, "current_state": "Release", "device_id": "1524", "device_name": "Goodbye", "model": "HQWT-U-P2W", "name": "Foyer_Front Door Keypad 2 Keypad", "parent_device": "1512", "serial": None, "type": "PalladiomKeypad", }, "1602": { "button_group": "1601", "button_led": "1597", "button_name": "Living Room", "button_number": 1, "current_state": "Release", "device_id": "1602", "device_name": "Living Room", "model": "HQWT-U-P4W", "name": "Living Room_Entryway Device 1 Keypad", "parent_device": "1592", "serial": None, "type": "PalladiomKeypad", }, "1606": { "button_group": "1601", "button_led": "1598", "button_name": "Shades", "button_number": 2, "current_state": "Release", "device_id": "1606", "device_name": "Shades", "model": "HQWT-U-P4W", "name": "Living Room_Entryway Device 1 Keypad", "parent_device": "1592", "serial": None, "type": "PalladiomKeypad", }, "1610": { "button_group": "1601", "button_led": "1599", "button_name": "Entertain", "button_number": 3, "current_state": "Release", "device_id": "1610", "device_name": "Entertain", "model": "HQWT-U-P4W", "name": "Living Room_Entryway Device 1 Keypad", "parent_device": "1592", "serial": None, "type": "PalladiomKeypad", }, "1614": { "button_group": "1601", "button_led": "1600", "button_name": "Relax", "button_number": 4, "current_state": "Release", "device_id": "1614", "device_name": "Relax", "model": "HQWT-U-P4W", "name": "Living Room_Entryway Device 1 Keypad", "parent_device": "1592", "serial": None, "type": "PalladiomKeypad", }, "1636": { "button_group": "1635", "button_led": "1631", "button_name": "Bedroom", "button_number": 1, "current_state": "Release", "device_id": "1636", "device_name": "Bedroom", "model": "HQWT-U-P4W", "name": "Bedroom 1_Entryway Device 1 Keypad", "parent_device": "1626", "serial": None, "type": "PalladiomKeypad", }, "1640": { "button_group": "1635", "button_led": "1632", "button_name": "Shades", "button_number": 2, "current_state": "Release", "device_id": "1640", "device_name": "Shades", "model": "HQWT-U-P4W", "name": "Bedroom 1_Entryway Device 1 Keypad", "parent_device": "1626", "serial": None, "type": "PalladiomKeypad", }, "1644": { "button_group": "1635", "button_led": "1633", "button_name": "Bright", "button_number": 3, "current_state": "Release", "device_id": "1644", "device_name": "Bright", "model": "HQWT-U-P4W", "name": "Bedroom 1_Entryway Device 1 Keypad", "parent_device": "1626", "serial": None, "type": "PalladiomKeypad", }, "1648": { "button_group": "1635", "button_led": "1634", "button_name": "Dimmed", "button_number": 4, "current_state": "Release", "device_id": "1648", "device_name": "Dimmed", "model": "HQWT-U-P4W", "name": "Bedroom 1_Entryway Device 1 Keypad", "parent_device": "1626", "serial": None, "type": "PalladiomKeypad", }, "1670": { "button_group": "1669", "button_led": "1665", "button_name": "Bathroom", "button_number": 1, "current_state": "Release", "device_id": "1670", "device_name": "Bathroom", "model": "HQWT-U-P4W", "name": "Bathroom 1_Entryway Device 1 Keypad", "parent_device": "1660", "serial": None, "type": "PalladiomKeypad", }, "1674": { "button_group": "1669", "button_led": "1666", "button_name": "Fan", "button_number": 2, "current_state": "Release", "device_id": "1674", "device_name": "Fan", "model": "HQWT-U-P4W", "name": "Bathroom 1_Entryway Device 1 Keypad", "parent_device": "1660", "serial": None, "type": "PalladiomKeypad", }, "1678": { "button_group": "1669", "button_led": "1667", "button_name": "Bright", "button_number": 3, "current_state": "Release", "device_id": "1678", "device_name": "Bright", "model": "HQWT-U-P4W", "name": "Bathroom 1_Entryway Device 1 Keypad", "parent_device": "1660", "serial": None, "type": "PalladiomKeypad", }, "1682": { "button_group": "1669", "button_led": "1668", "button_name": "Dimmed", "button_number": 4, "current_state": "Release", "device_id": "1682", "device_name": "Dimmed", "model": "HQWT-U-P4W", "name": "Bathroom 1_Entryway Device 1 Keypad", "parent_device": "1660", "serial": None, "type": "PalladiomKeypad", }, "861": { "button_group": "860", "button_led": "856", "button_name": "Foyer", "button_number": 1, "current_state": "Release", "device_id": "861", "device_name": "Foyer", "model": "HQWT-U-P4W", "name": "Foyer_Front Door Keypad 1 Keypad", "parent_device": "851", "serial": None, "type": "PalladiomKeypad", }, "865": { "button_group": "860", "button_led": "857", "button_name": "Shades", "button_number": 2, "current_state": "Release", "device_id": "865", "device_name": "Shades", "model": "HQWT-U-P4W", "name": "Foyer_Front Door Keypad 1 Keypad", "parent_device": "851", "serial": None, "type": "PalladiomKeypad", }, "869": { "button_group": "860", "button_led": "858", "button_name": "Entertain", "button_number": 3, "current_state": "Release", "device_id": "869", "device_name": "Entertain", "model": "HQWT-U-P4W", "name": "Foyer_Front Door Keypad 1 Keypad", "parent_device": "851", "serial": None, "type": "PalladiomKeypad", }, "873": { "button_group": "860", "button_led": "859", "button_name": "Dimmed", "button_number": 4, "current_state": "Release", "device_id": "873", "device_name": "Dimmed", "model": "HQWT-U-P4W", "name": "Foyer_Front Door Keypad 1 Keypad", "parent_device": "851", "serial": None, "type": "PalladiomKeypad", }, } assert buttons == expected_buttons @pytest.mark.asyncio async def test_get_devices_by_invalid_domain(bridge: Bridge): """Tests that getting devices for an invalid domain returns an empty list.""" devices = bridge.target.get_devices_by_domain("this_is_an_invalid_domain") assert devices == [] @pytest.mark.asyncio async def test_qsx_get_devices_for_invalid_zone(qsx_processor: Bridge): """Tests that getting devices for an invalid zone raises an exception.""" try: _ = qsx_processor.target.get_device_by_zone_id("2") assert False except KeyError: assert True @pytest.mark.asyncio async def test_ra3_occupancy_group_list(ra3_bridge: Bridge): """Test the list of occupancy groups loaded by the bridge.""" # Occupancy group 766 has multiple sensor devices, but should only appear once expected_groups = { "766": { "area": "766", "occupancy_group_id": "766", "name": "Entry Occupancy", "status": OCCUPANCY_GROUP_UNKNOWN, "sensors": ["1870", "1888"], "device_name": "Occupancy", }, "2796": { "area": "2796", "occupancy_group_id": "2796", "name": "Porch Occupancy", "status": OCCUPANCY_GROUP_UNKNOWN, "sensors": ["1970"], "device_name": "Occupancy", }, } assert ra3_bridge.target.occupancy_groups == expected_groups @pytest.mark.asyncio async def test_on_connect_callback() -> None: """Test that the on connect callback is called.""" loop = asyncio.get_running_loop() init_future: asyncio.Future[None] = loop.create_future() def _on_connect_callback() -> None: init_future.set_result(None) bridge = Bridge(on_connect_callback=_on_connect_callback) init_task = asyncio.create_task(bridge.initialize(CASETA_PROCESSOR)) await init_future init_task.cancel() with pytest.raises(asyncio.CancelledError): await init_task await bridge.target.close()