pax_global_header00006660000000000000000000000064151467505330014523gustar00rootroot0000000000000052 comment=5334f51fda0fa8131fbe8942e9f0f371e689a482 lawtancool-pyControl4-5334f51/000077500000000000000000000000001514675053300162155ustar00rootroot00000000000000lawtancool-pyControl4-5334f51/.flake8000066400000000000000000000000361514675053300173670ustar00rootroot00000000000000[flake8] max-line-length = 88 lawtancool-pyControl4-5334f51/.github/000077500000000000000000000000001514675053300175555ustar00rootroot00000000000000lawtancool-pyControl4-5334f51/.github/workflows/000077500000000000000000000000001514675053300216125ustar00rootroot00000000000000lawtancool-pyControl4-5334f51/.github/workflows/CI.yml000066400000000000000000000031141514675053300226270ustar00rootroot00000000000000# This is a basic workflow to help you get started with Actions name: CI # Controls when the action will run. Triggers the workflow on push or pull request # events but only for the master branch on: push: pull_request: # A workflow run is made up of one or more jobs that can run sequentially or in parallel jobs: build: name: Lint code with flake8 runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - name: Set up Python uses: actions/setup-python@v5.6.0 - name: Install dependencies run: | python -m pip install --upgrade pip pip install -r requirements.txt - name: Setup flake8 annotations uses: rbialon/flake8-annotations@v1 - name: Lint with flake8 run: | pip install flake8 # stop the build if there are Python syntax errors or undefined names flake8 ./pyControl4 --count --select=E9,F63,F7,F82 --show-source --statistics # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide flake8 ./pyControl4 --count --ignore=E501 --exit-zero --max-complexity=10 --max-line-length=127 --statistics black: name: Check code formatting with black # The type of runner that the job will run on runs-on: ubuntu-latest # Steps represent a sequence of tasks that will be executed as part of the job steps: # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it - uses: actions/checkout@v2 # Runs a single command using the runners shell - name: Black Code Formatter uses: psf/black@stable lawtancool-pyControl4-5334f51/.github/workflows/codeql-analysis.yml000066400000000000000000000031351514675053300254270ustar00rootroot00000000000000name: "CodeQL" on: push: pull_request: # The branches below must be a subset of the branches above schedule: - cron: '0 18 * * 6' jobs: analyse: name: Analyse runs-on: ubuntu-latest steps: - name: Checkout repository uses: actions/checkout@v2 with: # We must fetch at least the immediate parents so that if this is # a pull request then we can checkout the head. fetch-depth: 2 # If this run was triggered by a pull request event, then checkout # the head of the pull request instead of the merge commit. - run: git checkout HEAD^2 if: ${{ github.event_name == 'pull_request' }} # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL uses: github/codeql-action/init@v1 # Override language selection by uncommenting this and choosing your languages # with: # languages: go, javascript, csharp, python, cpp, java # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild uses: github/codeql-action/autobuild@v1 # ℹ️ Command-line programs to run using the OS shell. # 📚 https://git.io/JvXDl # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines # and modify them (or add more) to build your code if your project # uses a compiled language #- run: | # make bootstrap # make release - name: Perform CodeQL Analysis uses: github/codeql-action/analyze@v1 lawtancool-pyControl4-5334f51/.github/workflows/pdoc.yml000066400000000000000000000030701514675053300232620ustar00rootroot00000000000000# This is a basic workflow to help you get started with Actions name: pdoc # Controls when the action will run. Triggers the workflow on push or pull request # events but only for the master branch on: push: branches: [ master ] # A workflow run is made up of one or more jobs that can run sequentially or in parallel jobs: # This workflow contains a single job called "build" generate-documentation: # The type of runner that the job will run on runs-on: ubuntu-latest # Steps represent a sequence of tasks that will be executed as part of the job steps: # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it - uses: actions/checkout@v2 - name: Setup Python uses: actions/setup-python@v2 - name: Install pdoc3 uses: BSFishy/pip-action@v1 with: # The packages to install from Pip requirements: requirements-dev.txt # Runs a set of commands using the runners shell - name: Run pdoc run: pdoc --html pyControl4/ --output-dir docs-temp/ --force - name: Copy HTML files to root of docs/ folder run: cp -r docs-temp/pyControl4/. docs/ && rm -r docs-temp/pyControl4/ - name: Commit files run: | git config --local user.email "action@github.com" git config --local user.name "GitHub Action" git add --all git diff-index --quiet HEAD || git commit -am "Update documentation" - name: Push changes uses: ad-m/github-push-action@master with: github_token: ${{ secrets.GITHUB_TOKEN }} lawtancool-pyControl4-5334f51/.github/workflows/pytest.yml000066400000000000000000000014501514675053300236650ustar00rootroot00000000000000# This workflow will install Python dependencies, run tests with a single version of Python # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python name: Python application on: push: branches: [ "master" ] pull_request: branches: [ "master" ] permissions: contents: read jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Set up Python 3.11 uses: actions/setup-python@v3 with: python-version: "3.11" - name: Install dependencies run: | python -m pip install --upgrade pip pip install pytest if [ -f requirements-dev.txt ]; then pip install -r requirements-dev.txt; fi - name: Test with pytest run: | pytest lawtancool-pyControl4-5334f51/.github/workflows/pythonpublish.yml000066400000000000000000000015041514675053300252450ustar00rootroot00000000000000# This workflows will upload a Python Package using Twine when a release is created # For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries name: PyPI Release on: release: types: [published] jobs: deploy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - name: Set up Python uses: actions/setup-python@v1 with: python-version: '3.x' - name: Install dependencies run: | python -m pip install --upgrade pip pip install setuptools wheel twine - name: Build and publish env: TWINE_USERNAME: __token__ TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }} run: | python setup.py sdist bdist_wheel twine upload dist/* lawtancool-pyControl4-5334f51/.github/workflows/shiftleft-analysis.yml000066400000000000000000000026131514675053300261500ustar00rootroot00000000000000# This workflow integrates ShiftLeft Scan with GitHub's code scanning feature # ShiftLeft Scan is a free open-source security tool for modern DevOps teams # Visit https://slscan.io/en/latest/integrations/code-scan for help name: ShiftLeft Scan # This section configures the trigger for the workflow. Feel free to customize depending on your convention on: push: pull_request: # The branches below must be a subset of the branches above jobs: Scan-Build: # Scan runs on ubuntu, mac and windows runs-on: ubuntu-latest steps: - uses: actions/checkout@v1 # Instructions # 1. Setup JDK, Node.js, Python etc depending on your project type # 2. Compile or build the project before invoking scan # Example: mvn compile, or npm install or pip install goes here # 3. Invoke ShiftLeft Scan with the github token. Leave the workspace empty to use relative url - name: Perform ShiftLeft Scan uses: ShiftLeftSecurity/scan-action@master env: WORKSPACE: "" GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} SCAN_AUTO_BUILD: true with: output: reports # Scan auto-detects the languages in your project. To override uncomment the below variable and set the type # type: credscan,java # type: python - name: Upload report uses: github/codeql-action/upload-sarif@v1 with: sarif_file: reports lawtancool-pyControl4-5334f51/.gitignore000066400000000000000000000001051514675053300202010ustar00rootroot00000000000000.venv/ .vscode/ __pycache__/ login_info.py allitems.json *.egg-info/ lawtancool-pyControl4-5334f51/LICENSE000066400000000000000000000261351514675053300172310ustar00rootroot00000000000000 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. lawtancool-pyControl4-5334f51/README.md000066400000000000000000000046571514675053300175100ustar00rootroot00000000000000# pyControl4 [![PyPI version](https://badge.fury.io/py/pyControl4.svg)](https://badge.fury.io/py/pyControl4)[![Downloads](https://pepy.tech/badge/pycontrol4)](https://pepy.tech/project/pycontrol4) [![CI](https://github.com/lawtancool/pyControl4/workflows/CI/badge.svg)](https://github.com/lawtancool/pyControl4/actions?query=workflow%3ACI)[![pdoc](https://github.com/lawtancool/pyControl4/workflows/pdoc/badge.svg)](https://github.com/lawtancool/pyControl4/actions?query=workflow%3Apdoc)[![PyPI Release](https://github.com/lawtancool/pyControl4/workflows/PyPI%20Release/badge.svg)](https://github.com/lawtancool/pyControl4/actions?query=workflow%3A%22PyPI+Release%22) An asynchronous library to interact with Control4 systems through their built-in REST API. This is known to work on controllers with OS 2.10.1.544795-res and OS 3.0+. Auto-generated function documentation can be found at For those who are looking for a pre-built solution for controlling their devices, this library is implemented in the [official Home Assistant Control4 integration](https://www.home-assistant.io/integrations/control4/). ## Usage example ```python from pyControl4.account import C4Account from pyControl4.director import C4Director from pyControl4.light import C4Light import asyncio username = "" password = "" ip = "192.168.1.25" """Authenticate with Control4 account""" account = C4Account(username, password) asyncio.run(account.get_account_bearer_token()) """Get and print controller name""" account_controllers = asyncio.run(account.get_account_controllers()) print(account_controllers["controllerCommonName"]) """Get bearer token to communicate with controller locally""" director_bearer_token = asyncio.run( account.get_director_bearer_token(account_controllers["controllerCommonName"]) )["token"] """Create new C4Director instance""" director = C4Director(ip, director_bearer_token) """Print all devices on the controller""" print(asyncio.run(director.get_all_item_info())) """Create new C4Light instance""" light = C4Light(director, 253) """Ramp light level to 10% over 10000ms""" asyncio.run(light.ramp_to_level(10, 10000)) """Print state of light""" print(asyncio.run(light.get_state())) ``` ## Contributing Pull requests are welcome! Please lint your Python code with `flake8` and format it with [Black](https://pypi.org/project/black/). ## Disclaimer This library is not affiliated with or endorsed by Control4. lawtancool-pyControl4-5334f51/docs/000077500000000000000000000000001514675053300171455ustar00rootroot00000000000000lawtancool-pyControl4-5334f51/docs/account.html000066400000000000000000000717741514675053300215070ustar00rootroot00000000000000 pyControl4.account API documentation

Module pyControl4.account

Authenticates with the Control4 API, retrieves account and registered controller info, and retrieves a bearer token for connecting to a Control4 Director.

Classes

class C4Account (username: str, password: str, session: aiohttp.ClientSession | None = None)
Expand source code
class C4Account:
    def __init__(
        self,
        username: str,
        password: str,
        session: aiohttp.ClientSession | None = None,
    ):
        """Creates a Control4 account object.

        Parameters:
            `username` - Control4 account username/email.

            `password` - Control4 account password.

            `session` - (Optional) Allows the use of an `aiohttp.ClientSession`
                object for all network requests. This session will not be closed
                by the library. If not provided, the library will open and close
                its own `ClientSession`s as needed.
        """
        self.username = username
        self.password = password
        self.session = session

    @asynccontextmanager
    async def _get_session(self) -> AsyncGenerator[aiohttp.ClientSession, None]:
        """Returns the configured session or creates a temporary one.

        If self.session is set, yields it without closing.
        Otherwise, creates and closes a temporary session.
        """
        if self.session is not None:
            yield self.session
        else:
            async with aiohttp.ClientSession() as session:
                yield session

    async def _send_account_auth_request(self) -> str:
        """Used internally to retrieve an account bearer token. Returns the entire
        JSON response from the Control4 auth API.
        """
        data_dict = {
            "clientInfo": {
                "device": {
                    "deviceName": "pyControl4",
                    "deviceUUID": "0000000000000000",
                    "make": "pyControl4",
                    "model": "pyControl4",
                    "os": "Android",
                    "osVersion": "10",
                },
                "userInfo": {
                    "applicationKey": APPLICATION_KEY,
                    "password": self.password,
                    "userName": self.username,
                },
            }
        }
        async with self._get_session() as session:
            async with asyncio.timeout(10):
                async with session.post(
                    AUTHENTICATION_ENDPOINT, json=data_dict
                ) as resp:
                    text = await resp.text()
                    check_response_for_error(text)
                    return text

    async def _send_account_get_request(self, uri: str) -> str:
        """Used internally to send GET requests to the Control4 API,
        authenticated with the account bearer token. Returns the entire JSON
        response from the Control4 auth API.

        Parameters:
            `uri` - Full URI to send GET request to.
        """
        try:
            headers = {"Authorization": f"Bearer {self.account_bearer_token}"}
        except AttributeError:
            msg = (
                "The account bearer token is missing. "
                "Is your username/password correct?"
            )
            _LOGGER.error(msg)
            raise
        async with self._get_session() as session:
            async with asyncio.timeout(10):
                async with session.get(uri, headers=headers) as resp:
                    text = await resp.text()
                    check_response_for_error(text)
                    return text

    async def _send_controller_auth_request(self, controller_common_name: str) -> str:
        """Used internally to retrieve an director bearer token. Returns the
        entire JSON response from the Control4 auth API.

        Parameters:
            `controller_common_name`: Common name of the controller.
                See `get_account_controllers()` for details.
        """
        try:
            headers = {"Authorization": f"Bearer {self.account_bearer_token}"}
        except AttributeError:
            msg = (
                "The account bearer token is missing. "
                "Is your username/password correct?"
            )
            _LOGGER.error(msg)
            raise
        data_dict = {
            "serviceInfo": {
                "commonName": controller_common_name,
                "services": "director",
            }
        }
        async with self._get_session() as session:
            async with asyncio.timeout(10):
                async with session.post(
                    CONTROLLER_AUTHORIZATION_ENDPOINT,
                    headers=headers,
                    json=data_dict,
                ) as resp:
                    text = await resp.text()
                    check_response_for_error(text)
                    return text

    async def get_account_bearer_token(self) -> str:
        """Gets an account bearer token for making Control4 online API requests."""
        data = await self._send_account_auth_request()
        json_dict = json.loads(data)
        try:
            token: str = json_dict["authToken"]["token"]
            self.account_bearer_token = token
            return token
        except KeyError:
            msg = (
                "Did not receive an account bearer token. "
                "Is your username/password correct?"
            )
            _LOGGER.error(msg + data)
            raise

    async def get_account_controllers(self) -> dict[str, Any]:
        """Returns a dictionary of the information for all controllers registered
        to an account.

        Returns:
            ```
            {
                "controllerCommonName": "control4_MODEL_MACADDRESS",
                "href": "https://apis.control4.com/account/v3/rest/accounts/000000",
                "name": "Name"
            }
            ```
        """
        data = await self._send_account_get_request(GET_CONTROLLERS_ENDPOINT)
        json_dict = json.loads(data)
        try:
            result: dict[str, Any] = json_dict["account"]
            return result
        except KeyError:
            msg = "Did not receive account information from the Control4 API."
            _LOGGER.error(msg + " Response: " + data)
            raise

    async def get_controller_info(self, controller_href: str) -> dict[str, Any]:
        """Returns a dictionary of the information of a specific controller.

        Parameters:
            `controller_href` - The API `href` of the controller (get this from
                the output of `get_account_controllers()`)

        Returns:
            ```
            {
                'allowsPatching': True,
                'allowsSupport': False,
                'blockNotifications': False,
                'controllerCommonName': 'control4_MODEL_MACADDRESS',
                'controller': {
                    'href': 'https://apis.control4.com/account/v3/rest/accounts/000000/controller'  # noqa: E501
                },
                'created': '2017-08-26T18:33:31Z',
                'dealer': {
                    'href': 'https://apis.control4.com/account/v3/rest/dealers/12345'
                },
                'enabled': True,
                'hasLoggedIn': True,
                'href': 'https://apis.control4.com/account/v3/rest/accounts/000000',
                'id': 000000,
                'lastCheckIn': '2020-06-13T21:52:34Z',
                'licenses': {
                    'href': 'https://apis.control4.com/account/v3/rest/accounts/000000/licenses'  # noqa: E501
                },
                'modified': '2020-06-13T21:52:34Z',
                'name': 'Name',
                'provisionDate': '2017-08-26T18:35:11Z',
                'storage': {
                    'href': 'https://apis.control4.com/storage/v1/rest/accounts/000000'
                },
                'type': 'Consumer',
                'users': {
                    'href': 'https://apis.control4.com/account/v3/rest/accounts/000000/users'  # noqa: E501
                }
            }
            ```
        """
        data = await self._send_account_get_request(controller_href)
        json_dict: dict[str, Any] = json.loads(data)
        return json_dict

    async def get_controller_os_version(self, controller_href: str) -> str:
        """Returns the OS version of a controller as a string.

        Parameters:
            `controller_href` - The API `href` of the controller (get this from
                the output of `get_account_controllers()`)
        """
        data = await self._send_account_get_request(controller_href + "/controller")
        json_dict = json.loads(data)
        try:
            version: str = json_dict["osVersion"]
            return version
        except KeyError:
            msg = "Did not receive OS version from the Control4 API."
            _LOGGER.error(msg + " Response: " + data)
            raise

    async def get_director_bearer_token(
        self, controller_common_name: str
    ) -> dict[str, Any]:
        """Returns a dictionary with a director bearer token for making Control4
        Director API requests, and its time valid in seconds (usually 86400 seconds)

        Parameters:
            `controller_common_name`: Common name of the controller.
                See `get_account_controllers()` for details.
        """
        data = await self._send_controller_auth_request(controller_common_name)
        json_dict = json.loads(data)
        try:
            auth_token = json_dict.get("authToken", {})
            token = auth_token.get("token")
            valid_seconds = auth_token.get("validSeconds")
            if token is None or valid_seconds is None:
                raise KeyError("Missing token or validSeconds in authToken")
            return {"token": token, "validSeconds": valid_seconds}
        except KeyError:
            msg = "Did not receive a director bearer token from the Control4 API."
            _LOGGER.error(msg + " Response: " + data)
            raise

Creates a Control4 account object.

Parameters

username - Control4 account username/email.

password - Control4 account password.

session - (Optional) Allows the use of an aiohttp.ClientSession object for all network requests. This session will not be closed by the library. If not provided, the library will open and close its own ClientSessions as needed.

Methods

async def get_account_bearer_token(self) ‑> str
Expand source code
async def get_account_bearer_token(self) -> str:
    """Gets an account bearer token for making Control4 online API requests."""
    data = await self._send_account_auth_request()
    json_dict = json.loads(data)
    try:
        token: str = json_dict["authToken"]["token"]
        self.account_bearer_token = token
        return token
    except KeyError:
        msg = (
            "Did not receive an account bearer token. "
            "Is your username/password correct?"
        )
        _LOGGER.error(msg + data)
        raise

Gets an account bearer token for making Control4 online API requests.

async def get_account_controllers(self) ‑> dict[str, typing.Any]
Expand source code
async def get_account_controllers(self) -> dict[str, Any]:
    """Returns a dictionary of the information for all controllers registered
    to an account.

    Returns:
        ```
        {
            "controllerCommonName": "control4_MODEL_MACADDRESS",
            "href": "https://apis.control4.com/account/v3/rest/accounts/000000",
            "name": "Name"
        }
        ```
    """
    data = await self._send_account_get_request(GET_CONTROLLERS_ENDPOINT)
    json_dict = json.loads(data)
    try:
        result: dict[str, Any] = json_dict["account"]
        return result
    except KeyError:
        msg = "Did not receive account information from the Control4 API."
        _LOGGER.error(msg + " Response: " + data)
        raise

Returns a dictionary of the information for all controllers registered to an account.

Returns

{
    "controllerCommonName": "control4_MODEL_MACADDRESS",
    "href": "https://apis.control4.com/account/v3/rest/accounts/000000",
    "name": "Name"
}
async def get_controller_info(self, controller_href: str) ‑> dict[str, typing.Any]
Expand source code
async def get_controller_info(self, controller_href: str) -> dict[str, Any]:
    """Returns a dictionary of the information of a specific controller.

    Parameters:
        `controller_href` - The API `href` of the controller (get this from
            the output of `get_account_controllers()`)

    Returns:
        ```
        {
            'allowsPatching': True,
            'allowsSupport': False,
            'blockNotifications': False,
            'controllerCommonName': 'control4_MODEL_MACADDRESS',
            'controller': {
                'href': 'https://apis.control4.com/account/v3/rest/accounts/000000/controller'  # noqa: E501
            },
            'created': '2017-08-26T18:33:31Z',
            'dealer': {
                'href': 'https://apis.control4.com/account/v3/rest/dealers/12345'
            },
            'enabled': True,
            'hasLoggedIn': True,
            'href': 'https://apis.control4.com/account/v3/rest/accounts/000000',
            'id': 000000,
            'lastCheckIn': '2020-06-13T21:52:34Z',
            'licenses': {
                'href': 'https://apis.control4.com/account/v3/rest/accounts/000000/licenses'  # noqa: E501
            },
            'modified': '2020-06-13T21:52:34Z',
            'name': 'Name',
            'provisionDate': '2017-08-26T18:35:11Z',
            'storage': {
                'href': 'https://apis.control4.com/storage/v1/rest/accounts/000000'
            },
            'type': 'Consumer',
            'users': {
                'href': 'https://apis.control4.com/account/v3/rest/accounts/000000/users'  # noqa: E501
            }
        }
        ```
    """
    data = await self._send_account_get_request(controller_href)
    json_dict: dict[str, Any] = json.loads(data)
    return json_dict

Returns a dictionary of the information of a specific controller.

Parameters

controller_href - The API href of the controller (get this from the output of get_account_controllers())

Returns

{
    'allowsPatching': True,
    'allowsSupport': False,
    'blockNotifications': False,
    'controllerCommonName': 'control4_MODEL_MACADDRESS',
    'controller': {
        'href': 'https://apis.control4.com/account/v3/rest/accounts/000000/controller'  # noqa: E501
    },
    'created': '2017-08-26T18:33:31Z',
    'dealer': {
        'href': 'https://apis.control4.com/account/v3/rest/dealers/12345'
    },
    'enabled': True,
    'hasLoggedIn': True,
    'href': 'https://apis.control4.com/account/v3/rest/accounts/000000',
    'id': 000000,
    'lastCheckIn': '2020-06-13T21:52:34Z',
    'licenses': {
        'href': 'https://apis.control4.com/account/v3/rest/accounts/000000/licenses'  # noqa: E501
    },
    'modified': '2020-06-13T21:52:34Z',
    'name': 'Name',
    'provisionDate': '2017-08-26T18:35:11Z',
    'storage': {
        'href': 'https://apis.control4.com/storage/v1/rest/accounts/000000'
    },
    'type': 'Consumer',
    'users': {
        'href': 'https://apis.control4.com/account/v3/rest/accounts/000000/users'  # noqa: E501
    }
}
async def get_controller_os_version(self, controller_href: str) ‑> str
Expand source code
async def get_controller_os_version(self, controller_href: str) -> str:
    """Returns the OS version of a controller as a string.

    Parameters:
        `controller_href` - The API `href` of the controller (get this from
            the output of `get_account_controllers()`)
    """
    data = await self._send_account_get_request(controller_href + "/controller")
    json_dict = json.loads(data)
    try:
        version: str = json_dict["osVersion"]
        return version
    except KeyError:
        msg = "Did not receive OS version from the Control4 API."
        _LOGGER.error(msg + " Response: " + data)
        raise

Returns the OS version of a controller as a string.

Parameters

controller_href - The API href of the controller (get this from the output of get_account_controllers())

async def get_director_bearer_token(self, controller_common_name: str) ‑> dict[str, typing.Any]
Expand source code
async def get_director_bearer_token(
    self, controller_common_name: str
) -> dict[str, Any]:
    """Returns a dictionary with a director bearer token for making Control4
    Director API requests, and its time valid in seconds (usually 86400 seconds)

    Parameters:
        `controller_common_name`: Common name of the controller.
            See `get_account_controllers()` for details.
    """
    data = await self._send_controller_auth_request(controller_common_name)
    json_dict = json.loads(data)
    try:
        auth_token = json_dict.get("authToken", {})
        token = auth_token.get("token")
        valid_seconds = auth_token.get("validSeconds")
        if token is None or valid_seconds is None:
            raise KeyError("Missing token or validSeconds in authToken")
        return {"token": token, "validSeconds": valid_seconds}
    except KeyError:
        msg = "Did not receive a director bearer token from the Control4 API."
        _LOGGER.error(msg + " Response: " + data)
        raise

Returns a dictionary with a director bearer token for making Control4 Director API requests, and its time valid in seconds (usually 86400 seconds)

Parameters

controller_common_name: Common name of the controller. See get_account_controllers() for details.

lawtancool-pyControl4-5334f51/docs/alarm.html000066400000000000000000001144121514675053300211320ustar00rootroot00000000000000 pyControl4.alarm API documentation

Module pyControl4.alarm

Controls Control4 security panel and contact sensor (door, window, motion) devices.

Classes

class C4ContactSensor (director: C4Director, item_id: int)
Expand source code
class C4ContactSensor:
    def __init__(self, director: C4Director, item_id: int) -> None:
        """Creates a Control4 Contact Sensor object.

        Parameters:
            `director` - A `pyControl4.director.C4Director` object that corresponds
                to the Control4 Director that the contact sensor is connected to.

            `item_id` - The Control4 item ID of the contact sensor.
        """
        self.director = director
        self.item_id = item_id

    async def get_contact_state(self) -> bool | None:
        """Returns `True` if contact is triggered (door/window is closed, motion is
        detected), otherwise returns `False`.
        """
        contact_state = await self.director.get_item_variable_value(
            self.item_id, "ContactState"
        )
        if contact_state is None:
            return None
        return bool(contact_state)

Creates a Control4 Contact Sensor object.

Parameters

director - A C4Director object that corresponds to the Control4 Director that the contact sensor is connected to.

item_id - The Control4 item ID of the contact sensor.

Methods

async def get_contact_state(self) ‑> bool | None
Expand source code
async def get_contact_state(self) -> bool | None:
    """Returns `True` if contact is triggered (door/window is closed, motion is
    detected), otherwise returns `False`.
    """
    contact_state = await self.director.get_item_variable_value(
        self.item_id, "ContactState"
    )
    if contact_state is None:
        return None
    return bool(contact_state)

Returns True if contact is triggered (door/window is closed, motion is detected), otherwise returns False.

class C4SecurityPanel (director: C4Director, item_id: int)
Expand source code
class C4SecurityPanel(C4Entity):
    async def get_arm_state(self) -> str | None:
        """
        NOTE: Prefer using `get_partition_state()`
        and `get_armed_type()` over this method.
        Returns the arm state of the security panel as "DISARMED", "ARMED_HOME",
        or "ARMED_AWAY".
        """
        disarmed = await self.director.get_item_variable_value(
            self.item_id, "DISARMED_STATE"
        )
        armed_home = await self.director.get_item_variable_value(
            self.item_id, "HOME_STATE"
        )
        armed_away = await self.director.get_item_variable_value(
            self.item_id, "AWAY_STATE"
        )
        try:
            if disarmed is not None and int(disarmed) == 1:
                return "DISARMED"
            elif armed_home is not None and int(armed_home) == 1:
                return "ARMED_HOME"
            elif armed_away is not None and int(armed_away) == 1:
                return "ARMED_AWAY"
        except (ValueError, TypeError):
            pass
        return None

    async def get_alarm_state(self) -> bool | None:
        """Returns `True` if alarm is triggered, otherwise returns `False`."""
        alarm_state = await self.director.get_item_variable_value(
            self.item_id, "ALARM_STATE"
        )
        if alarm_state is None:
            return None
        return bool(alarm_state)

    async def get_display_text(self) -> str | None:
        """Returns the display text of the security panel."""
        display_text = await self.director.get_item_variable_value(
            self.item_id, "DISPLAY_TEXT"
        )
        return display_text

    async def get_trouble_text(self) -> str | None:
        """Returns the trouble display text of the security panel."""
        trouble_text = await self.director.get_item_variable_value(
            self.item_id, "TROUBLE_TEXT"
        )
        return trouble_text

    async def get_partition_state(self) -> str | None:
        """Returns the partition state of the security panel.

        Possible values include "DISARMED_NOT_READY", "DISARMED_READY", "ARMED_HOME",
        "ARMED_AWAY", "EXIT_DELAY", "ENTRY_DELAY"
        """
        partition_state = await self.director.get_item_variable_value(
            self.item_id, "PARTITION_STATE"
        )
        return partition_state

    async def get_delay_time_total(self) -> int | None:
        """Returns the total exit delay time. Returns 0 if an exit delay is not
        currently running.
        """
        delay_time_total = await self.director.get_item_variable_value(
            self.item_id, "DELAY_TIME_TOTAL"
        )
        return int(delay_time_total) if delay_time_total is not None else None

    async def get_delay_time_remaining(self) -> int | None:
        """Returns the remaining exit delay time. Returns 0 if an exit delay is
        not currently running.
        """
        delay_time_remaining = await self.director.get_item_variable_value(
            self.item_id, "DELAY_TIME_REMAINING"
        )
        return int(delay_time_remaining) if delay_time_remaining is not None else None

    async def get_open_zone_count(self) -> int | None:
        """Returns the number of open/unsecured zones."""
        open_zone_count = await self.director.get_item_variable_value(
            self.item_id, "OPEN_ZONE_COUNT"
        )
        return int(open_zone_count) if open_zone_count is not None else None

    async def get_alarm_type(self) -> str | None:
        """Returns details about the current alarm type."""
        alarm_type = await self.director.get_item_variable_value(
            self.item_id, "ALARM_TYPE"
        )
        return alarm_type

    async def get_armed_type(self) -> str | None:
        """Returns details about the current arm type."""
        armed_type = await self.director.get_item_variable_value(
            self.item_id, "ARMED_TYPE"
        )
        return armed_type

    async def get_last_emergency(self) -> str | None:
        """Returns details about the last emergency trigger."""
        last_emergency = await self.director.get_item_variable_value(
            self.item_id, "LAST_EMERGENCY"
        )
        return last_emergency

    async def get_last_arm_failure(self) -> str | None:
        """Returns details about the last arm failure."""
        last_arm_failed = await self.director.get_item_variable_value(
            self.item_id, "LAST_ARM_FAILED"
        )
        return last_arm_failed

    async def set_arm(self, usercode: str, mode: str) -> None:
        """Arms the security panel with the specified mode.

        Parameters:
            `usercode` - PIN/code for arming the system.

            `mode` - Arm mode to use. This depends on what is supported by the
                security panel itself.
        """
        usercode = str(usercode)
        await self.director.send_post_request(
            f"/api/v1/items/{self.item_id}/commands",
            "PARTITION_ARM",
            {"ArmType": mode, "UserCode": usercode},
        )

    async def set_disarm(self, usercode: str) -> None:
        """Disarms the security panel.

        Parameters:
            `usercode` - PIN/code for disarming the system.
        """
        usercode = str(usercode)
        await self.director.send_post_request(
            f"/api/v1/items/{self.item_id}/commands",
            "PARTITION_DISARM",
            {"UserCode": usercode},
        )

    async def get_emergency_types(self) -> list[str]:
        """Returns the available emergency types as a list.

        Possible types are "Fire", "Medical", "Panic", and "Police".
        """
        types_list: list[str] = []

        data = await self.director.get_item_info(self.item_id)
        if not data or not isinstance(data, list) or len(data) == 0:
            return types_list

        capabilities = data[0].get("capabilities", {})
        if capabilities.get("has_fire"):
            types_list.append("Fire")
        if capabilities.get("has_medical"):
            types_list.append("Medical")
        if capabilities.get("has_panic"):
            types_list.append("Panic")
        if capabilities.get("has_police"):
            types_list.append("Police")

        return types_list

    async def get_arm_types(self) -> list[str]:
        """Returns the available arm types as a list."""
        data = await self.director.get_item_info(self.item_id)
        if not data or not isinstance(data, list) or len(data) == 0:
            return []

        capabilities = data[0].get("capabilities", {})
        arm_types_str = capabilities.get("arm_types", "")
        if not arm_types_str:
            return []
        return [t.strip() for t in arm_types_str.split(",") if t.strip()]

    async def trigger_emergency(self, emergency_type: str) -> None:
        """Triggers an emergency of the specified type.

        Parameters:
            `emergency_type` - Type of emergency:
            "Fire", "Medical", "Panic", or "Police"
        """
        await self.director.send_post_request(
            f"/api/v1/items/{self.item_id}/commands",
            "EXECUTE_EMERGENCY",
            {"EmergencyType": emergency_type},
        )

    async def send_key_press(self, key: str) -> None:
        """Sends a single keypress to the security panel's virtual keypad (if
        supported).

        Parameters:
            `key` - Keypress to send. Only one key at a time.
        """
        key = str(key)
        await self.director.send_post_request(
            f"/api/v1/items/{self.item_id}/commands",
            "KEY_PRESS",
            {"KeyName": key},
        )

Creates a Control4 object.

Parameters

director - A C4Director object that corresponds to the Control4 Director that the device is connected to.

item_id - The Control4 item ID.

Ancestors

Methods

async def get_alarm_state(self) ‑> bool | None
Expand source code
async def get_alarm_state(self) -> bool | None:
    """Returns `True` if alarm is triggered, otherwise returns `False`."""
    alarm_state = await self.director.get_item_variable_value(
        self.item_id, "ALARM_STATE"
    )
    if alarm_state is None:
        return None
    return bool(alarm_state)

Returns True if alarm is triggered, otherwise returns False.

async def get_alarm_type(self) ‑> str | None
Expand source code
async def get_alarm_type(self) -> str | None:
    """Returns details about the current alarm type."""
    alarm_type = await self.director.get_item_variable_value(
        self.item_id, "ALARM_TYPE"
    )
    return alarm_type

Returns details about the current alarm type.

async def get_arm_state(self) ‑> str | None
Expand source code
async def get_arm_state(self) -> str | None:
    """
    NOTE: Prefer using `get_partition_state()`
    and `get_armed_type()` over this method.
    Returns the arm state of the security panel as "DISARMED", "ARMED_HOME",
    or "ARMED_AWAY".
    """
    disarmed = await self.director.get_item_variable_value(
        self.item_id, "DISARMED_STATE"
    )
    armed_home = await self.director.get_item_variable_value(
        self.item_id, "HOME_STATE"
    )
    armed_away = await self.director.get_item_variable_value(
        self.item_id, "AWAY_STATE"
    )
    try:
        if disarmed is not None and int(disarmed) == 1:
            return "DISARMED"
        elif armed_home is not None and int(armed_home) == 1:
            return "ARMED_HOME"
        elif armed_away is not None and int(armed_away) == 1:
            return "ARMED_AWAY"
    except (ValueError, TypeError):
        pass
    return None

NOTE: Prefer using get_partition_state() and get_armed_type() over this method. Returns the arm state of the security panel as "DISARMED", "ARMED_HOME", or "ARMED_AWAY".

async def get_arm_types(self) ‑> list[str]
Expand source code
async def get_arm_types(self) -> list[str]:
    """Returns the available arm types as a list."""
    data = await self.director.get_item_info(self.item_id)
    if not data or not isinstance(data, list) or len(data) == 0:
        return []

    capabilities = data[0].get("capabilities", {})
    arm_types_str = capabilities.get("arm_types", "")
    if not arm_types_str:
        return []
    return [t.strip() for t in arm_types_str.split(",") if t.strip()]

Returns the available arm types as a list.

async def get_armed_type(self) ‑> str | None
Expand source code
async def get_armed_type(self) -> str | None:
    """Returns details about the current arm type."""
    armed_type = await self.director.get_item_variable_value(
        self.item_id, "ARMED_TYPE"
    )
    return armed_type

Returns details about the current arm type.

async def get_delay_time_remaining(self) ‑> int | None
Expand source code
async def get_delay_time_remaining(self) -> int | None:
    """Returns the remaining exit delay time. Returns 0 if an exit delay is
    not currently running.
    """
    delay_time_remaining = await self.director.get_item_variable_value(
        self.item_id, "DELAY_TIME_REMAINING"
    )
    return int(delay_time_remaining) if delay_time_remaining is not None else None

Returns the remaining exit delay time. Returns 0 if an exit delay is not currently running.

async def get_delay_time_total(self) ‑> int | None
Expand source code
async def get_delay_time_total(self) -> int | None:
    """Returns the total exit delay time. Returns 0 if an exit delay is not
    currently running.
    """
    delay_time_total = await self.director.get_item_variable_value(
        self.item_id, "DELAY_TIME_TOTAL"
    )
    return int(delay_time_total) if delay_time_total is not None else None

Returns the total exit delay time. Returns 0 if an exit delay is not currently running.

async def get_display_text(self) ‑> str | None
Expand source code
async def get_display_text(self) -> str | None:
    """Returns the display text of the security panel."""
    display_text = await self.director.get_item_variable_value(
        self.item_id, "DISPLAY_TEXT"
    )
    return display_text

Returns the display text of the security panel.

async def get_emergency_types(self) ‑> list[str]
Expand source code
async def get_emergency_types(self) -> list[str]:
    """Returns the available emergency types as a list.

    Possible types are "Fire", "Medical", "Panic", and "Police".
    """
    types_list: list[str] = []

    data = await self.director.get_item_info(self.item_id)
    if not data or not isinstance(data, list) or len(data) == 0:
        return types_list

    capabilities = data[0].get("capabilities", {})
    if capabilities.get("has_fire"):
        types_list.append("Fire")
    if capabilities.get("has_medical"):
        types_list.append("Medical")
    if capabilities.get("has_panic"):
        types_list.append("Panic")
    if capabilities.get("has_police"):
        types_list.append("Police")

    return types_list

Returns the available emergency types as a list.

Possible types are "Fire", "Medical", "Panic", and "Police".

async def get_last_arm_failure(self) ‑> str | None
Expand source code
async def get_last_arm_failure(self) -> str | None:
    """Returns details about the last arm failure."""
    last_arm_failed = await self.director.get_item_variable_value(
        self.item_id, "LAST_ARM_FAILED"
    )
    return last_arm_failed

Returns details about the last arm failure.

async def get_last_emergency(self) ‑> str | None
Expand source code
async def get_last_emergency(self) -> str | None:
    """Returns details about the last emergency trigger."""
    last_emergency = await self.director.get_item_variable_value(
        self.item_id, "LAST_EMERGENCY"
    )
    return last_emergency

Returns details about the last emergency trigger.

async def get_open_zone_count(self) ‑> int | None
Expand source code
async def get_open_zone_count(self) -> int | None:
    """Returns the number of open/unsecured zones."""
    open_zone_count = await self.director.get_item_variable_value(
        self.item_id, "OPEN_ZONE_COUNT"
    )
    return int(open_zone_count) if open_zone_count is not None else None

Returns the number of open/unsecured zones.

async def get_partition_state(self) ‑> str | None
Expand source code
async def get_partition_state(self) -> str | None:
    """Returns the partition state of the security panel.

    Possible values include "DISARMED_NOT_READY", "DISARMED_READY", "ARMED_HOME",
    "ARMED_AWAY", "EXIT_DELAY", "ENTRY_DELAY"
    """
    partition_state = await self.director.get_item_variable_value(
        self.item_id, "PARTITION_STATE"
    )
    return partition_state

Returns the partition state of the security panel.

Possible values include "DISARMED_NOT_READY", "DISARMED_READY", "ARMED_HOME", "ARMED_AWAY", "EXIT_DELAY", "ENTRY_DELAY"

async def get_trouble_text(self) ‑> str | None
Expand source code
async def get_trouble_text(self) -> str | None:
    """Returns the trouble display text of the security panel."""
    trouble_text = await self.director.get_item_variable_value(
        self.item_id, "TROUBLE_TEXT"
    )
    return trouble_text

Returns the trouble display text of the security panel.

async def send_key_press(self, key: str) ‑> None
Expand source code
async def send_key_press(self, key: str) -> None:
    """Sends a single keypress to the security panel's virtual keypad (if
    supported).

    Parameters:
        `key` - Keypress to send. Only one key at a time.
    """
    key = str(key)
    await self.director.send_post_request(
        f"/api/v1/items/{self.item_id}/commands",
        "KEY_PRESS",
        {"KeyName": key},
    )

Sends a single keypress to the security panel's virtual keypad (if supported).

Parameters

key - Keypress to send. Only one key at a time.

async def set_arm(self, usercode: str, mode: str) ‑> None
Expand source code
async def set_arm(self, usercode: str, mode: str) -> None:
    """Arms the security panel with the specified mode.

    Parameters:
        `usercode` - PIN/code for arming the system.

        `mode` - Arm mode to use. This depends on what is supported by the
            security panel itself.
    """
    usercode = str(usercode)
    await self.director.send_post_request(
        f"/api/v1/items/{self.item_id}/commands",
        "PARTITION_ARM",
        {"ArmType": mode, "UserCode": usercode},
    )

Arms the security panel with the specified mode.

Parameters

usercode - PIN/code for arming the system.

mode - Arm mode to use. This depends on what is supported by the security panel itself.

async def set_disarm(self, usercode: str) ‑> None
Expand source code
async def set_disarm(self, usercode: str) -> None:
    """Disarms the security panel.

    Parameters:
        `usercode` - PIN/code for disarming the system.
    """
    usercode = str(usercode)
    await self.director.send_post_request(
        f"/api/v1/items/{self.item_id}/commands",
        "PARTITION_DISARM",
        {"UserCode": usercode},
    )

Disarms the security panel.

Parameters

usercode - PIN/code for disarming the system.

async def trigger_emergency(self, emergency_type: str) ‑> None
Expand source code
async def trigger_emergency(self, emergency_type: str) -> None:
    """Triggers an emergency of the specified type.

    Parameters:
        `emergency_type` - Type of emergency:
        "Fire", "Medical", "Panic", or "Police"
    """
    await self.director.send_post_request(
        f"/api/v1/items/{self.item_id}/commands",
        "EXECUTE_EMERGENCY",
        {"EmergencyType": emergency_type},
    )

Triggers an emergency of the specified type.

Parameters

emergency_type - Type of emergency: "Fire", "Medical", "Panic", or "Police"

lawtancool-pyControl4-5334f51/docs/auth.html000066400000000000000000000756541514675053300210150ustar00rootroot00000000000000 pyControl4.auth API documentation

Module pyControl4.auth

Authenticates with the Control4 API, retrieves account and registered controller info, and retrieves a bearer token for connecting to a Control4 Director.

Expand source code
"""Authenticates with the Control4 API, retrieves account and registered controller info, and retrieves a bearer token for connecting to a Control4 Director.
"""
import aiohttp
import asyncio
import async_timeout
import json
import logging

AUTHENTICATION_ENDPOINT = "https://apis.control4.com/authentication/v1/rest"
CONTROLLER_AUTHORIZATION_ENDPOINT = (
    "https://apis.control4.com/authentication/v1/rest/authorization"
)
GET_CONTROLLERS_ENDPOINT = "https://apis.control4.com/account/v3/rest/accounts"
APPLICATION_KEY = "78f6791373d61bea49fdb9fb8897f1f3af193f11"

_LOGGER = logging.getLogger(__name__)


class C4Auth:
    def __init__(self, username, password):
        self.username = username
        self.password = password
        asyncio.run(self.getAccountBearerToken())

    async def __sendAccountAuthRequest(self):
        """Used internally to retrieve an account bearer token. Returns the entire JSON response from the Control4 auth API.

        Parameters:
            `username` - Control4 account username/email.

            `password` - Control4 account password.
        """
        dataDictionary = {
            "clientInfo": {
                "device": {
                    "deviceName": "pyControl4",
                    "deviceUUID": "0000000000000000",
                    "make": "pyControl4",
                    "model": "pyControl4",
                    "os": "Android",
                    "osVersion": "10",
                },
                "userInfo": {
                    "applicationKey": APPLICATION_KEY,
                    "password": self.password,
                    "userName": self.username,
                },
            }
        }
        async with aiohttp.ClientSession() as session:
            async with asyncio.timeout(10):
                async with session.post(
                    AUTHENTICATION_ENDPOINT, json=dataDictionary
                ) as resp:
                    return await resp.text()

    async def __sendAccountGetRequest(self, uri):
        """Used internally to send GET requests to the Control4 API, authenticated with the account bearer token. Returns the entire JSON response from the Control4 auth API.

        Parameters:
            `account_bearer_token` - Control4 account bearer token.

            `uri` - Full URI to send GET request to.
        """
        try:
            headers = {"Authorization": "Bearer {}".format(self.account_bearer_token)}
        except AttributeError:
            msg = "The account bearer token is missing - was your username/password correct? "
            _LOGGER.error(msg)
            raise
        async with aiohttp.ClientSession() as session:
            async with asyncio.timeout(10):
                async with session.get(uri, headers=headers) as resp:
                    return await resp.text()

    async def __sendControllerAuthRequest(self, controller_common_name):
        """Used internally to retrieve an director bearer token. Returns the entire JSON response from the Control4 auth API.

        Parameters:
            `account_bearer_token` - Control4 account bearer token.

            `controller_common_name`: Common name of the controller. See `getAccountControllers()` for details.
        """
        try:
            headers = {"Authorization": "Bearer {}".format(self.account_bearer_token)}
        except AttributeError:
            msg = "The account bearer token is missing - was your username/password correct? "
            _LOGGER.error(msg)
            raise
        dataDictionary = {
            "serviceInfo": {
                "commonName": controller_common_name,
                "services": "director",
            }
        }
        async with aiohttp.ClientSession() as session:
            async with asyncio.timeout(10):
                async with session.post(
                    CONTROLLER_AUTHORIZATION_ENDPOINT,
                    headers=headers,
                    json=dataDictionary,
                ) as resp:
                    return await resp.text()

    async def getAccountBearerToken(self):
        """Returns an account bearer token for making Control4 online API requests.

        Parameters:
            `username` - Control4 account username/email.

            `password` - Control4 account password.
        """
        data = await self.__sendAccountAuthRequest()
        jsonDictionary = json.loads(data)
        try:
            self.account_bearer_token = jsonDictionary["authToken"]["token"]
        except KeyError:
            msg = "Did not recieve an account bearer token. Is your username/password correct? "
            _LOGGER.error(msg + data)
            raise

    async def getAccountControllers(self):
        """Returns a dictionary of the information for all controllers registered to an account.

        Parameters:
            `account_bearer_token` - Control4 account bearer token.

        Returns:
            ```
            {    
                "controllerCommonName": "control4_MODEL_MACADDRESS",
                "href": "https://apis.control4.com/account/v3/rest/accounts/000000",
                "name": "Name"
            }
            ```
        """
        data = await self.__sendAccountGetRequest(GET_CONTROLLERS_ENDPOINT)
        jsonDictionary = json.loads(data)
        return jsonDictionary["account"]

    async def getControllerInfo(self, controller_href):
        """Returns a dictionary of the information of a specific controller.

        Parameters:
            `account_bearer_token` - Control4 account bearer token.

            `controller_href` - The API `href` of the controller (get this from the output of `getAccountControllers()`)

        Returns:
            ```
            {
                'allowsPatching': True,
                'allowsSupport': False,
                'blockNotifications': False,
                'controllerCommonName': 'control4_MODEL_MACADDRESS',
                'controller': {
                    'href': 'https://apis.control4.com/account/v3/rest/accounts/000000/controller'
                },
                'created': '2017-08-26T18:33:31Z',
                'dealer': {
                    'href': 'https://apis.control4.com/account/v3/rest/dealers/12345'
                },
                'enabled': True,
                'hasLoggedIn': True,
                'href': 'https://apis.control4.com/account/v3/rest/accounts/000000',
                'id': 000000,
                'lastCheckIn': '2020-06-13T21:52:34Z',
                'licenses': {
                    'href': 'https://apis.control4.com/account/v3/rest/accounts/000000/licenses'
                },
                'modified': '2020-06-13T21:52:34Z',
                'name': 'Name',
                'provisionDate': '2017-08-26T18:35:11Z',
                'storage': {
                    'href': 'https://apis.control4.com/storage/v1/rest/accounts/000000'
                },
                'type': 'Consumer',
                'users': {
                    'href': 'https://apis.control4.com/account/v3/rest/accounts/000000/users'
                }
            }
            ```
        """
        data = await self.__sendAccountGetRequest(controller_href)
        jsonDictionary = json.loads(data)
        return jsonDictionary

    async def getDirectorBearerToken(self, controller_common_name):
        """Returns a director bearer token for making Control4 Director API requests.

        Parameters:
            `account_bearer_token` - Control4 account bearer token.

            `controller_common_name`: Common name of the controller. See `getAccountControllers()` for details.
        """
        data = await self.__sendControllerAuthRequest(controller_common_name)
        jsonDictionary = json.loads(data)
        return jsonDictionary["authToken"]["token"]

Classes

class C4Auth (username, password)
Expand source code
class C4Auth:
    def __init__(self, username, password):
        self.username = username
        self.password = password
        asyncio.run(self.getAccountBearerToken())

    async def __sendAccountAuthRequest(self):
        """Used internally to retrieve an account bearer token. Returns the entire JSON response from the Control4 auth API.

        Parameters:
            `username` - Control4 account username/email.

            `password` - Control4 account password.
        """
        dataDictionary = {
            "clientInfo": {
                "device": {
                    "deviceName": "pyControl4",
                    "deviceUUID": "0000000000000000",
                    "make": "pyControl4",
                    "model": "pyControl4",
                    "os": "Android",
                    "osVersion": "10",
                },
                "userInfo": {
                    "applicationKey": APPLICATION_KEY,
                    "password": self.password,
                    "userName": self.username,
                },
            }
        }
        async with aiohttp.ClientSession() as session:
            async with asyncio.timeout(10):
                async with session.post(
                    AUTHENTICATION_ENDPOINT, json=dataDictionary
                ) as resp:
                    return await resp.text()

    async def __sendAccountGetRequest(self, uri):
        """Used internally to send GET requests to the Control4 API, authenticated with the account bearer token. Returns the entire JSON response from the Control4 auth API.

        Parameters:
            `account_bearer_token` - Control4 account bearer token.

            `uri` - Full URI to send GET request to.
        """
        try:
            headers = {"Authorization": "Bearer {}".format(self.account_bearer_token)}
        except AttributeError:
            msg = "The account bearer token is missing - was your username/password correct? "
            _LOGGER.error(msg)
            raise
        async with aiohttp.ClientSession() as session:
            async with asyncio.timeout(10):
                async with session.get(uri, headers=headers) as resp:
                    return await resp.text()

    async def __sendControllerAuthRequest(self, controller_common_name):
        """Used internally to retrieve an director bearer token. Returns the entire JSON response from the Control4 auth API.

        Parameters:
            `account_bearer_token` - Control4 account bearer token.

            `controller_common_name`: Common name of the controller. See `getAccountControllers()` for details.
        """
        try:
            headers = {"Authorization": "Bearer {}".format(self.account_bearer_token)}
        except AttributeError:
            msg = "The account bearer token is missing - was your username/password correct? "
            _LOGGER.error(msg)
            raise
        dataDictionary = {
            "serviceInfo": {
                "commonName": controller_common_name,
                "services": "director",
            }
        }
        async with aiohttp.ClientSession() as session:
            async with asyncio.timeout(10):
                async with session.post(
                    CONTROLLER_AUTHORIZATION_ENDPOINT,
                    headers=headers,
                    json=dataDictionary,
                ) as resp:
                    return await resp.text()

    async def getAccountBearerToken(self):
        """Returns an account bearer token for making Control4 online API requests.

        Parameters:
            `username` - Control4 account username/email.

            `password` - Control4 account password.
        """
        data = await self.__sendAccountAuthRequest()
        jsonDictionary = json.loads(data)
        try:
            self.account_bearer_token = jsonDictionary["authToken"]["token"]
        except KeyError:
            msg = "Did not recieve an account bearer token. Is your username/password correct? "
            _LOGGER.error(msg + data)
            raise

    async def getAccountControllers(self):
        """Returns a dictionary of the information for all controllers registered to an account.

        Parameters:
            `account_bearer_token` - Control4 account bearer token.

        Returns:
            ```
            {    
                "controllerCommonName": "control4_MODEL_MACADDRESS",
                "href": "https://apis.control4.com/account/v3/rest/accounts/000000",
                "name": "Name"
            }
            ```
        """
        data = await self.__sendAccountGetRequest(GET_CONTROLLERS_ENDPOINT)
        jsonDictionary = json.loads(data)
        return jsonDictionary["account"]

    async def getControllerInfo(self, controller_href):
        """Returns a dictionary of the information of a specific controller.

        Parameters:
            `account_bearer_token` - Control4 account bearer token.

            `controller_href` - The API `href` of the controller (get this from the output of `getAccountControllers()`)

        Returns:
            ```
            {
                'allowsPatching': True,
                'allowsSupport': False,
                'blockNotifications': False,
                'controllerCommonName': 'control4_MODEL_MACADDRESS',
                'controller': {
                    'href': 'https://apis.control4.com/account/v3/rest/accounts/000000/controller'
                },
                'created': '2017-08-26T18:33:31Z',
                'dealer': {
                    'href': 'https://apis.control4.com/account/v3/rest/dealers/12345'
                },
                'enabled': True,
                'hasLoggedIn': True,
                'href': 'https://apis.control4.com/account/v3/rest/accounts/000000',
                'id': 000000,
                'lastCheckIn': '2020-06-13T21:52:34Z',
                'licenses': {
                    'href': 'https://apis.control4.com/account/v3/rest/accounts/000000/licenses'
                },
                'modified': '2020-06-13T21:52:34Z',
                'name': 'Name',
                'provisionDate': '2017-08-26T18:35:11Z',
                'storage': {
                    'href': 'https://apis.control4.com/storage/v1/rest/accounts/000000'
                },
                'type': 'Consumer',
                'users': {
                    'href': 'https://apis.control4.com/account/v3/rest/accounts/000000/users'
                }
            }
            ```
        """
        data = await self.__sendAccountGetRequest(controller_href)
        jsonDictionary = json.loads(data)
        return jsonDictionary

    async def getDirectorBearerToken(self, controller_common_name):
        """Returns a director bearer token for making Control4 Director API requests.

        Parameters:
            `account_bearer_token` - Control4 account bearer token.

            `controller_common_name`: Common name of the controller. See `getAccountControllers()` for details.
        """
        data = await self.__sendControllerAuthRequest(controller_common_name)
        jsonDictionary = json.loads(data)
        return jsonDictionary["authToken"]["token"]

Methods

async def getAccountBearerToken(self)

Returns an account bearer token for making Control4 online API requests.

Parameters

username - Control4 account username/email.

password - Control4 account password.

Expand source code
async def getAccountBearerToken(self):
    """Returns an account bearer token for making Control4 online API requests.

    Parameters:
        `username` - Control4 account username/email.

        `password` - Control4 account password.
    """
    data = await self.__sendAccountAuthRequest()
    jsonDictionary = json.loads(data)
    try:
        self.account_bearer_token = jsonDictionary["authToken"]["token"]
    except KeyError:
        msg = "Did not recieve an account bearer token. Is your username/password correct? "
        _LOGGER.error(msg + data)
        raise
async def getAccountControllers(self)

Returns a dictionary of the information for all controllers registered to an account.

Parameters

account_bearer_token - Control4 account bearer token.

Returns

{    
    "controllerCommonName": "control4_MODEL_MACADDRESS",
    "href": "https://apis.control4.com/account/v3/rest/accounts/000000",
    "name": "Name"
}
Expand source code
async def getAccountControllers(self):
    """Returns a dictionary of the information for all controllers registered to an account.

    Parameters:
        `account_bearer_token` - Control4 account bearer token.

    Returns:
        ```
        {    
            "controllerCommonName": "control4_MODEL_MACADDRESS",
            "href": "https://apis.control4.com/account/v3/rest/accounts/000000",
            "name": "Name"
        }
        ```
    """
    data = await self.__sendAccountGetRequest(GET_CONTROLLERS_ENDPOINT)
    jsonDictionary = json.loads(data)
    return jsonDictionary["account"]
async def getControllerInfo(self, controller_href)

Returns a dictionary of the information of a specific controller.

Parameters

account_bearer_token - Control4 account bearer token.

controller_href - The API href of the controller (get this from the output of getAccountControllers())

Returns

{
    'allowsPatching': True,
    'allowsSupport': False,
    'blockNotifications': False,
    'controllerCommonName': 'control4_MODEL_MACADDRESS',
    'controller': {
        'href': 'https://apis.control4.com/account/v3/rest/accounts/000000/controller'
    },
    'created': '2017-08-26T18:33:31Z',
    'dealer': {
        'href': 'https://apis.control4.com/account/v3/rest/dealers/12345'
    },
    'enabled': True,
    'hasLoggedIn': True,
    'href': 'https://apis.control4.com/account/v3/rest/accounts/000000',
    'id': 000000,
    'lastCheckIn': '2020-06-13T21:52:34Z',
    'licenses': {
        'href': 'https://apis.control4.com/account/v3/rest/accounts/000000/licenses'
    },
    'modified': '2020-06-13T21:52:34Z',
    'name': 'Name',
    'provisionDate': '2017-08-26T18:35:11Z',
    'storage': {
        'href': 'https://apis.control4.com/storage/v1/rest/accounts/000000'
    },
    'type': 'Consumer',
    'users': {
        'href': 'https://apis.control4.com/account/v3/rest/accounts/000000/users'
    }
}
Expand source code
async def getControllerInfo(self, controller_href):
    """Returns a dictionary of the information of a specific controller.

    Parameters:
        `account_bearer_token` - Control4 account bearer token.

        `controller_href` - The API `href` of the controller (get this from the output of `getAccountControllers()`)

    Returns:
        ```
        {
            'allowsPatching': True,
            'allowsSupport': False,
            'blockNotifications': False,
            'controllerCommonName': 'control4_MODEL_MACADDRESS',
            'controller': {
                'href': 'https://apis.control4.com/account/v3/rest/accounts/000000/controller'
            },
            'created': '2017-08-26T18:33:31Z',
            'dealer': {
                'href': 'https://apis.control4.com/account/v3/rest/dealers/12345'
            },
            'enabled': True,
            'hasLoggedIn': True,
            'href': 'https://apis.control4.com/account/v3/rest/accounts/000000',
            'id': 000000,
            'lastCheckIn': '2020-06-13T21:52:34Z',
            'licenses': {
                'href': 'https://apis.control4.com/account/v3/rest/accounts/000000/licenses'
            },
            'modified': '2020-06-13T21:52:34Z',
            'name': 'Name',
            'provisionDate': '2017-08-26T18:35:11Z',
            'storage': {
                'href': 'https://apis.control4.com/storage/v1/rest/accounts/000000'
            },
            'type': 'Consumer',
            'users': {
                'href': 'https://apis.control4.com/account/v3/rest/accounts/000000/users'
            }
        }
        ```
    """
    data = await self.__sendAccountGetRequest(controller_href)
    jsonDictionary = json.loads(data)
    return jsonDictionary
async def getDirectorBearerToken(self, controller_common_name)

Returns a director bearer token for making Control4 Director API requests.

Parameters

account_bearer_token - Control4 account bearer token.

controller_common_name: Common name of the controller. See getAccountControllers() for details.

Expand source code
async def getDirectorBearerToken(self, controller_common_name):
    """Returns a director bearer token for making Control4 Director API requests.

    Parameters:
        `account_bearer_token` - Control4 account bearer token.

        `controller_common_name`: Common name of the controller. See `getAccountControllers()` for details.
    """
    data = await self.__sendControllerAuthRequest(controller_common_name)
    jsonDictionary = json.loads(data)
    return jsonDictionary["authToken"]["token"]
lawtancool-pyControl4-5334f51/docs/blind.html000066400000000000000000000653121514675053300211320ustar00rootroot00000000000000 pyControl4.blind API documentation

Module pyControl4.blind

Controls Control4 blind devices.

Classes

class C4Blind (director: C4Director, item_id: int)
Expand source code
class C4Blind(C4Entity):
    async def get_battery_level(self) -> int | None:
        """Returns the battery of a blind. We currently don't know the range or
        meaning.
        """
        value = await self.director.get_item_variable_value(
            self.item_id, "Battery Level"
        )
        if value is None:
            return None
        return int(value)

    async def get_closing(self) -> bool | None:
        """Returns an indication of whether the blind is moving in the closed direction
        as a boolean (True=closing, False=opening). If the blind is stopped, reports
        the direction it last moved.
        """
        value = await self.director.get_item_variable_value(self.item_id, "Closing")
        if value is None:
            return None
        return bool(value)

    async def get_fully_closed(self) -> bool | None:
        """Returns an indication of whether the blind is fully closed as a boolean
        (True=fully closed, False=at least partially open)."""
        value = await self.director.get_item_variable_value(
            self.item_id, "Fully Closed"
        )
        if value is None:
            return None
        return bool(value)

    async def get_fully_open(self) -> bool | None:
        """Returns an indication of whether the blind is fully open as a boolean
        (True=fully open, False=at least partially closed)."""
        value = await self.director.get_item_variable_value(self.item_id, "Fully Open")
        if value is None:
            return None
        return bool(value)

    async def get_level(self) -> int | None:
        """Returns the level (current position) of a blind as an int 0-100.
        0 is fully closed and 100 is fully open.
        """
        value = await self.director.get_item_variable_value(self.item_id, "Level")
        if value is None:
            return None
        return int(value)

    async def get_open(self) -> bool | None:
        """Returns an indication of whether the blind is open as a boolean (True=open,
        False=closed). This is true even if the blind is only partially open.
        """
        value = await self.director.get_item_variable_value(self.item_id, "Open")
        if value is None:
            return None
        return bool(value)

    async def get_opening(self) -> bool | None:
        """Returns an indication of whether the blind is moving in the open direction
        as a boolean (True=opening, False=closing). If the blind is stopped, reports
        the direction it last moved.
        """
        value = await self.director.get_item_variable_value(self.item_id, "Opening")
        if value is None:
            return None
        return bool(value)

    async def get_stopped(self) -> bool | None:
        """Returns an indication of whether the blind is stopped as a boolean
        (True=stopped, False=moving)."""
        value = await self.director.get_item_variable_value(self.item_id, "Stopped")
        if value is None:
            return None
        return bool(value)

    async def get_target_level(self) -> int | None:
        """Returns the target level (desired position) of a blind as an int 0-100.
         The blind will move if this is different from the current level.
        0 is fully closed and 100 is fully open.
        """
        value = await self.director.get_item_variable_value(
            self.item_id, "Target Level"
        )
        if value is None:
            return None
        return int(value)

    async def open(self) -> None:
        """Opens the blind completely."""
        await self.director.send_post_request(
            f"/api/v1/items/{self.item_id}/commands",
            "SET_LEVEL_TARGET:LEVEL_TARGET_OPEN",
            {},
        )

    async def close(self) -> None:
        """Closes the blind completely."""
        await self.director.send_post_request(
            f"/api/v1/items/{self.item_id}/commands",
            "SET_LEVEL_TARGET:LEVEL_TARGET_CLOSED",
            {},
        )

    async def set_level_target(self, level: int) -> None:
        """Sets the desired level of a blind; it will start moving towards that level.
        Level 0 is fully closed and level 100 is fully open.

        Parameters:
            `level` - (int) 0-100
        """
        await self.director.send_post_request(
            f"/api/v1/items/{self.item_id}/commands",
            "SET_LEVEL_TARGET",
            {"LEVEL_TARGET": level},
        )

    async def stop(self) -> None:
        """Stops the blind if it is moving. Shortly after stopping, the target level
        will be set to the level the blind had actually reached when it stopped.
        """
        await self.director.send_post_request(
            f"/api/v1/items/{self.item_id}/commands",
            "STOP",
            {},
        )

    async def toggle(self) -> None:
        """Toggles the blind between open and closed. Has no effect if the blind is
        partially open.
        """
        await self.director.send_post_request(
            f"/api/v1/items/{self.item_id}/commands",
            "TOGGLE",
            {},
        )

Creates a Control4 object.

Parameters

director - A C4Director object that corresponds to the Control4 Director that the device is connected to.

item_id - The Control4 item ID.

Ancestors

Methods

async def close(self) ‑> None
Expand source code
async def close(self) -> None:
    """Closes the blind completely."""
    await self.director.send_post_request(
        f"/api/v1/items/{self.item_id}/commands",
        "SET_LEVEL_TARGET:LEVEL_TARGET_CLOSED",
        {},
    )

Closes the blind completely.

async def get_battery_level(self) ‑> int | None
Expand source code
async def get_battery_level(self) -> int | None:
    """Returns the battery of a blind. We currently don't know the range or
    meaning.
    """
    value = await self.director.get_item_variable_value(
        self.item_id, "Battery Level"
    )
    if value is None:
        return None
    return int(value)

Returns the battery of a blind. We currently don't know the range or meaning.

async def get_closing(self) ‑> bool | None
Expand source code
async def get_closing(self) -> bool | None:
    """Returns an indication of whether the blind is moving in the closed direction
    as a boolean (True=closing, False=opening). If the blind is stopped, reports
    the direction it last moved.
    """
    value = await self.director.get_item_variable_value(self.item_id, "Closing")
    if value is None:
        return None
    return bool(value)

Returns an indication of whether the blind is moving in the closed direction as a boolean (True=closing, False=opening). If the blind is stopped, reports the direction it last moved.

async def get_fully_closed(self) ‑> bool | None
Expand source code
async def get_fully_closed(self) -> bool | None:
    """Returns an indication of whether the blind is fully closed as a boolean
    (True=fully closed, False=at least partially open)."""
    value = await self.director.get_item_variable_value(
        self.item_id, "Fully Closed"
    )
    if value is None:
        return None
    return bool(value)

Returns an indication of whether the blind is fully closed as a boolean (True=fully closed, False=at least partially open).

async def get_fully_open(self) ‑> bool | None
Expand source code
async def get_fully_open(self) -> bool | None:
    """Returns an indication of whether the blind is fully open as a boolean
    (True=fully open, False=at least partially closed)."""
    value = await self.director.get_item_variable_value(self.item_id, "Fully Open")
    if value is None:
        return None
    return bool(value)

Returns an indication of whether the blind is fully open as a boolean (True=fully open, False=at least partially closed).

async def get_level(self) ‑> int | None
Expand source code
async def get_level(self) -> int | None:
    """Returns the level (current position) of a blind as an int 0-100.
    0 is fully closed and 100 is fully open.
    """
    value = await self.director.get_item_variable_value(self.item_id, "Level")
    if value is None:
        return None
    return int(value)

Returns the level (current position) of a blind as an int 0-100. 0 is fully closed and 100 is fully open.

async def get_open(self) ‑> bool | None
Expand source code
async def get_open(self) -> bool | None:
    """Returns an indication of whether the blind is open as a boolean (True=open,
    False=closed). This is true even if the blind is only partially open.
    """
    value = await self.director.get_item_variable_value(self.item_id, "Open")
    if value is None:
        return None
    return bool(value)

Returns an indication of whether the blind is open as a boolean (True=open, False=closed). This is true even if the blind is only partially open.

async def get_opening(self) ‑> bool | None
Expand source code
async def get_opening(self) -> bool | None:
    """Returns an indication of whether the blind is moving in the open direction
    as a boolean (True=opening, False=closing). If the blind is stopped, reports
    the direction it last moved.
    """
    value = await self.director.get_item_variable_value(self.item_id, "Opening")
    if value is None:
        return None
    return bool(value)

Returns an indication of whether the blind is moving in the open direction as a boolean (True=opening, False=closing). If the blind is stopped, reports the direction it last moved.

async def get_stopped(self) ‑> bool | None
Expand source code
async def get_stopped(self) -> bool | None:
    """Returns an indication of whether the blind is stopped as a boolean
    (True=stopped, False=moving)."""
    value = await self.director.get_item_variable_value(self.item_id, "Stopped")
    if value is None:
        return None
    return bool(value)

Returns an indication of whether the blind is stopped as a boolean (True=stopped, False=moving).

async def get_target_level(self) ‑> int | None
Expand source code
async def get_target_level(self) -> int | None:
    """Returns the target level (desired position) of a blind as an int 0-100.
     The blind will move if this is different from the current level.
    0 is fully closed and 100 is fully open.
    """
    value = await self.director.get_item_variable_value(
        self.item_id, "Target Level"
    )
    if value is None:
        return None
    return int(value)

Returns the target level (desired position) of a blind as an int 0-100. The blind will move if this is different from the current level. 0 is fully closed and 100 is fully open.

async def open(self) ‑> None
Expand source code
async def open(self) -> None:
    """Opens the blind completely."""
    await self.director.send_post_request(
        f"/api/v1/items/{self.item_id}/commands",
        "SET_LEVEL_TARGET:LEVEL_TARGET_OPEN",
        {},
    )

Opens the blind completely.

async def set_level_target(self, level: int) ‑> None
Expand source code
async def set_level_target(self, level: int) -> None:
    """Sets the desired level of a blind; it will start moving towards that level.
    Level 0 is fully closed and level 100 is fully open.

    Parameters:
        `level` - (int) 0-100
    """
    await self.director.send_post_request(
        f"/api/v1/items/{self.item_id}/commands",
        "SET_LEVEL_TARGET",
        {"LEVEL_TARGET": level},
    )

Sets the desired level of a blind; it will start moving towards that level. Level 0 is fully closed and level 100 is fully open.

Parameters

level - (int) 0-100

async def stop(self) ‑> None
Expand source code
async def stop(self) -> None:
    """Stops the blind if it is moving. Shortly after stopping, the target level
    will be set to the level the blind had actually reached when it stopped.
    """
    await self.director.send_post_request(
        f"/api/v1/items/{self.item_id}/commands",
        "STOP",
        {},
    )

Stops the blind if it is moving. Shortly after stopping, the target level will be set to the level the blind had actually reached when it stopped.

async def toggle(self) ‑> None
Expand source code
async def toggle(self) -> None:
    """Toggles the blind between open and closed. Has no effect if the blind is
    partially open.
    """
    await self.director.send_post_request(
        f"/api/v1/items/{self.item_id}/commands",
        "TOGGLE",
        {},
    )

Toggles the blind between open and closed. Has no effect if the blind is partially open.

lawtancool-pyControl4-5334f51/docs/climate.html000066400000000000000000001064061514675053300214600ustar00rootroot00000000000000 pyControl4.climate API documentation

Module pyControl4.climate

Controls Control4 Climate Control devices.

Classes

class C4Climate (director: C4Director, item_id: int)
Expand source code
class C4Climate(C4Entity):
    # ------------------------
    # HVAC and Fan States
    # ------------------------

    async def get_hvac_state(self) -> str | None:
        """Returns the current HVAC state (e.g., on/off or active mode)."""
        return await self.director.get_item_variable_value(self.item_id, "HVAC_STATE")

    async def get_fan_state(self) -> str | None:
        """Returns the current power state of the fan (on, off)."""
        return await self.director.get_item_variable_value(self.item_id, "FAN_STATE")

    # ------------------------
    # Mode Getters
    # ------------------------

    async def get_hvac_mode(self) -> str | None:
        """Returns the currently active HVAC mode."""
        return await self.director.get_item_variable_value(self.item_id, "HVAC_MODE")

    async def get_hvac_modes(self) -> list[str] | None:
        """Returns a list of supported HVAC modes."""
        value = await self.director.get_item_variable_value(
            self.item_id, "HVAC_MODES_LIST"
        )
        if value is None:
            return None
        return [m.strip() for m in value.split(",") if m.strip()]

    async def get_fan_mode(self) -> str | None:
        """Returns the currently active fan mode."""
        return await self.director.get_item_variable_value(self.item_id, "FAN_MODE")

    async def get_fan_modes(self) -> list[str] | None:
        """Returns a list of supported fan modes."""
        value = await self.director.get_item_variable_value(
            self.item_id, "FAN_MODES_LIST"
        )
        if value is None:
            return None
        return [m.strip() for m in value.split(",") if m.strip()]

    async def get_hold_mode(self) -> str | None:
        """Returns the currently active hold mode."""
        return await self.director.get_item_variable_value(self.item_id, "HOLD_MODE")

    async def get_hold_modes(self) -> list[str] | None:
        """Returns a list of supported hold modes."""
        value = await self.director.get_item_variable_value(
            self.item_id, "HOLD_MODES_LIST"
        )
        if value is None:
            return None
        return [m.strip() for m in value.split(",") if m.strip()]

    # ------------------------
    # Setpoint Getters
    # ------------------------

    async def get_cool_setpoint_f(self) -> float | None:
        """Returns the cooling setpoint temperature in Fahrenheit."""
        value = await self.director.get_item_variable_value(
            self.item_id, "COOL_SETPOINT_F"
        )
        if value is None:
            return None
        return float(value)

    async def get_cool_setpoint_c(self) -> float | None:
        """Returns the cooling setpoint temperature in Celsius."""
        value = await self.director.get_item_variable_value(
            self.item_id, "COOL_SETPOINT_C"
        )
        if value is None:
            return None
        return float(value)

    async def get_heat_setpoint_f(self) -> float | None:
        """Returns the heating setpoint temperature in Fahrenheit."""
        value = await self.director.get_item_variable_value(
            self.item_id, "HEAT_SETPOINT_F"
        )
        if value is None:
            return None
        return float(value)

    async def get_heat_setpoint_c(self) -> float | None:
        """Returns the heating setpoint temperature in Celsius."""
        value = await self.director.get_item_variable_value(
            self.item_id, "HEAT_SETPOINT_C"
        )
        if value is None:
            return None
        return float(value)

    # ------------------------
    # Sensor Readings
    # ------------------------

    async def get_humidity(self) -> float | None:
        """Returns the current humidity percentage."""
        value = await self.director.get_item_variable_value(self.item_id, "HUMIDITY")
        if value is None:
            return None
        return float(value)

    async def get_current_temperature_f(self) -> float | None:
        """Returns the current ambient temperature in Fahrenheit."""
        value = await self.director.get_item_variable_value(
            self.item_id, "TEMPERATURE_F"
        )
        if value is None:
            return None
        return float(value)

    async def get_current_temperature_c(self) -> float | None:
        """Returns the current ambient temperature in Celsius."""
        value = await self.director.get_item_variable_value(
            self.item_id, "TEMPERATURE_C"
        )
        if value is None:
            return None
        return float(value)

    # ------------------------
    # Setters / Commands
    # ------------------------

    async def set_cool_setpoint_f(self, temp: float) -> None:
        """Sets the cooling setpoint temperature in Fahrenheit."""
        await self.director.send_post_request(
            f"/api/v1/items/{self.item_id}/commands",
            "SET_SETPOINT_COOL",
            {"FAHRENHEIT": temp},
        )

    async def set_cool_setpoint_c(self, temp: float) -> None:
        """Sets the cooling setpoint temperature in Celsius."""
        await self.director.send_post_request(
            f"/api/v1/items/{self.item_id}/commands",
            "SET_SETPOINT_COOL",
            {"CELSIUS": temp},
        )

    async def set_heat_setpoint_f(self, temp: float) -> None:
        """Sets the heating setpoint temperature in Fahrenheit."""
        await self.director.send_post_request(
            f"/api/v1/items/{self.item_id}/commands",
            "SET_SETPOINT_HEAT",
            {"FAHRENHEIT": temp},
        )

    async def set_heat_setpoint_c(self, temp: float) -> None:
        """Sets the heating setpoint temperature in Celsius."""
        await self.director.send_post_request(
            f"/api/v1/items/{self.item_id}/commands",
            "SET_SETPOINT_HEAT",
            {"CELSIUS": temp},
        )

    async def set_hvac_mode(self, mode: str) -> None:
        """Sets the HVAC operating mode (e.g., heat, cool, auto)."""
        await self.director.send_post_request(
            f"/api/v1/items/{self.item_id}/commands",
            "SET_MODE_HVAC",
            {"MODE": mode},
        )

    async def set_fan_mode(self, mode: str) -> None:
        """Sets the fan operating mode (e.g., auto, on, circulate)."""
        await self.director.send_post_request(
            f"/api/v1/items/{self.item_id}/commands",
            "SET_MODE_FAN",
            {"MODE": mode},
        )

    async def set_preset(self, preset: str) -> None:
        """Applies a predefined climate preset by name."""
        await self.director.send_post_request(
            f"/api/v1/items/{self.item_id}/commands",
            "SET_PRESET",
            {"NAME": preset},
        )

    async def set_hold_mode(self, mode: str) -> None:
        """Sets the hold mode."""
        await self.director.send_post_request(
            f"/api/v1/items/{self.item_id}/commands",
            "SET_MODE_HOLD",
            {"MODE": mode},
        )

Creates a Control4 object.

Parameters

director - A C4Director object that corresponds to the Control4 Director that the device is connected to.

item_id - The Control4 item ID.

Ancestors

Methods

async def get_cool_setpoint_c(self) ‑> float | None
Expand source code
async def get_cool_setpoint_c(self) -> float | None:
    """Returns the cooling setpoint temperature in Celsius."""
    value = await self.director.get_item_variable_value(
        self.item_id, "COOL_SETPOINT_C"
    )
    if value is None:
        return None
    return float(value)

Returns the cooling setpoint temperature in Celsius.

async def get_cool_setpoint_f(self) ‑> float | None
Expand source code
async def get_cool_setpoint_f(self) -> float | None:
    """Returns the cooling setpoint temperature in Fahrenheit."""
    value = await self.director.get_item_variable_value(
        self.item_id, "COOL_SETPOINT_F"
    )
    if value is None:
        return None
    return float(value)

Returns the cooling setpoint temperature in Fahrenheit.

async def get_current_temperature_c(self) ‑> float | None
Expand source code
async def get_current_temperature_c(self) -> float | None:
    """Returns the current ambient temperature in Celsius."""
    value = await self.director.get_item_variable_value(
        self.item_id, "TEMPERATURE_C"
    )
    if value is None:
        return None
    return float(value)

Returns the current ambient temperature in Celsius.

async def get_current_temperature_f(self) ‑> float | None
Expand source code
async def get_current_temperature_f(self) -> float | None:
    """Returns the current ambient temperature in Fahrenheit."""
    value = await self.director.get_item_variable_value(
        self.item_id, "TEMPERATURE_F"
    )
    if value is None:
        return None
    return float(value)

Returns the current ambient temperature in Fahrenheit.

async def get_fan_mode(self) ‑> str | None
Expand source code
async def get_fan_mode(self) -> str | None:
    """Returns the currently active fan mode."""
    return await self.director.get_item_variable_value(self.item_id, "FAN_MODE")

Returns the currently active fan mode.

async def get_fan_modes(self) ‑> list[str] | None
Expand source code
async def get_fan_modes(self) -> list[str] | None:
    """Returns a list of supported fan modes."""
    value = await self.director.get_item_variable_value(
        self.item_id, "FAN_MODES_LIST"
    )
    if value is None:
        return None
    return [m.strip() for m in value.split(",") if m.strip()]

Returns a list of supported fan modes.

async def get_fan_state(self) ‑> str | None
Expand source code
async def get_fan_state(self) -> str | None:
    """Returns the current power state of the fan (on, off)."""
    return await self.director.get_item_variable_value(self.item_id, "FAN_STATE")

Returns the current power state of the fan (on, off).

async def get_heat_setpoint_c(self) ‑> float | None
Expand source code
async def get_heat_setpoint_c(self) -> float | None:
    """Returns the heating setpoint temperature in Celsius."""
    value = await self.director.get_item_variable_value(
        self.item_id, "HEAT_SETPOINT_C"
    )
    if value is None:
        return None
    return float(value)

Returns the heating setpoint temperature in Celsius.

async def get_heat_setpoint_f(self) ‑> float | None
Expand source code
async def get_heat_setpoint_f(self) -> float | None:
    """Returns the heating setpoint temperature in Fahrenheit."""
    value = await self.director.get_item_variable_value(
        self.item_id, "HEAT_SETPOINT_F"
    )
    if value is None:
        return None
    return float(value)

Returns the heating setpoint temperature in Fahrenheit.

async def get_hold_mode(self) ‑> str | None
Expand source code
async def get_hold_mode(self) -> str | None:
    """Returns the currently active hold mode."""
    return await self.director.get_item_variable_value(self.item_id, "HOLD_MODE")

Returns the currently active hold mode.

async def get_hold_modes(self) ‑> list[str] | None
Expand source code
async def get_hold_modes(self) -> list[str] | None:
    """Returns a list of supported hold modes."""
    value = await self.director.get_item_variable_value(
        self.item_id, "HOLD_MODES_LIST"
    )
    if value is None:
        return None
    return [m.strip() for m in value.split(",") if m.strip()]

Returns a list of supported hold modes.

async def get_humidity(self) ‑> float | None
Expand source code
async def get_humidity(self) -> float | None:
    """Returns the current humidity percentage."""
    value = await self.director.get_item_variable_value(self.item_id, "HUMIDITY")
    if value is None:
        return None
    return float(value)

Returns the current humidity percentage.

async def get_hvac_mode(self) ‑> str | None
Expand source code
async def get_hvac_mode(self) -> str | None:
    """Returns the currently active HVAC mode."""
    return await self.director.get_item_variable_value(self.item_id, "HVAC_MODE")

Returns the currently active HVAC mode.

async def get_hvac_modes(self) ‑> list[str] | None
Expand source code
async def get_hvac_modes(self) -> list[str] | None:
    """Returns a list of supported HVAC modes."""
    value = await self.director.get_item_variable_value(
        self.item_id, "HVAC_MODES_LIST"
    )
    if value is None:
        return None
    return [m.strip() for m in value.split(",") if m.strip()]

Returns a list of supported HVAC modes.

async def get_hvac_state(self) ‑> str | None
Expand source code
async def get_hvac_state(self) -> str | None:
    """Returns the current HVAC state (e.g., on/off or active mode)."""
    return await self.director.get_item_variable_value(self.item_id, "HVAC_STATE")

Returns the current HVAC state (e.g., on/off or active mode).

async def set_cool_setpoint_c(self, temp: float) ‑> None
Expand source code
async def set_cool_setpoint_c(self, temp: float) -> None:
    """Sets the cooling setpoint temperature in Celsius."""
    await self.director.send_post_request(
        f"/api/v1/items/{self.item_id}/commands",
        "SET_SETPOINT_COOL",
        {"CELSIUS": temp},
    )

Sets the cooling setpoint temperature in Celsius.

async def set_cool_setpoint_f(self, temp: float) ‑> None
Expand source code
async def set_cool_setpoint_f(self, temp: float) -> None:
    """Sets the cooling setpoint temperature in Fahrenheit."""
    await self.director.send_post_request(
        f"/api/v1/items/{self.item_id}/commands",
        "SET_SETPOINT_COOL",
        {"FAHRENHEIT": temp},
    )

Sets the cooling setpoint temperature in Fahrenheit.

async def set_fan_mode(self, mode: str) ‑> None
Expand source code
async def set_fan_mode(self, mode: str) -> None:
    """Sets the fan operating mode (e.g., auto, on, circulate)."""
    await self.director.send_post_request(
        f"/api/v1/items/{self.item_id}/commands",
        "SET_MODE_FAN",
        {"MODE": mode},
    )

Sets the fan operating mode (e.g., auto, on, circulate).

async def set_heat_setpoint_c(self, temp: float) ‑> None
Expand source code
async def set_heat_setpoint_c(self, temp: float) -> None:
    """Sets the heating setpoint temperature in Celsius."""
    await self.director.send_post_request(
        f"/api/v1/items/{self.item_id}/commands",
        "SET_SETPOINT_HEAT",
        {"CELSIUS": temp},
    )

Sets the heating setpoint temperature in Celsius.

async def set_heat_setpoint_f(self, temp: float) ‑> None
Expand source code
async def set_heat_setpoint_f(self, temp: float) -> None:
    """Sets the heating setpoint temperature in Fahrenheit."""
    await self.director.send_post_request(
        f"/api/v1/items/{self.item_id}/commands",
        "SET_SETPOINT_HEAT",
        {"FAHRENHEIT": temp},
    )

Sets the heating setpoint temperature in Fahrenheit.

async def set_hold_mode(self, mode: str) ‑> None
Expand source code
async def set_hold_mode(self, mode: str) -> None:
    """Sets the hold mode."""
    await self.director.send_post_request(
        f"/api/v1/items/{self.item_id}/commands",
        "SET_MODE_HOLD",
        {"MODE": mode},
    )

Sets the hold mode.

async def set_hvac_mode(self, mode: str) ‑> None
Expand source code
async def set_hvac_mode(self, mode: str) -> None:
    """Sets the HVAC operating mode (e.g., heat, cool, auto)."""
    await self.director.send_post_request(
        f"/api/v1/items/{self.item_id}/commands",
        "SET_MODE_HVAC",
        {"MODE": mode},
    )

Sets the HVAC operating mode (e.g., heat, cool, auto).

async def set_preset(self, preset: str) ‑> None
Expand source code
async def set_preset(self, preset: str) -> None:
    """Applies a predefined climate preset by name."""
    await self.director.send_post_request(
        f"/api/v1/items/{self.item_id}/commands",
        "SET_PRESET",
        {"NAME": preset},
    )

Applies a predefined climate preset by name.

lawtancool-pyControl4-5334f51/docs/director.html000066400000000000000000001225261514675053300216560ustar00rootroot00000000000000 pyControl4.director API documentation

Module pyControl4.director

Handles communication with a Control4 Director, and provides functions for getting details about items on the Director.

Classes

class C4Director (ip: str,
director_bearer_token: str,
session_no_verify_ssl: aiohttp.ClientSession | None = None)
Expand source code
class C4Director:
    def __init__(
        self,
        ip: str,
        director_bearer_token: str,
        session_no_verify_ssl: aiohttp.ClientSession | None = None,
    ):
        """Creates a Control4 Director object.

        Parameters:
            `ip` - The IP address of the Control4 Director/Controller.

            `director_bearer_token` - The bearer token used to authenticate
                                      with the Director.
                See `pyControl4.account.C4Account.get_director_bearer_token`
                for how to get this.

            `session` - (Optional) Allows the use of an
                        `aiohttp.ClientSession` object
                        for all network requests. This
                        session will not be closed by the library.
                        If not provided, the library will open and
                        close its own `ClientSession`s as needed.
        """
        self.base_url = f"https://{ip}"
        self.headers = {"Authorization": f"Bearer {director_bearer_token}"}
        self.director_bearer_token = director_bearer_token
        self.session = session_no_verify_ssl

    @asynccontextmanager
    async def _get_session(self) -> AsyncGenerator[aiohttp.ClientSession, None]:
        """Returns the configured session or creates a temporary one.

        If self.session is set, yields it without closing.
        Otherwise, creates and closes a temporary session.
        """
        if self.session is not None:
            yield self.session
        else:
            async with aiohttp.ClientSession(
                connector=aiohttp.TCPConnector(verify_ssl=False)
            ) as session:
                yield session

    async def send_get_request(self, uri: str) -> str:
        """Sends a GET request to the specified API URI.
        Returns the Director's JSON response as a string.

        Parameters:
            `uri` - The API URI to send the request to. Do not include the IP
                    address of the Director.
        """
        async with self._get_session() as session:
            async with asyncio.timeout(10):
                async with session.get(
                    self.base_url + uri, headers=self.headers
                ) as resp:
                    text = await resp.text()
                    check_response_for_error(text)
                    return text

    async def send_post_request(
        self, uri: str, command: str, params: dict[str, Any], is_async: bool = True
    ) -> str:
        """Sends a POST request to the specified API URI. Used to send commands
           to the Director.
        Returns the Director's JSON response as a string.

        Parameters:
            `uri` - The API URI to send the request to. Do not include the IP
                    address of the Director.

            `command` - The Control4 command to send.

            `params` - The parameters of the command, provided as a dictionary.
        """
        data_dict = {
            "async": is_async,
            "command": command,
            "tParams": params,
        }
        async with self._get_session() as session:
            async with asyncio.timeout(10):
                async with session.post(
                    self.base_url + uri, headers=self.headers, json=data_dict
                ) as resp:
                    text = await resp.text()
                    check_response_for_error(text)
                    return text

    async def get_all_items_by_category(self, category: str) -> list[dict[str, Any]]:
        """Returns a list of items related to a particular category.

        Parameters:
            `category` - Control4 Category Name: controllers, comfort, lights,
                         cameras, sensors, audio_video,
                         motorization, thermostats, motors,
                         control4_remote_hub,
                         outlet_wireless_dimmer, voice-scene
        """
        data = await self.send_get_request(f"/api/v1/categories/{category}")
        result: list[dict[str, Any]] = json.loads(data)
        return result

    async def get_all_item_info(self) -> list[dict[str, Any]]:
        """Returns a list of all the items on the Director."""
        data = await self.send_get_request("/api/v1/items")
        result: list[dict[str, Any]] = json.loads(data)
        return result

    async def get_item_info(self, item_id: int) -> list[dict[str, Any]]:
        """Returns a list of the details of the specified item.

        Parameters:
            `item_id` - The Control4 item ID.
        """
        data = await self.send_get_request(f"/api/v1/items/{item_id}")
        result: list[dict[str, Any]] = json.loads(data)
        return result

    async def get_item_setup(self, item_id: int) -> dict[str, Any]:
        """Returns the setup info of the specified item.

        Parameters:
            `item_id` - The Control4 item ID.
        """
        data = await self.send_post_request(
            f"/api/v1/items/{item_id}/commands", "GET_SETUP", {}, False
        )
        result: dict[str, Any] = json.loads(data)
        return result

    async def get_item_variables(self, item_id: int) -> list[dict[str, Any]]:
        """Returns a list of the variables available for the specified item.

        Parameters:
            `item_id` - The Control4 item ID.
        """
        data = await self.send_get_request(f"/api/v1/items/{item_id}/variables")
        result: list[dict[str, Any]] = json.loads(data)
        return result

    async def get_item_variable_value(
        self, item_id: int, var_name: str | list[str] | tuple[str, ...] | set[str]
    ) -> Any | None:
        """Returns the value of the specified variable for the
        specified item.

        The returned value is the JSON ``"value"`` field from the Director
        response. If that field is the string ``"Undefined"``, this method
        returns ``None``.
        Parameters:
            `item_id` - The Control4 item ID.

            `var_name` - The Control4 variable name or names.
        """

        if isinstance(var_name, (tuple, list, set)):
            var_name = ",".join(var_name)

        data = await self.send_get_request(
            f"/api/v1/items/{item_id}/variables?varnames={var_name}"
        )
        if data == "[]":
            raise ValueError(
                f"Empty response received from Director! The variable {var_name} "
                f"doesn't seem to exist for item {item_id}."
            )
        json_dict = json.loads(data)
        if not isinstance(json_dict, list) or not json_dict:
            raise ValueError(
                f"Invalid response format from Director for variable {var_name}: {data}"
            )
        value = json_dict[0].get("value")
        if value == "Undefined":
            return None
        return value

    async def get_all_item_variable_value(
        self, var_name: str | list[str] | tuple[str, ...] | set[str]
    ) -> list[dict[str, Any]]:
        """Returns a list of dictionaries with the values of the specified variable
        for all items that have it.

        Parameters:
            `var_name` - The Control4 variable name or names.
        """
        if isinstance(var_name, (tuple, list, set)):
            var_name = ",".join(var_name)

        data = await self.send_get_request(
            f"/api/v1/items/variables?varnames={var_name}"
        )
        if data == "[]":
            raise ValueError(
                f"Empty response received from Director! The variable {var_name} "
                f"doesn't seem to exist for any items."
            )
        json_dict: list[dict[str, Any]] = json.loads(data)
        for item in json_dict:
            if item.get("value") == "Undefined":
                item["value"] = None
        return json_dict

    async def get_item_commands(self, item_id: int) -> list[dict[str, Any]]:
        """Returns the commands available for the specified item.

        Parameters:
            `item_id` - The Control4 item ID.
        """
        data = await self.send_get_request(f"/api/v1/items/{item_id}/commands")
        result: list[dict[str, Any]] = json.loads(data)
        return result

    async def get_item_network(self, item_id: int) -> list[dict[str, Any]]:
        """Returns the network information for the specified item.

        Parameters:
            `item_id` - The Control4 item ID.
        """
        data = await self.send_get_request(f"/api/v1/items/{item_id}/network")
        result: list[dict[str, Any]] = json.loads(data)
        return result

    async def get_item_bindings(self, item_id: int) -> list[dict[str, Any]]:
        """Returns the bindings information for the specified item.

        Parameters:
            `item_id` - The Control4 item ID.
        """
        data = await self.send_get_request(f"/api/v1/items/{item_id}/bindings")
        result: list[dict[str, Any]] = json.loads(data)
        return result

    async def get_ui_configuration(self) -> dict[str, Any]:
        """Returns a dictionary of the Control4 App UI Configuration enumerating
        rooms and capabilities

        Returns:

            {
             "experiences": [
                {
                 "type": "watch",
                 "sources": {
                    "source": [
                     {
                      "id": 59,
                      "type": "HDMI"
                     },
                     {
                      "id": 946,
                      "type": "HDMI"
                     },
                     {
                      "id": 950,
                      "type": "HDMI"
                     },
                     {
                      "id": 33,
                      "type": "VIDEO_SELECTION"
                     }
                    ]
                },
                 "active": false,
                 "room_id": 9,
                 "username": "primaryuser"
                },
                {
                 "type": "listen",
                 "sources": {
                    "source": [
                    {
                     "id": 298,
                     "type": "DIGITAL_AUDIO_SERVER",
                     "name": "My Music"
                    },
                    {
                     "id": 302,
                     "type": "AUDIO_SELECTION",
                     "name": "Stations"
                    },
                    {
                     "id": 306,
                     "type": "DIGITAL_AUDIO_SERVER",
                     "name": "ShairBridge"
                    },
                    {
                     "id": 937,
                     "type": "DIGITAL_AUDIO_SERVER",
                     "name": "Spotify Connect"
                    },
                    {
                     "id": 100002,
                     "type": "DIGITAL_AUDIO_CLIENT",
                     "name": "Digital Media"
                    }
                   ]
                },
                 "active": false,
                 "room_id": 9,
                 "username": "primaryuser"
                },
                {
                 "type": "cameras",
                 "sources": {
                    "source": [
                    {
                     "id": 877,
                     "type": "Camera"
                    },
                    ...
                }
                ...
            }
        """
        data = await self.send_get_request("/api/v1/agents/ui_configuration")
        result: dict[str, Any] = json.loads(data)
        return result

Creates a Control4 Director object.

Parameters

ip - The IP address of the Control4 Director/Controller.

director_bearer_token - The bearer token used to authenticate with the Director. See C4Account.get_director_bearer_token() for how to get this.

session - (Optional) Allows the use of an aiohttp.ClientSession object for all network requests. This session will not be closed by the library. If not provided, the library will open and close its own ClientSessions as needed.

Methods

async def get_all_item_info(self) ‑> list[dict[str, typing.Any]]
Expand source code
async def get_all_item_info(self) -> list[dict[str, Any]]:
    """Returns a list of all the items on the Director."""
    data = await self.send_get_request("/api/v1/items")
    result: list[dict[str, Any]] = json.loads(data)
    return result

Returns a list of all the items on the Director.

async def get_all_item_variable_value(self, var_name: str | list[str] | tuple[str, ...] | set[str]) ‑> list[dict[str, typing.Any]]
Expand source code
async def get_all_item_variable_value(
    self, var_name: str | list[str] | tuple[str, ...] | set[str]
) -> list[dict[str, Any]]:
    """Returns a list of dictionaries with the values of the specified variable
    for all items that have it.

    Parameters:
        `var_name` - The Control4 variable name or names.
    """
    if isinstance(var_name, (tuple, list, set)):
        var_name = ",".join(var_name)

    data = await self.send_get_request(
        f"/api/v1/items/variables?varnames={var_name}"
    )
    if data == "[]":
        raise ValueError(
            f"Empty response received from Director! The variable {var_name} "
            f"doesn't seem to exist for any items."
        )
    json_dict: list[dict[str, Any]] = json.loads(data)
    for item in json_dict:
        if item.get("value") == "Undefined":
            item["value"] = None
    return json_dict

Returns a list of dictionaries with the values of the specified variable for all items that have it.

Parameters

var_name - The Control4 variable name or names.

async def get_all_items_by_category(self, category: str) ‑> list[dict[str, typing.Any]]
Expand source code
async def get_all_items_by_category(self, category: str) -> list[dict[str, Any]]:
    """Returns a list of items related to a particular category.

    Parameters:
        `category` - Control4 Category Name: controllers, comfort, lights,
                     cameras, sensors, audio_video,
                     motorization, thermostats, motors,
                     control4_remote_hub,
                     outlet_wireless_dimmer, voice-scene
    """
    data = await self.send_get_request(f"/api/v1/categories/{category}")
    result: list[dict[str, Any]] = json.loads(data)
    return result

Returns a list of items related to a particular category.

Parameters

category - Control4 Category Name: controllers, comfort, lights, cameras, sensors, audio_video, motorization, thermostats, motors, control4_remote_hub, outlet_wireless_dimmer, voice-scene

async def get_item_bindings(self, item_id: int) ‑> list[dict[str, typing.Any]]
Expand source code
async def get_item_bindings(self, item_id: int) -> list[dict[str, Any]]:
    """Returns the bindings information for the specified item.

    Parameters:
        `item_id` - The Control4 item ID.
    """
    data = await self.send_get_request(f"/api/v1/items/{item_id}/bindings")
    result: list[dict[str, Any]] = json.loads(data)
    return result

Returns the bindings information for the specified item.

Parameters

item_id - The Control4 item ID.

async def get_item_commands(self, item_id: int) ‑> list[dict[str, typing.Any]]
Expand source code
async def get_item_commands(self, item_id: int) -> list[dict[str, Any]]:
    """Returns the commands available for the specified item.

    Parameters:
        `item_id` - The Control4 item ID.
    """
    data = await self.send_get_request(f"/api/v1/items/{item_id}/commands")
    result: list[dict[str, Any]] = json.loads(data)
    return result

Returns the commands available for the specified item.

Parameters

item_id - The Control4 item ID.

async def get_item_info(self, item_id: int) ‑> list[dict[str, typing.Any]]
Expand source code
async def get_item_info(self, item_id: int) -> list[dict[str, Any]]:
    """Returns a list of the details of the specified item.

    Parameters:
        `item_id` - The Control4 item ID.
    """
    data = await self.send_get_request(f"/api/v1/items/{item_id}")
    result: list[dict[str, Any]] = json.loads(data)
    return result

Returns a list of the details of the specified item.

Parameters

item_id - The Control4 item ID.

async def get_item_network(self, item_id: int) ‑> list[dict[str, typing.Any]]
Expand source code
async def get_item_network(self, item_id: int) -> list[dict[str, Any]]:
    """Returns the network information for the specified item.

    Parameters:
        `item_id` - The Control4 item ID.
    """
    data = await self.send_get_request(f"/api/v1/items/{item_id}/network")
    result: list[dict[str, Any]] = json.loads(data)
    return result

Returns the network information for the specified item.

Parameters

item_id - The Control4 item ID.

async def get_item_setup(self, item_id: int) ‑> dict[str, typing.Any]
Expand source code
async def get_item_setup(self, item_id: int) -> dict[str, Any]:
    """Returns the setup info of the specified item.

    Parameters:
        `item_id` - The Control4 item ID.
    """
    data = await self.send_post_request(
        f"/api/v1/items/{item_id}/commands", "GET_SETUP", {}, False
    )
    result: dict[str, Any] = json.loads(data)
    return result

Returns the setup info of the specified item.

Parameters

item_id - The Control4 item ID.

async def get_item_variable_value(self, item_id: int, var_name: str | list[str] | tuple[str, ...] | set[str]) ‑> Any | None
Expand source code
async def get_item_variable_value(
    self, item_id: int, var_name: str | list[str] | tuple[str, ...] | set[str]
) -> Any | None:
    """Returns the value of the specified variable for the
    specified item.

    The returned value is the JSON ``"value"`` field from the Director
    response. If that field is the string ``"Undefined"``, this method
    returns ``None``.
    Parameters:
        `item_id` - The Control4 item ID.

        `var_name` - The Control4 variable name or names.
    """

    if isinstance(var_name, (tuple, list, set)):
        var_name = ",".join(var_name)

    data = await self.send_get_request(
        f"/api/v1/items/{item_id}/variables?varnames={var_name}"
    )
    if data == "[]":
        raise ValueError(
            f"Empty response received from Director! The variable {var_name} "
            f"doesn't seem to exist for item {item_id}."
        )
    json_dict = json.loads(data)
    if not isinstance(json_dict, list) or not json_dict:
        raise ValueError(
            f"Invalid response format from Director for variable {var_name}: {data}"
        )
    value = json_dict[0].get("value")
    if value == "Undefined":
        return None
    return value

Returns the value of the specified variable for the specified item.

The returned value is the JSON "value" field from the Director response. If that field is the string "Undefined", this method returns None.

Parameters

item_id - The Control4 item ID.

var_name - The Control4 variable name or names.

async def get_item_variables(self, item_id: int) ‑> list[dict[str, typing.Any]]
Expand source code
async def get_item_variables(self, item_id: int) -> list[dict[str, Any]]:
    """Returns a list of the variables available for the specified item.

    Parameters:
        `item_id` - The Control4 item ID.
    """
    data = await self.send_get_request(f"/api/v1/items/{item_id}/variables")
    result: list[dict[str, Any]] = json.loads(data)
    return result

Returns a list of the variables available for the specified item.

Parameters

item_id - The Control4 item ID.

async def get_ui_configuration(self) ‑> dict[str, typing.Any]
Expand source code
async def get_ui_configuration(self) -> dict[str, Any]:
    """Returns a dictionary of the Control4 App UI Configuration enumerating
    rooms and capabilities

    Returns:

        {
         "experiences": [
            {
             "type": "watch",
             "sources": {
                "source": [
                 {
                  "id": 59,
                  "type": "HDMI"
                 },
                 {
                  "id": 946,
                  "type": "HDMI"
                 },
                 {
                  "id": 950,
                  "type": "HDMI"
                 },
                 {
                  "id": 33,
                  "type": "VIDEO_SELECTION"
                 }
                ]
            },
             "active": false,
             "room_id": 9,
             "username": "primaryuser"
            },
            {
             "type": "listen",
             "sources": {
                "source": [
                {
                 "id": 298,
                 "type": "DIGITAL_AUDIO_SERVER",
                 "name": "My Music"
                },
                {
                 "id": 302,
                 "type": "AUDIO_SELECTION",
                 "name": "Stations"
                },
                {
                 "id": 306,
                 "type": "DIGITAL_AUDIO_SERVER",
                 "name": "ShairBridge"
                },
                {
                 "id": 937,
                 "type": "DIGITAL_AUDIO_SERVER",
                 "name": "Spotify Connect"
                },
                {
                 "id": 100002,
                 "type": "DIGITAL_AUDIO_CLIENT",
                 "name": "Digital Media"
                }
               ]
            },
             "active": false,
             "room_id": 9,
             "username": "primaryuser"
            },
            {
             "type": "cameras",
             "sources": {
                "source": [
                {
                 "id": 877,
                 "type": "Camera"
                },
                ...
            }
            ...
        }
    """
    data = await self.send_get_request("/api/v1/agents/ui_configuration")
    result: dict[str, Any] = json.loads(data)
    return result

Returns a dictionary of the Control4 App UI Configuration enumerating rooms and capabilities

Returns

{ "experiences": [ { "type": "watch", "sources": { "source": [ { "id": 59, "type": "HDMI" }, { "id": 946, "type": "HDMI" }, { "id": 950, "type": "HDMI" }, { "id": 33, "type": "VIDEO_SELECTION" } ] }, "active": false, "room_id": 9, "username": "primaryuser" }, { "type": "listen", "sources": { "source": [ { "id": 298, "type": "DIGITAL_AUDIO_SERVER", "name": "My Music" }, { "id": 302, "type": "AUDIO_SELECTION", "name": "Stations" }, { "id": 306, "type": "DIGITAL_AUDIO_SERVER", "name": "ShairBridge" }, { "id": 937, "type": "DIGITAL_AUDIO_SERVER", "name": "Spotify Connect" }, { "id": 100002, "type": "DIGITAL_AUDIO_CLIENT", "name": "Digital Media" } ] }, "active": false, "room_id": 9, "username": "primaryuser" }, { "type": "cameras", "sources": { "source": [ { "id": 877, "type": "Camera" }, … } … }

async def send_get_request(self, uri: str) ‑> str
Expand source code
async def send_get_request(self, uri: str) -> str:
    """Sends a GET request to the specified API URI.
    Returns the Director's JSON response as a string.

    Parameters:
        `uri` - The API URI to send the request to. Do not include the IP
                address of the Director.
    """
    async with self._get_session() as session:
        async with asyncio.timeout(10):
            async with session.get(
                self.base_url + uri, headers=self.headers
            ) as resp:
                text = await resp.text()
                check_response_for_error(text)
                return text

Sends a GET request to the specified API URI. Returns the Director's JSON response as a string.

Parameters

uri - The API URI to send the request to. Do not include the IP address of the Director.

async def send_post_request(self, uri: str, command: str, params: dict[str, Any], is_async: bool = True) ‑> str
Expand source code
async def send_post_request(
    self, uri: str, command: str, params: dict[str, Any], is_async: bool = True
) -> str:
    """Sends a POST request to the specified API URI. Used to send commands
       to the Director.
    Returns the Director's JSON response as a string.

    Parameters:
        `uri` - The API URI to send the request to. Do not include the IP
                address of the Director.

        `command` - The Control4 command to send.

        `params` - The parameters of the command, provided as a dictionary.
    """
    data_dict = {
        "async": is_async,
        "command": command,
        "tParams": params,
    }
    async with self._get_session() as session:
        async with asyncio.timeout(10):
            async with session.post(
                self.base_url + uri, headers=self.headers, json=data_dict
            ) as resp:
                text = await resp.text()
                check_response_for_error(text)
                return text

Sends a POST request to the specified API URI. Used to send commands to the Director. Returns the Director's JSON response as a string.

Parameters

uri - The API URI to send the request to. Do not include the IP address of the Director.

command - The Control4 command to send.

params - The parameters of the command, provided as a dictionary.

lawtancool-pyControl4-5334f51/docs/error_handling.html000066400000000000000000000357421514675053300230430ustar00rootroot00000000000000 pyControl4.error_handling API documentation

Module pyControl4.error_handling

Handles errors received from the Control4 API.

Functions

def check_response_for_error(response_text: str) ‑> None
Expand source code
def check_response_for_error(response_text: str) -> None:
    """Checks a string response from the Control4 API for error codes.

    Parameters:
        `response_text` - JSON or XML response from Control4, as a string.
    """
    response_format = _check_response_format(response_text)

    if response_format == "JSON":
        dictionary = json.loads(response_text)
    else:  # XML
        dictionary = xmltodict.parse(response_text)

    error_info = _extract_error_info(dictionary)
    if error_info:
        _raise_error(error_info, response_text)

Checks a string response from the Control4 API for error codes.

Parameters

response_text - JSON or XML response from Control4, as a string.

Classes

class BadCredentials (message: str)
Expand source code
class BadCredentials(Unauthorized):
    """Raised when provided credentials are incorrect."""

Raised when provided credentials are incorrect.

Ancestors

class BadToken (message: str)
Expand source code
class BadToken(Unauthorized):
    """Raised when director bearer token is invalid."""

Raised when director bearer token is invalid.

Ancestors

class C4Exception (message: str)
Expand source code
class C4Exception(Exception):
    """Base error for pyControl4."""

    def __init__(self, message: str) -> None:
        self.message = message

Base error for pyControl4.

Ancestors

  • builtins.Exception
  • builtins.BaseException

Subclasses

class InvalidCategory (message: str)
Expand source code
class InvalidCategory(C4Exception):
    """Raised when an invalid category is provided when calling
    `pyControl4.director.C4Director.get_all_items_by_category`."""

Raised when an invalid category is provided when calling C4Director.get_all_items_by_category().

Ancestors

  • C4Exception
  • builtins.Exception
  • builtins.BaseException
class NotFound (message: str)
Expand source code
class NotFound(C4Exception):
    """Raised when a 404 response is received from the Control4 API.
    Occurs when the requested controller, etc. could not be found."""

Raised when a 404 response is received from the Control4 API. Occurs when the requested controller, etc. could not be found.

Ancestors

  • C4Exception
  • builtins.Exception
  • builtins.BaseException
class Unauthorized (message: str)
Expand source code
class Unauthorized(C4Exception):
    """Raised when unauthorized, but no other recognized details are provided.
    Occurs when token is invalid."""

Raised when unauthorized, but no other recognized details are provided. Occurs when token is invalid.

Ancestors

  • C4Exception
  • builtins.Exception
  • builtins.BaseException

Subclasses

lawtancool-pyControl4-5334f51/docs/fan.html000066400000000000000000000355311514675053300206060ustar00rootroot00000000000000 pyControl4.fan API documentation

Module pyControl4.fan

Controls Control4 Fan devices.

Classes

class C4Fan (director: C4Director, item_id: int)
Expand source code
class C4Fan(C4Entity):
    # ------------------------
    # Fan State Getters
    # ------------------------

    async def get_state(self) -> bool | None:
        """
        Returns the current power state of the fan.

        Returns:
            bool: True if the fan is on, False otherwise.
        """
        value = await self.director.get_item_variable_value(self.item_id, "IS_ON")
        if value is None:
            return None
        return bool(value)

    async def get_speed(self) -> int | None:
        """
        Returns the current speed of the fan controller.

        Valid speed values:
            0 - Off
            1 - Low
            2 - Medium
            3 - Medium High
            4 - High

        Note:
            Only valid for fan controllers. On non-dimmer switches,
            use `get_state()` instead.

        Returns:
            int: Current fan speed (0–4).
        """
        value = await self.director.get_item_variable_value(
            self.item_id, "CURRENT_SPEED"
        )
        if value is None:
            return None
        return int(value)

    # ------------------------
    # Fan Control Setters
    # ------------------------

    async def set_speed(self, speed: int) -> None:
        """
        Sets the fan speed or turns it off.

        Parameters:
            speed (int): Fan speed level:
                0 - Off
                1 - Low
                2 - Medium
                3 - Medium High
                4 - High
        """
        await self.director.send_post_request(
            f"/api/v1/items/{self.item_id}/commands",
            "SET_SPEED",
            {"SPEED": speed},
        )

    async def set_preset(self, preset: int) -> None:
        """
        Sets the fan's preset speed — the speed used when the fan is
        turned on without specifying speed.

        Parameters:
            preset (int): Preset fan speed level:
                0 - Off
                1 - Low
                2 - Medium
                3 - Medium High
                4 - High
        """
        await self.director.send_post_request(
            f"/api/v1/items/{self.item_id}/commands",
            "DESIGNATE_PRESET",
            {"PRESET": preset},
        )

Creates a Control4 object.

Parameters

director - A C4Director object that corresponds to the Control4 Director that the device is connected to.

item_id - The Control4 item ID.

Ancestors

Methods

async def get_speed(self) ‑> int | None
Expand source code
async def get_speed(self) -> int | None:
    """
    Returns the current speed of the fan controller.

    Valid speed values:
        0 - Off
        1 - Low
        2 - Medium
        3 - Medium High
        4 - High

    Note:
        Only valid for fan controllers. On non-dimmer switches,
        use `get_state()` instead.

    Returns:
        int: Current fan speed (0–4).
    """
    value = await self.director.get_item_variable_value(
        self.item_id, "CURRENT_SPEED"
    )
    if value is None:
        return None
    return int(value)

Returns the current speed of the fan controller.

Valid speed values: 0 - Off 1 - Low 2 - Medium 3 - Medium High 4 - High

Note

Only valid for fan controllers. On non-dimmer switches, use get_state() instead.

Returns

int
Current fan speed (0–4).
async def get_state(self) ‑> bool | None
Expand source code
async def get_state(self) -> bool | None:
    """
    Returns the current power state of the fan.

    Returns:
        bool: True if the fan is on, False otherwise.
    """
    value = await self.director.get_item_variable_value(self.item_id, "IS_ON")
    if value is None:
        return None
    return bool(value)

Returns the current power state of the fan.

Returns

bool
True if the fan is on, False otherwise.
async def set_preset(self, preset: int) ‑> None
Expand source code
async def set_preset(self, preset: int) -> None:
    """
    Sets the fan's preset speed — the speed used when the fan is
    turned on without specifying speed.

    Parameters:
        preset (int): Preset fan speed level:
            0 - Off
            1 - Low
            2 - Medium
            3 - Medium High
            4 - High
    """
    await self.director.send_post_request(
        f"/api/v1/items/{self.item_id}/commands",
        "DESIGNATE_PRESET",
        {"PRESET": preset},
    )

Sets the fan's preset speed — the speed used when the fan is turned on without specifying speed.

Parameters

preset (int): Preset fan speed level: 0 - Off 1 - Low 2 - Medium 3 - Medium High 4 - High

async def set_speed(self, speed: int) ‑> None
Expand source code
async def set_speed(self, speed: int) -> None:
    """
    Sets the fan speed or turns it off.

    Parameters:
        speed (int): Fan speed level:
            0 - Off
            1 - Low
            2 - Medium
            3 - Medium High
            4 - High
    """
    await self.director.send_post_request(
        f"/api/v1/items/{self.item_id}/commands",
        "SET_SPEED",
        {"SPEED": speed},
    )

Sets the fan speed or turns it off.

Parameters

speed (int): Fan speed level: 0 - Off 1 - Low 2 - Medium 3 - Medium High 4 - High

lawtancool-pyControl4-5334f51/docs/index.html000066400000000000000000000303351514675053300211460ustar00rootroot00000000000000 pyControl4 API documentation

Package pyControl4

Sub-modules

pyControl4.account

Authenticates with the Control4 API, retrieves account and registered controller info, and retrieves a bearer token for connecting to a Control4 …

pyControl4.alarm

Controls Control4 security panel and contact sensor (door, window, motion) devices.

pyControl4.blind

Controls Control4 blind devices.

pyControl4.climate

Controls Control4 Climate Control devices.

pyControl4.director

Handles communication with a Control4 Director, and provides functions for getting details about items on the Director.

pyControl4.error_handling

Handles errors received from the Control4 API.

pyControl4.fan

Controls Control4 Fan devices.

pyControl4.light

Controls Control4 Light devices.

pyControl4.relay

Controls Control4 Relay devices. These can include locks, and potentially other types of devices.

pyControl4.room

Controls Control4 Room devices.

pyControl4.websocket

Handles Websocket connections to a Control4 Director, allowing for real-time updates using callbacks.

Classes

class C4Entity (director: C4Director, item_id: int)
Expand source code
class C4Entity:
    def __init__(self, director: C4Director, item_id: int):
        """Creates a Control4 object.

        Parameters:
            `director` - A `pyControl4.director.C4Director` object that corresponds
                to the Control4 Director that the device is connected to.

            `item_id` - The Control4 item ID.
        """
        self.director = director
        self.item_id = int(item_id)

Creates a Control4 object.

Parameters

pyControl4.director - A C4Director object that corresponds to the Control4 Director that the device is connected to.

item_id - The Control4 item ID.

Subclasses

lawtancool-pyControl4-5334f51/docs/light.html000066400000000000000000000423301514675053300211440ustar00rootroot00000000000000 pyControl4.light API documentation

Module pyControl4.light

Controls Control4 Light devices.

Classes

class C4Light (director: C4Director, item_id: int)
Expand source code
class C4Light(C4Entity):
    async def get_level(self) -> int | None:
        """Returns the level of a dimming-capable light as an int 0-100.
        Will cause an error if called on a non-dimmer switch. Use `get_state()` instead.
        """
        value = await self.director.get_item_variable_value(self.item_id, "LIGHT_LEVEL")
        if value is None:
            return None
        return int(value)

    async def get_state(self) -> bool | None:
        """Returns the power state of a dimmer or switch as a boolean (True=on,
        False=off).
        """
        value = await self.director.get_item_variable_value(self.item_id, "LIGHT_STATE")
        if value is None:
            return None
        return bool(value)

    async def set_level(self, level: int) -> None:
        """Sets the light level of a dimmer or turns on/off a switch.
        Any `level > 0` will turn on a switch, and `level = 0` will turn off a switch.

        Parameters:
            `level` - (int) 0-100
        """
        await self.director.send_post_request(
            f"/api/v1/items/{self.item_id}/commands",
            "SET_LEVEL",
            {"LEVEL": level},
        )

    async def ramp_to_level(self, level: int, time: int) -> None:
        """Ramps the light level of a dimmer over time.
        Any `level > 0` will turn on a switch, and `level = 0` will turn off a switch.

        Parameters:
            `level` - (int) 0-100

            `time` - (int) Duration in milliseconds
        """
        await self.director.send_post_request(
            f"/api/v1/items/{self.item_id}/commands",
            "RAMP_TO_LEVEL",
            {"LEVEL": level, "TIME": time},
        )

    async def set_color_xy(
        self, x: float, y: float, *, rate: int | None = None
    ) -> None:
        """Sends SET_COLOR_TARGET with xy"""
        params = {
            "LIGHT_COLOR_TARGET_X": float(x),
            "LIGHT_COLOR_TARGET_Y": float(y),
            "LIGHT_COLOR_TARGET_MODE": 0,
        }
        if rate is not None:
            params["RATE"] = int(rate)

        await self.director.send_post_request(
            f"/api/v1/items/{self.item_id}/commands",
            "SET_COLOR_TARGET",
            params,
        )

    async def set_color_temperature(
        self, kelvin: int, *, rate: int | None = None
    ) -> None:
        params = {
            "LIGHT_COLOR_TARGET_COLOR_CORRELATED_TEMPERATURE": int(kelvin),
            "LIGHT_COLOR_TARGET_MODE": 1,
        }
        if rate is not None:
            params["RATE"] = int(rate)

        await self.director.send_post_request(
            f"/api/v1/items/{self.item_id}/commands",
            "SET_COLOR_TARGET",
            params,
        )

Creates a Control4 object.

Parameters

director - A C4Director object that corresponds to the Control4 Director that the device is connected to.

item_id - The Control4 item ID.

Ancestors

Methods

async def get_level(self) ‑> int | None
Expand source code
async def get_level(self) -> int | None:
    """Returns the level of a dimming-capable light as an int 0-100.
    Will cause an error if called on a non-dimmer switch. Use `get_state()` instead.
    """
    value = await self.director.get_item_variable_value(self.item_id, "LIGHT_LEVEL")
    if value is None:
        return None
    return int(value)

Returns the level of a dimming-capable light as an int 0-100. Will cause an error if called on a non-dimmer switch. Use get_state() instead.

async def get_state(self) ‑> bool | None
Expand source code
async def get_state(self) -> bool | None:
    """Returns the power state of a dimmer or switch as a boolean (True=on,
    False=off).
    """
    value = await self.director.get_item_variable_value(self.item_id, "LIGHT_STATE")
    if value is None:
        return None
    return bool(value)

Returns the power state of a dimmer or switch as a boolean (True=on, False=off).

async def ramp_to_level(self, level: int, time: int) ‑> None
Expand source code
async def ramp_to_level(self, level: int, time: int) -> None:
    """Ramps the light level of a dimmer over time.
    Any `level > 0` will turn on a switch, and `level = 0` will turn off a switch.

    Parameters:
        `level` - (int) 0-100

        `time` - (int) Duration in milliseconds
    """
    await self.director.send_post_request(
        f"/api/v1/items/{self.item_id}/commands",
        "RAMP_TO_LEVEL",
        {"LEVEL": level, "TIME": time},
    )

Ramps the light level of a dimmer over time. Any level > 0 will turn on a switch, and level = 0 will turn off a switch.

Parameters

level - (int) 0-100

time - (int) Duration in milliseconds

async def set_color_temperature(self, kelvin: int, *, rate: int | None = None) ‑> None
Expand source code
async def set_color_temperature(
    self, kelvin: int, *, rate: int | None = None
) -> None:
    params = {
        "LIGHT_COLOR_TARGET_COLOR_CORRELATED_TEMPERATURE": int(kelvin),
        "LIGHT_COLOR_TARGET_MODE": 1,
    }
    if rate is not None:
        params["RATE"] = int(rate)

    await self.director.send_post_request(
        f"/api/v1/items/{self.item_id}/commands",
        "SET_COLOR_TARGET",
        params,
    )
async def set_color_xy(self, x: float, y: float, *, rate: int | None = None) ‑> None
Expand source code
async def set_color_xy(
    self, x: float, y: float, *, rate: int | None = None
) -> None:
    """Sends SET_COLOR_TARGET with xy"""
    params = {
        "LIGHT_COLOR_TARGET_X": float(x),
        "LIGHT_COLOR_TARGET_Y": float(y),
        "LIGHT_COLOR_TARGET_MODE": 0,
    }
    if rate is not None:
        params["RATE"] = int(rate)

    await self.director.send_post_request(
        f"/api/v1/items/{self.item_id}/commands",
        "SET_COLOR_TARGET",
        params,
    )

Sends SET_COLOR_TARGET with xy

async def set_level(self, level: int) ‑> None
Expand source code
async def set_level(self, level: int) -> None:
    """Sets the light level of a dimmer or turns on/off a switch.
    Any `level > 0` will turn on a switch, and `level = 0` will turn off a switch.

    Parameters:
        `level` - (int) 0-100
    """
    await self.director.send_post_request(
        f"/api/v1/items/{self.item_id}/commands",
        "SET_LEVEL",
        {"LEVEL": level},
    )

Sets the light level of a dimmer or turns on/off a switch. Any level > 0 will turn on a switch, and level = 0 will turn off a switch.

Parameters

level - (int) 0-100

lawtancool-pyControl4-5334f51/docs/relay.html000066400000000000000000000403761514675053300211610ustar00rootroot00000000000000 pyControl4.relay API documentation

Module pyControl4.relay

Controls Control4 Relay devices. These can include locks, and potentially other types of devices.

Classes

class C4Relay (director: C4Director, item_id: int)
Expand source code
class C4Relay(C4Entity):
    async def get_relay_state(self) -> int | None:
        """Returns the current state of the relay.

        For locks, `0` means locked and `1` means unlocked.
        For relays in general, `0` probably means open and `1` probably means closed.
        """

        value = await self.director.get_item_variable_value(self.item_id, "RelayState")
        if value is None:
            return None
        return int(value)

    async def get_relay_state_verified(self) -> bool | None:
        """Returns True if Relay is functional.

        Notes:
            I think this is just used to verify that the relay is functional,
            not 100% sure though.
        """
        value = await self.director.get_item_variable_value(
            self.item_id, "StateVerified"
        )
        if value is None:
            return None
        return bool(value)

    async def open(self) -> None:
        """Set the relay to its open state.

        Example description JSON for this command from the director:
        ```
        {
          "display": "Lock the Front › Door Lock",
          "command": "OPEN",
          "deviceId": 307
        }
        ```
        """

        await self.director.send_post_request(
            f"/api/v1/items/{self.item_id}/commands",
            "OPEN",
            {},
        )

    async def close(self) -> None:
        """Set the relay to its closed state.

        Example description JSON for this command from the director:
        ```
        {
          "display": "Unlock the Front › Door Lock",
          "command": "CLOSE",
          "deviceId": 307
        }
        ```
        """

        await self.director.send_post_request(
            f"/api/v1/items/{self.item_id}/commands",
            "CLOSE",
            {},
        )

    async def toggle(self) -> None:
        """Toggles the relay state.

        Example description JSON for this command from the director:
        ```
        {
          "display": "Toggle the Front › Door Lock",
          "command": "TOGGLE",
          "deviceId": 307
        }
        ```
        """

        await self.director.send_post_request(
            f"/api/v1/items/{self.item_id}/commands",
            "TOGGLE",
            {},
        )

Creates a Control4 object.

Parameters

director - A C4Director object that corresponds to the Control4 Director that the device is connected to.

item_id - The Control4 item ID.

Ancestors

Methods

async def close(self) ‑> None
Expand source code
async def close(self) -> None:
    """Set the relay to its closed state.

    Example description JSON for this command from the director:
    ```
    {
      "display": "Unlock the Front › Door Lock",
      "command": "CLOSE",
      "deviceId": 307
    }
    ```
    """

    await self.director.send_post_request(
        f"/api/v1/items/{self.item_id}/commands",
        "CLOSE",
        {},
    )

Set the relay to its closed state.

Example description JSON for this command from the director:

{
  "display": "Unlock the Front › Door Lock",
  "command": "CLOSE",
  "deviceId": 307
}
async def get_relay_state(self) ‑> int | None
Expand source code
async def get_relay_state(self) -> int | None:
    """Returns the current state of the relay.

    For locks, `0` means locked and `1` means unlocked.
    For relays in general, `0` probably means open and `1` probably means closed.
    """

    value = await self.director.get_item_variable_value(self.item_id, "RelayState")
    if value is None:
        return None
    return int(value)

Returns the current state of the relay.

For locks, 0 means locked and 1 means unlocked. For relays in general, 0 probably means open and 1 probably means closed.

async def get_relay_state_verified(self) ‑> bool | None
Expand source code
async def get_relay_state_verified(self) -> bool | None:
    """Returns True if Relay is functional.

    Notes:
        I think this is just used to verify that the relay is functional,
        not 100% sure though.
    """
    value = await self.director.get_item_variable_value(
        self.item_id, "StateVerified"
    )
    if value is None:
        return None
    return bool(value)

Returns True if Relay is functional.

Notes

I think this is just used to verify that the relay is functional, not 100% sure though.

async def open(self) ‑> None
Expand source code
async def open(self) -> None:
    """Set the relay to its open state.

    Example description JSON for this command from the director:
    ```
    {
      "display": "Lock the Front › Door Lock",
      "command": "OPEN",
      "deviceId": 307
    }
    ```
    """

    await self.director.send_post_request(
        f"/api/v1/items/{self.item_id}/commands",
        "OPEN",
        {},
    )

Set the relay to its open state.

Example description JSON for this command from the director:

{
  "display": "Lock the Front › Door Lock",
  "command": "OPEN",
  "deviceId": 307
}
async def toggle(self) ‑> None
Expand source code
async def toggle(self) -> None:
    """Toggles the relay state.

    Example description JSON for this command from the director:
    ```
    {
      "display": "Toggle the Front › Door Lock",
      "command": "TOGGLE",
      "deviceId": 307
    }
    ```
    """

    await self.director.send_post_request(
        f"/api/v1/items/{self.item_id}/commands",
        "TOGGLE",
        {},
    )

Toggles the relay state.

Example description JSON for this command from the director:

{
  "display": "Toggle the Front › Door Lock",
  "command": "TOGGLE",
  "deviceId": 307
}
lawtancool-pyControl4-5334f51/docs/room.html000066400000000000000000000726251514675053300210230ustar00rootroot00000000000000 pyControl4.room API documentation

Module pyControl4.room

Controls Control4 Room devices.

Classes

class C4Room (director: C4Director, item_id: int)
Expand source code
class C4Room(C4Entity):
    """
    A media-oriented view of a Control4 Room, supporting items of type="room"
    """

    async def is_room_hidden(self) -> bool | None:
        """Returns True if the room is hidden from the end-user"""
        value = await self.director.get_item_variable_value(self.item_id, "ROOM_HIDDEN")
        if value is None:
            return None
        return int(value) != 0

    async def is_on(self) -> bool | None:
        """Returns True/False if the room is "ON" from the director's perspective"""
        value = await self.director.get_item_variable_value(self.item_id, "POWER_STATE")
        if value is None:
            return None
        return int(value) != 0

    async def set_room_off(self) -> None:
        """Turn the room "OFF" """
        await self.director.send_post_request(
            f"/api/v1/items/{self.item_id}/commands",
            "ROOM_OFF",
            {},
        )

    async def _set_source(self, source_id: int, audio_only: bool) -> None:
        """
        Sets the room source, turning on the room if necessary.
        If audio_only, only the current audio device is changed
        """
        await self.director.send_post_request(
            f"/api/v1/items/{self.item_id}/commands",
            "SELECT_AUDIO_DEVICE" if audio_only else "SELECT_VIDEO_DEVICE",
            {"deviceid": source_id},
        )

    async def set_audio_source(self, source_id: int) -> None:
        """Sets the current audio source for the room"""
        await self._set_source(source_id, audio_only=True)

    async def set_video_and_audio_source(self, source_id: int) -> None:
        """Sets the current audio and video source for the room"""
        await self._set_source(source_id, audio_only=False)

    async def get_volume(self) -> int | None:
        """Returns the current volume for the room from 0-100"""
        value = await self.director.get_item_variable_value(
            self.item_id, "CURRENT_VOLUME"
        )
        if value is None:
            return None
        return int(value)

    async def is_muted(self) -> bool | None:
        """Returns True if the room is muted"""
        value = await self.director.get_item_variable_value(self.item_id, "IS_MUTED")
        if value is None:
            return None
        return int(value) != 0

    async def set_mute_on(self) -> None:
        """Mute the room"""
        await self.director.send_post_request(
            f"/api/v1/items/{self.item_id}/commands",
            "MUTE_ON",
            {},
        )

    async def set_mute_off(self) -> None:
        """Unmute the room"""
        await self.director.send_post_request(
            f"/api/v1/items/{self.item_id}/commands",
            "MUTE_OFF",
            {},
        )

    async def toggle_mute(self) -> None:
        """Toggle the current mute state for the room"""
        await self.director.send_post_request(
            f"/api/v1/items/{self.item_id}/commands",
            "MUTE_TOGGLE",
            {},
        )

    async def set_volume(self, volume: int) -> None:
        """Set the room volume, 0-100"""
        await self.director.send_post_request(
            f"/api/v1/items/{self.item_id}/commands",
            "SET_VOLUME_LEVEL",
            {"LEVEL": volume},
        )

    async def set_increment_volume(self) -> None:
        """Increase volume by 1"""
        await self.director.send_post_request(
            f"/api/v1/items/{self.item_id}/commands",
            "PULSE_VOL_UP",
            {},
        )

    async def set_decrement_volume(self) -> None:
        """Decrease volume by 1"""
        await self.director.send_post_request(
            f"/api/v1/items/{self.item_id}/commands",
            "PULSE_VOL_DOWN",
            {},
        )

    async def set_play(self) -> None:
        await self.director.send_post_request(
            f"/api/v1/items/{self.item_id}/commands",
            "PLAY",
            {},
        )

    async def set_pause(self) -> None:
        await self.director.send_post_request(
            f"/api/v1/items/{self.item_id}/commands",
            "PAUSE",
            {},
        )

    async def set_stop(self) -> None:
        """Stops the currently playing media but does not turn off the room"""
        await self.director.send_post_request(
            f"/api/v1/items/{self.item_id}/commands",
            "STOP",
            {},
        )

    async def get_audio_devices(self) -> dict[str, Any]:
        """
        Note: As tested in OS 3.2.3 this doesn't work, but may work in previous versions

        Get the audio devices located in the room.
        Note that this is literally the devices in the room,
        not necessarily all devices _playable_ in the room.
        See `pyControl4.director.C4Director.get_ui_configuration`
        for a more accurate list
        """
        data = await self.director.send_get_request(
            f"/api/v1/locations/rooms/{self.item_id}/audio_devices"
        )
        result: dict[str, Any] = json.loads(data)
        return result

    async def get_video_devices(self) -> dict[str, Any]:
        """
        Note: As tested in OS 3.2.3 this doesn't work, but may work in previous versions

        Get the video devices located in the room.
        Note that this is literally the devices in the room,
        not necessarily all devices _playable_ in the room.
        See `pyControl4.director.C4Director.get_ui_configuration`
        for a more accurate list
        """
        data = await self.director.send_get_request(
            f"/api/v1/locations/rooms/{self.item_id}/video_devices"
        )
        result: dict[str, Any] = json.loads(data)
        return result

A media-oriented view of a Control4 Room, supporting items of type="room"

Creates a Control4 object.

Parameters

director - A C4Director object that corresponds to the Control4 Director that the device is connected to.

item_id - The Control4 item ID.

Ancestors

Methods

async def get_audio_devices(self) ‑> dict[str, typing.Any]
Expand source code
async def get_audio_devices(self) -> dict[str, Any]:
    """
    Note: As tested in OS 3.2.3 this doesn't work, but may work in previous versions

    Get the audio devices located in the room.
    Note that this is literally the devices in the room,
    not necessarily all devices _playable_ in the room.
    See `pyControl4.director.C4Director.get_ui_configuration`
    for a more accurate list
    """
    data = await self.director.send_get_request(
        f"/api/v1/locations/rooms/{self.item_id}/audio_devices"
    )
    result: dict[str, Any] = json.loads(data)
    return result

Note: As tested in OS 3.2.3 this doesn't work, but may work in previous versions

Get the audio devices located in the room. Note that this is literally the devices in the room, not necessarily all devices playable in the room. See C4Director.get_ui_configuration() for a more accurate list

async def get_video_devices(self) ‑> dict[str, typing.Any]
Expand source code
async def get_video_devices(self) -> dict[str, Any]:
    """
    Note: As tested in OS 3.2.3 this doesn't work, but may work in previous versions

    Get the video devices located in the room.
    Note that this is literally the devices in the room,
    not necessarily all devices _playable_ in the room.
    See `pyControl4.director.C4Director.get_ui_configuration`
    for a more accurate list
    """
    data = await self.director.send_get_request(
        f"/api/v1/locations/rooms/{self.item_id}/video_devices"
    )
    result: dict[str, Any] = json.loads(data)
    return result

Note: As tested in OS 3.2.3 this doesn't work, but may work in previous versions

Get the video devices located in the room. Note that this is literally the devices in the room, not necessarily all devices playable in the room. See C4Director.get_ui_configuration() for a more accurate list

async def get_volume(self) ‑> int | None
Expand source code
async def get_volume(self) -> int | None:
    """Returns the current volume for the room from 0-100"""
    value = await self.director.get_item_variable_value(
        self.item_id, "CURRENT_VOLUME"
    )
    if value is None:
        return None
    return int(value)

Returns the current volume for the room from 0-100

async def is_muted(self) ‑> bool | None
Expand source code
async def is_muted(self) -> bool | None:
    """Returns True if the room is muted"""
    value = await self.director.get_item_variable_value(self.item_id, "IS_MUTED")
    if value is None:
        return None
    return int(value) != 0

Returns True if the room is muted

async def is_on(self) ‑> bool | None
Expand source code
async def is_on(self) -> bool | None:
    """Returns True/False if the room is "ON" from the director's perspective"""
    value = await self.director.get_item_variable_value(self.item_id, "POWER_STATE")
    if value is None:
        return None
    return int(value) != 0

Returns True/False if the room is "ON" from the director's perspective

async def is_room_hidden(self) ‑> bool | None
Expand source code
async def is_room_hidden(self) -> bool | None:
    """Returns True if the room is hidden from the end-user"""
    value = await self.director.get_item_variable_value(self.item_id, "ROOM_HIDDEN")
    if value is None:
        return None
    return int(value) != 0

Returns True if the room is hidden from the end-user

async def set_audio_source(self, source_id: int) ‑> None
Expand source code
async def set_audio_source(self, source_id: int) -> None:
    """Sets the current audio source for the room"""
    await self._set_source(source_id, audio_only=True)

Sets the current audio source for the room

async def set_decrement_volume(self) ‑> None
Expand source code
async def set_decrement_volume(self) -> None:
    """Decrease volume by 1"""
    await self.director.send_post_request(
        f"/api/v1/items/{self.item_id}/commands",
        "PULSE_VOL_DOWN",
        {},
    )

Decrease volume by 1

async def set_increment_volume(self) ‑> None
Expand source code
async def set_increment_volume(self) -> None:
    """Increase volume by 1"""
    await self.director.send_post_request(
        f"/api/v1/items/{self.item_id}/commands",
        "PULSE_VOL_UP",
        {},
    )

Increase volume by 1

async def set_mute_off(self) ‑> None
Expand source code
async def set_mute_off(self) -> None:
    """Unmute the room"""
    await self.director.send_post_request(
        f"/api/v1/items/{self.item_id}/commands",
        "MUTE_OFF",
        {},
    )

Unmute the room

async def set_mute_on(self) ‑> None
Expand source code
async def set_mute_on(self) -> None:
    """Mute the room"""
    await self.director.send_post_request(
        f"/api/v1/items/{self.item_id}/commands",
        "MUTE_ON",
        {},
    )

Mute the room

async def set_pause(self) ‑> None
Expand source code
async def set_pause(self) -> None:
    await self.director.send_post_request(
        f"/api/v1/items/{self.item_id}/commands",
        "PAUSE",
        {},
    )
async def set_play(self) ‑> None
Expand source code
async def set_play(self) -> None:
    await self.director.send_post_request(
        f"/api/v1/items/{self.item_id}/commands",
        "PLAY",
        {},
    )
async def set_room_off(self) ‑> None
Expand source code
async def set_room_off(self) -> None:
    """Turn the room "OFF" """
    await self.director.send_post_request(
        f"/api/v1/items/{self.item_id}/commands",
        "ROOM_OFF",
        {},
    )

Turn the room "OFF"

async def set_stop(self) ‑> None
Expand source code
async def set_stop(self) -> None:
    """Stops the currently playing media but does not turn off the room"""
    await self.director.send_post_request(
        f"/api/v1/items/{self.item_id}/commands",
        "STOP",
        {},
    )

Stops the currently playing media but does not turn off the room

async def set_video_and_audio_source(self, source_id: int) ‑> None
Expand source code
async def set_video_and_audio_source(self, source_id: int) -> None:
    """Sets the current audio and video source for the room"""
    await self._set_source(source_id, audio_only=False)

Sets the current audio and video source for the room

async def set_volume(self, volume: int) ‑> None
Expand source code
async def set_volume(self, volume: int) -> None:
    """Set the room volume, 0-100"""
    await self.director.send_post_request(
        f"/api/v1/items/{self.item_id}/commands",
        "SET_VOLUME_LEVEL",
        {"LEVEL": volume},
    )

Set the room volume, 0-100

async def toggle_mute(self) ‑> None
Expand source code
async def toggle_mute(self) -> None:
    """Toggle the current mute state for the room"""
    await self.director.send_post_request(
        f"/api/v1/items/{self.item_id}/commands",
        "MUTE_TOGGLE",
        {},
    )

Toggle the current mute state for the room

lawtancool-pyControl4-5334f51/docs/websocket.html000066400000000000000000000601351514675053300220260ustar00rootroot00000000000000 pyControl4.websocket API documentation

Module pyControl4.websocket

Handles Websocket connections to a Control4 Director, allowing for real-time updates using callbacks.

Classes

class C4Websocket (ip: str,
session_no_verify_ssl: aiohttp.ClientSession | None = None,
connect_callback: Callable[..., Any] | None = None,
disconnect_callback: Callable[..., Any] | None = None)
Expand source code
class C4Websocket:
    def __init__(
        self,
        ip: str,
        session_no_verify_ssl: aiohttp.ClientSession | None = None,
        connect_callback: Callable[..., Any] | None = None,
        disconnect_callback: Callable[..., Any] | None = None,
    ):
        """Creates a Control4 Websocket object.

        Parameters:
            `ip` - The IP address of the Control4 Director/Controller.

            `session_no_verify_ssl` - (Optional) Allows the use of an
                        `aiohttp.ClientSession` object
                        for all network requests. This
                        session will not be closed by the library.
                        If not provided, the library will open and
                        close its own `ClientSession`s as needed.
                        This session is also passed to the underlying
                        socketio/engineio client to avoid blocking
                        `ssl.create_default_context()` calls inside
                        the event loop.

            `connect_callback` - (Optional) A callback to be called when the
                Websocket connection is opened or reconnected after a network
                error.

            `disconnect_callback` - (Optional) A callback to be called when
                the Websocket connection is lost due to a network error.
        """
        self.base_url: str = f"https://{ip}"
        self.wss_url: str = f"wss://{ip}"
        self.session: aiohttp.ClientSession | None = session_no_verify_ssl
        self.connect_callback: Callable[..., Any] | None = connect_callback
        self.disconnect_callback: Callable[..., Any] | None = disconnect_callback

        self._item_callbacks: dict[int, list[Callable[..., Any]]] = dict()
        self._sio: socketio.AsyncClient | None = None

    @property
    def item_callbacks(self) -> MappingProxyType[int, list[Callable[..., Any]]]:
        """Returns a read-only view of registered item ids (key) and their
        callbacks (value). Use add_item_callback() or remove_item_callback()
        to modify callbacks.
        """
        return MappingProxyType(self._item_callbacks)

    def add_item_callback(self, item_id: int, callback: Callable[..., Any]) -> None:
        """Register a callback to receive updates about an item.
        Parameters:
            `item_id` - The Control4 item ID.
            `callback` - The callback to be called when an update is received
                for the provided item id.
        """
        _LOGGER.debug("Subscribing to updates for item id: %s", item_id)

        if item_id not in self._item_callbacks:
            self._item_callbacks[item_id] = []

        # Avoid duplicates
        if callback not in self._item_callbacks[item_id]:
            self._item_callbacks[item_id].append(callback)

    def remove_item_callback(
        self, item_id: int, callback: Callable[..., Any] | None = None
    ) -> None:
        """Unregister callback(s) for an item.
        Parameters:
            `item_id` - The Control4 item ID.
            `callback` - (Optional) Specific callback to remove. If None,
                removes all callbacks for this item_id.
        """
        if item_id not in self._item_callbacks:
            return

        if callback is None:
            # Remove all callbacks for this item_id
            del self._item_callbacks[item_id]
        else:
            # Remove a specific callback
            try:
                self._item_callbacks[item_id].remove(callback)
                # If no more callbacks, remove the entry
                if not self._item_callbacks[item_id]:
                    del self._item_callbacks[item_id]
            except ValueError:
                pass

    async def sio_connect(self, director_bearer_token: str) -> None:
        """Start WebSockets connection and listen, using the provided
        director_bearer_token to authenticate with the Control4 Director. If a
        connection already exists, it will be disconnected and a new connection
        will be created.

        This function should be called using a new token every 86400 seconds (the
        expiry time of the director tokens), otherwise the Control4 Director will
        stop sending WebSocket messages.

        Parameters:
            `director_bearer_token` - The bearer token used to authenticate
                with the Director. See
                `pyControl4.account.C4Account.get_director_bearer_token`
                for how to get this.
        """
        # Disconnect previous sio object
        await self.sio_disconnect()

        if self.session is not None:
            # Create a new session using the caller's connector so engineio
            # can safely close it in _reset() without affecting the caller's
            # session.  Setting ssl_verify=True prevents engineio from
            # creating its own no-verify SSLContext, allowing the
            # connector's SSL configuration to take effect.
            http_session = aiohttp.ClientSession(
                connector=self.session.connector, connector_owner=False
            )
            self._sio = socketio.AsyncClient(ssl_verify=True, http_session=http_session)
        else:
            self._sio = socketio.AsyncClient(ssl_verify=False)
        self._sio.register_namespace(
            _C4DirectorNamespace(
                token=director_bearer_token,
                url=self.base_url,
                callback=self._callback,
                session=self.session,
                connect_callback=self.connect_callback,
                disconnect_callback=self.disconnect_callback,
            )
        )
        await self._sio.connect(
            self.wss_url,
            transports=["websocket"],
            headers={"JWT": director_bearer_token},
        )

    async def sio_disconnect(self) -> None:
        """Disconnects the WebSockets connection, if it has been created."""
        if isinstance(self._sio, socketio.AsyncClient):
            await self._sio.disconnect()

    async def _callback(self, message: Any) -> None:
        if "status" in message:
            _LOGGER.debug(f'Subscription {message["status"]}')
            return
        if isinstance(message, list):
            for m in message:
                await self._process_message(m)
        else:
            await self._process_message(message)

    async def _process_message(self, message: Any) -> None:
        """Process an incoming event message."""
        _LOGGER.debug(message)
        device_id = message.get("iddevice") if isinstance(message, dict) else None
        if device_id is None:
            _LOGGER.debug("Received message without iddevice field")
            return

        callbacks = self._item_callbacks.get(device_id, [])
        if not callbacks:
            _LOGGER.debug(f"No Callback for device id {device_id}")
            return

        for callback in callbacks[:]:
            try:
                if isinstance(message, list):
                    for m in message:
                        await callback(device_id, m)
                else:
                    await callback(device_id, message)
            except Exception as exc:
                _LOGGER.warning(f"Captured exception during callback: {str(exc)}")

Creates a Control4 Websocket object.

Parameters

ip - The IP address of the Control4 Director/Controller.

session_no_verify_ssl - (Optional) Allows the use of an aiohttp.ClientSession object for all network requests. This session will not be closed by the library. If not provided, the library will open and close its own ClientSessions as needed. This session is also passed to the underlying socketio/engineio client to avoid blocking ssl.create_default_context() calls inside the event loop.

connect_callback - (Optional) A callback to be called when the Websocket connection is opened or reconnected after a network error.

disconnect_callback - (Optional) A callback to be called when the Websocket connection is lost due to a network error.

Instance variables

prop item_callbacks : MappingProxyType[int, list[Callable[..., Any]]]
Expand source code
@property
def item_callbacks(self) -> MappingProxyType[int, list[Callable[..., Any]]]:
    """Returns a read-only view of registered item ids (key) and their
    callbacks (value). Use add_item_callback() or remove_item_callback()
    to modify callbacks.
    """
    return MappingProxyType(self._item_callbacks)

Returns a read-only view of registered item ids (key) and their callbacks (value). Use add_item_callback() or remove_item_callback() to modify callbacks.

Methods

def add_item_callback(self, item_id: int, callback: Callable[..., Any]) ‑> None
Expand source code
def add_item_callback(self, item_id: int, callback: Callable[..., Any]) -> None:
    """Register a callback to receive updates about an item.
    Parameters:
        `item_id` - The Control4 item ID.
        `callback` - The callback to be called when an update is received
            for the provided item id.
    """
    _LOGGER.debug("Subscribing to updates for item id: %s", item_id)

    if item_id not in self._item_callbacks:
        self._item_callbacks[item_id] = []

    # Avoid duplicates
    if callback not in self._item_callbacks[item_id]:
        self._item_callbacks[item_id].append(callback)

Register a callback to receive updates about an item.

Parameters

item_id - The Control4 item ID. callback - The callback to be called when an update is received for the provided item id.

def remove_item_callback(self, item_id: int, callback: Callable[..., Any] | None = None) ‑> None
Expand source code
def remove_item_callback(
    self, item_id: int, callback: Callable[..., Any] | None = None
) -> None:
    """Unregister callback(s) for an item.
    Parameters:
        `item_id` - The Control4 item ID.
        `callback` - (Optional) Specific callback to remove. If None,
            removes all callbacks for this item_id.
    """
    if item_id not in self._item_callbacks:
        return

    if callback is None:
        # Remove all callbacks for this item_id
        del self._item_callbacks[item_id]
    else:
        # Remove a specific callback
        try:
            self._item_callbacks[item_id].remove(callback)
            # If no more callbacks, remove the entry
            if not self._item_callbacks[item_id]:
                del self._item_callbacks[item_id]
        except ValueError:
            pass

Unregister callback(s) for an item.

Parameters

item_id - The Control4 item ID. callback - (Optional) Specific callback to remove. If None, removes all callbacks for this item_id.

async def sio_connect(self, director_bearer_token: str) ‑> None
Expand source code
async def sio_connect(self, director_bearer_token: str) -> None:
    """Start WebSockets connection and listen, using the provided
    director_bearer_token to authenticate with the Control4 Director. If a
    connection already exists, it will be disconnected and a new connection
    will be created.

    This function should be called using a new token every 86400 seconds (the
    expiry time of the director tokens), otherwise the Control4 Director will
    stop sending WebSocket messages.

    Parameters:
        `director_bearer_token` - The bearer token used to authenticate
            with the Director. See
            `pyControl4.account.C4Account.get_director_bearer_token`
            for how to get this.
    """
    # Disconnect previous sio object
    await self.sio_disconnect()

    if self.session is not None:
        # Create a new session using the caller's connector so engineio
        # can safely close it in _reset() without affecting the caller's
        # session.  Setting ssl_verify=True prevents engineio from
        # creating its own no-verify SSLContext, allowing the
        # connector's SSL configuration to take effect.
        http_session = aiohttp.ClientSession(
            connector=self.session.connector, connector_owner=False
        )
        self._sio = socketio.AsyncClient(ssl_verify=True, http_session=http_session)
    else:
        self._sio = socketio.AsyncClient(ssl_verify=False)
    self._sio.register_namespace(
        _C4DirectorNamespace(
            token=director_bearer_token,
            url=self.base_url,
            callback=self._callback,
            session=self.session,
            connect_callback=self.connect_callback,
            disconnect_callback=self.disconnect_callback,
        )
    )
    await self._sio.connect(
        self.wss_url,
        transports=["websocket"],
        headers={"JWT": director_bearer_token},
    )

Start WebSockets connection and listen, using the provided director_bearer_token to authenticate with the Control4 Director. If a connection already exists, it will be disconnected and a new connection will be created.

This function should be called using a new token every 86400 seconds (the expiry time of the director tokens), otherwise the Control4 Director will stop sending WebSocket messages.

Parameters

director_bearer_token - The bearer token used to authenticate with the Director. See C4Account.get_director_bearer_token() for how to get this.

async def sio_disconnect(self) ‑> None
Expand source code
async def sio_disconnect(self) -> None:
    """Disconnects the WebSockets connection, if it has been created."""
    if isinstance(self._sio, socketio.AsyncClient):
        await self._sio.disconnect()

Disconnects the WebSockets connection, if it has been created.

lawtancool-pyControl4-5334f51/pyControl4/000077500000000000000000000000001514675053300202725ustar00rootroot00000000000000lawtancool-pyControl4-5334f51/pyControl4/__init__.py000066400000000000000000000007661514675053300224140ustar00rootroot00000000000000from __future__ import annotations from pyControl4.director import C4Director class C4Entity: def __init__(self, director: C4Director, item_id: int): """Creates a Control4 object. Parameters: `director` - A `pyControl4.director.C4Director` object that corresponds to the Control4 Director that the device is connected to. `item_id` - The Control4 item ID. """ self.director = director self.item_id = int(item_id) lawtancool-pyControl4-5334f51/pyControl4/account.py000066400000000000000000000244571514675053300223140ustar00rootroot00000000000000"""Authenticates with the Control4 API, retrieves account and registered controller info, and retrieves a bearer token for connecting to a Control4 Director. """ from __future__ import annotations from contextlib import asynccontextmanager from typing import Any, AsyncGenerator import aiohttp import asyncio import json import logging from .error_handling import check_response_for_error AUTHENTICATION_ENDPOINT = "https://apis.control4.com/authentication/v1/rest" CONTROLLER_AUTHORIZATION_ENDPOINT = ( "https://apis.control4.com/authentication/v1/rest/authorization" ) GET_CONTROLLERS_ENDPOINT = "https://apis.control4.com/account/v3/rest/accounts" APPLICATION_KEY = "78f6791373d61bea49fdb9fb8897f1f3af193f11" _LOGGER = logging.getLogger(__name__) class C4Account: def __init__( self, username: str, password: str, session: aiohttp.ClientSession | None = None, ): """Creates a Control4 account object. Parameters: `username` - Control4 account username/email. `password` - Control4 account password. `session` - (Optional) Allows the use of an `aiohttp.ClientSession` object for all network requests. This session will not be closed by the library. If not provided, the library will open and close its own `ClientSession`s as needed. """ self.username = username self.password = password self.session = session @asynccontextmanager async def _get_session(self) -> AsyncGenerator[aiohttp.ClientSession, None]: """Returns the configured session or creates a temporary one. If self.session is set, yields it without closing. Otherwise, creates and closes a temporary session. """ if self.session is not None: yield self.session else: async with aiohttp.ClientSession() as session: yield session async def _send_account_auth_request(self) -> str: """Used internally to retrieve an account bearer token. Returns the entire JSON response from the Control4 auth API. """ data_dict = { "clientInfo": { "device": { "deviceName": "pyControl4", "deviceUUID": "0000000000000000", "make": "pyControl4", "model": "pyControl4", "os": "Android", "osVersion": "10", }, "userInfo": { "applicationKey": APPLICATION_KEY, "password": self.password, "userName": self.username, }, } } async with self._get_session() as session: async with asyncio.timeout(10): async with session.post( AUTHENTICATION_ENDPOINT, json=data_dict ) as resp: text = await resp.text() check_response_for_error(text) return text async def _send_account_get_request(self, uri: str) -> str: """Used internally to send GET requests to the Control4 API, authenticated with the account bearer token. Returns the entire JSON response from the Control4 auth API. Parameters: `uri` - Full URI to send GET request to. """ try: headers = {"Authorization": f"Bearer {self.account_bearer_token}"} except AttributeError: msg = ( "The account bearer token is missing. " "Is your username/password correct?" ) _LOGGER.error(msg) raise async with self._get_session() as session: async with asyncio.timeout(10): async with session.get(uri, headers=headers) as resp: text = await resp.text() check_response_for_error(text) return text async def _send_controller_auth_request(self, controller_common_name: str) -> str: """Used internally to retrieve an director bearer token. Returns the entire JSON response from the Control4 auth API. Parameters: `controller_common_name`: Common name of the controller. See `get_account_controllers()` for details. """ try: headers = {"Authorization": f"Bearer {self.account_bearer_token}"} except AttributeError: msg = ( "The account bearer token is missing. " "Is your username/password correct?" ) _LOGGER.error(msg) raise data_dict = { "serviceInfo": { "commonName": controller_common_name, "services": "director", } } async with self._get_session() as session: async with asyncio.timeout(10): async with session.post( CONTROLLER_AUTHORIZATION_ENDPOINT, headers=headers, json=data_dict, ) as resp: text = await resp.text() check_response_for_error(text) return text async def get_account_bearer_token(self) -> str: """Gets an account bearer token for making Control4 online API requests.""" data = await self._send_account_auth_request() json_dict = json.loads(data) try: token: str = json_dict["authToken"]["token"] self.account_bearer_token = token return token except KeyError: msg = ( "Did not receive an account bearer token. " "Is your username/password correct?" ) _LOGGER.error(msg + data) raise async def get_account_controllers(self) -> dict[str, Any]: """Returns a dictionary of the information for all controllers registered to an account. Returns: ``` { "controllerCommonName": "control4_MODEL_MACADDRESS", "href": "https://apis.control4.com/account/v3/rest/accounts/000000", "name": "Name" } ``` """ data = await self._send_account_get_request(GET_CONTROLLERS_ENDPOINT) json_dict = json.loads(data) try: result: dict[str, Any] = json_dict["account"] return result except KeyError: msg = "Did not receive account information from the Control4 API." _LOGGER.error(msg + " Response: " + data) raise async def get_controller_info(self, controller_href: str) -> dict[str, Any]: """Returns a dictionary of the information of a specific controller. Parameters: `controller_href` - The API `href` of the controller (get this from the output of `get_account_controllers()`) Returns: ``` { 'allowsPatching': True, 'allowsSupport': False, 'blockNotifications': False, 'controllerCommonName': 'control4_MODEL_MACADDRESS', 'controller': { 'href': 'https://apis.control4.com/account/v3/rest/accounts/000000/controller' # noqa: E501 }, 'created': '2017-08-26T18:33:31Z', 'dealer': { 'href': 'https://apis.control4.com/account/v3/rest/dealers/12345' }, 'enabled': True, 'hasLoggedIn': True, 'href': 'https://apis.control4.com/account/v3/rest/accounts/000000', 'id': 000000, 'lastCheckIn': '2020-06-13T21:52:34Z', 'licenses': { 'href': 'https://apis.control4.com/account/v3/rest/accounts/000000/licenses' # noqa: E501 }, 'modified': '2020-06-13T21:52:34Z', 'name': 'Name', 'provisionDate': '2017-08-26T18:35:11Z', 'storage': { 'href': 'https://apis.control4.com/storage/v1/rest/accounts/000000' }, 'type': 'Consumer', 'users': { 'href': 'https://apis.control4.com/account/v3/rest/accounts/000000/users' # noqa: E501 } } ``` """ data = await self._send_account_get_request(controller_href) json_dict: dict[str, Any] = json.loads(data) return json_dict async def get_controller_os_version(self, controller_href: str) -> str: """Returns the OS version of a controller as a string. Parameters: `controller_href` - The API `href` of the controller (get this from the output of `get_account_controllers()`) """ data = await self._send_account_get_request(controller_href + "/controller") json_dict = json.loads(data) try: version: str = json_dict["osVersion"] return version except KeyError: msg = "Did not receive OS version from the Control4 API." _LOGGER.error(msg + " Response: " + data) raise async def get_director_bearer_token( self, controller_common_name: str ) -> dict[str, Any]: """Returns a dictionary with a director bearer token for making Control4 Director API requests, and its time valid in seconds (usually 86400 seconds) Parameters: `controller_common_name`: Common name of the controller. See `get_account_controllers()` for details. """ data = await self._send_controller_auth_request(controller_common_name) json_dict = json.loads(data) try: auth_token = json_dict.get("authToken", {}) token = auth_token.get("token") valid_seconds = auth_token.get("validSeconds") if token is None or valid_seconds is None: raise KeyError("Missing token or validSeconds in authToken") return {"token": token, "validSeconds": valid_seconds} except KeyError: msg = "Did not receive a director bearer token from the Control4 API." _LOGGER.error(msg + " Response: " + data) raise lawtancool-pyControl4-5334f51/pyControl4/alarm.py000066400000000000000000000210371514675053300217430ustar00rootroot00000000000000"""Controls Control4 security panel and contact sensor (door, window, motion) devices. """ from __future__ import annotations from pyControl4 import C4Entity from pyControl4.director import C4Director class C4SecurityPanel(C4Entity): async def get_arm_state(self) -> str | None: """ NOTE: Prefer using `get_partition_state()` and `get_armed_type()` over this method. Returns the arm state of the security panel as "DISARMED", "ARMED_HOME", or "ARMED_AWAY". """ disarmed = await self.director.get_item_variable_value( self.item_id, "DISARMED_STATE" ) armed_home = await self.director.get_item_variable_value( self.item_id, "HOME_STATE" ) armed_away = await self.director.get_item_variable_value( self.item_id, "AWAY_STATE" ) try: if disarmed is not None and int(disarmed) == 1: return "DISARMED" elif armed_home is not None and int(armed_home) == 1: return "ARMED_HOME" elif armed_away is not None and int(armed_away) == 1: return "ARMED_AWAY" except (ValueError, TypeError): pass return None async def get_alarm_state(self) -> bool | None: """Returns `True` if alarm is triggered, otherwise returns `False`.""" alarm_state = await self.director.get_item_variable_value( self.item_id, "ALARM_STATE" ) if alarm_state is None: return None return bool(alarm_state) async def get_display_text(self) -> str | None: """Returns the display text of the security panel.""" display_text = await self.director.get_item_variable_value( self.item_id, "DISPLAY_TEXT" ) return display_text async def get_trouble_text(self) -> str | None: """Returns the trouble display text of the security panel.""" trouble_text = await self.director.get_item_variable_value( self.item_id, "TROUBLE_TEXT" ) return trouble_text async def get_partition_state(self) -> str | None: """Returns the partition state of the security panel. Possible values include "DISARMED_NOT_READY", "DISARMED_READY", "ARMED_HOME", "ARMED_AWAY", "EXIT_DELAY", "ENTRY_DELAY" """ partition_state = await self.director.get_item_variable_value( self.item_id, "PARTITION_STATE" ) return partition_state async def get_delay_time_total(self) -> int | None: """Returns the total exit delay time. Returns 0 if an exit delay is not currently running. """ delay_time_total = await self.director.get_item_variable_value( self.item_id, "DELAY_TIME_TOTAL" ) return int(delay_time_total) if delay_time_total is not None else None async def get_delay_time_remaining(self) -> int | None: """Returns the remaining exit delay time. Returns 0 if an exit delay is not currently running. """ delay_time_remaining = await self.director.get_item_variable_value( self.item_id, "DELAY_TIME_REMAINING" ) return int(delay_time_remaining) if delay_time_remaining is not None else None async def get_open_zone_count(self) -> int | None: """Returns the number of open/unsecured zones.""" open_zone_count = await self.director.get_item_variable_value( self.item_id, "OPEN_ZONE_COUNT" ) return int(open_zone_count) if open_zone_count is not None else None async def get_alarm_type(self) -> str | None: """Returns details about the current alarm type.""" alarm_type = await self.director.get_item_variable_value( self.item_id, "ALARM_TYPE" ) return alarm_type async def get_armed_type(self) -> str | None: """Returns details about the current arm type.""" armed_type = await self.director.get_item_variable_value( self.item_id, "ARMED_TYPE" ) return armed_type async def get_last_emergency(self) -> str | None: """Returns details about the last emergency trigger.""" last_emergency = await self.director.get_item_variable_value( self.item_id, "LAST_EMERGENCY" ) return last_emergency async def get_last_arm_failure(self) -> str | None: """Returns details about the last arm failure.""" last_arm_failed = await self.director.get_item_variable_value( self.item_id, "LAST_ARM_FAILED" ) return last_arm_failed async def set_arm(self, usercode: str, mode: str) -> None: """Arms the security panel with the specified mode. Parameters: `usercode` - PIN/code for arming the system. `mode` - Arm mode to use. This depends on what is supported by the security panel itself. """ usercode = str(usercode) await self.director.send_post_request( f"/api/v1/items/{self.item_id}/commands", "PARTITION_ARM", {"ArmType": mode, "UserCode": usercode}, ) async def set_disarm(self, usercode: str) -> None: """Disarms the security panel. Parameters: `usercode` - PIN/code for disarming the system. """ usercode = str(usercode) await self.director.send_post_request( f"/api/v1/items/{self.item_id}/commands", "PARTITION_DISARM", {"UserCode": usercode}, ) async def get_emergency_types(self) -> list[str]: """Returns the available emergency types as a list. Possible types are "Fire", "Medical", "Panic", and "Police". """ types_list: list[str] = [] data = await self.director.get_item_info(self.item_id) if not data or not isinstance(data, list) or len(data) == 0: return types_list capabilities = data[0].get("capabilities", {}) if capabilities.get("has_fire"): types_list.append("Fire") if capabilities.get("has_medical"): types_list.append("Medical") if capabilities.get("has_panic"): types_list.append("Panic") if capabilities.get("has_police"): types_list.append("Police") return types_list async def get_arm_types(self) -> list[str]: """Returns the available arm types as a list.""" data = await self.director.get_item_info(self.item_id) if not data or not isinstance(data, list) or len(data) == 0: return [] capabilities = data[0].get("capabilities", {}) arm_types_str = capabilities.get("arm_types", "") if not arm_types_str: return [] return [t.strip() for t in arm_types_str.split(",") if t.strip()] async def trigger_emergency(self, emergency_type: str) -> None: """Triggers an emergency of the specified type. Parameters: `emergency_type` - Type of emergency: "Fire", "Medical", "Panic", or "Police" """ await self.director.send_post_request( f"/api/v1/items/{self.item_id}/commands", "EXECUTE_EMERGENCY", {"EmergencyType": emergency_type}, ) async def send_key_press(self, key: str) -> None: """Sends a single keypress to the security panel's virtual keypad (if supported). Parameters: `key` - Keypress to send. Only one key at a time. """ key = str(key) await self.director.send_post_request( f"/api/v1/items/{self.item_id}/commands", "KEY_PRESS", {"KeyName": key}, ) class C4ContactSensor: def __init__(self, director: C4Director, item_id: int) -> None: """Creates a Control4 Contact Sensor object. Parameters: `director` - A `pyControl4.director.C4Director` object that corresponds to the Control4 Director that the contact sensor is connected to. `item_id` - The Control4 item ID of the contact sensor. """ self.director = director self.item_id = item_id async def get_contact_state(self) -> bool | None: """Returns `True` if contact is triggered (door/window is closed, motion is detected), otherwise returns `False`. """ contact_state = await self.director.get_item_variable_value( self.item_id, "ContactState" ) if contact_state is None: return None return bool(contact_state) lawtancool-pyControl4-5334f51/pyControl4/blind.py000066400000000000000000000121751514675053300217420ustar00rootroot00000000000000"""Controls Control4 blind devices.""" from __future__ import annotations from pyControl4 import C4Entity class C4Blind(C4Entity): async def get_battery_level(self) -> int | None: """Returns the battery of a blind. We currently don't know the range or meaning. """ value = await self.director.get_item_variable_value( self.item_id, "Battery Level" ) if value is None: return None return int(value) async def get_closing(self) -> bool | None: """Returns an indication of whether the blind is moving in the closed direction as a boolean (True=closing, False=opening). If the blind is stopped, reports the direction it last moved. """ value = await self.director.get_item_variable_value(self.item_id, "Closing") if value is None: return None return bool(value) async def get_fully_closed(self) -> bool | None: """Returns an indication of whether the blind is fully closed as a boolean (True=fully closed, False=at least partially open).""" value = await self.director.get_item_variable_value( self.item_id, "Fully Closed" ) if value is None: return None return bool(value) async def get_fully_open(self) -> bool | None: """Returns an indication of whether the blind is fully open as a boolean (True=fully open, False=at least partially closed).""" value = await self.director.get_item_variable_value(self.item_id, "Fully Open") if value is None: return None return bool(value) async def get_level(self) -> int | None: """Returns the level (current position) of a blind as an int 0-100. 0 is fully closed and 100 is fully open. """ value = await self.director.get_item_variable_value(self.item_id, "Level") if value is None: return None return int(value) async def get_open(self) -> bool | None: """Returns an indication of whether the blind is open as a boolean (True=open, False=closed). This is true even if the blind is only partially open. """ value = await self.director.get_item_variable_value(self.item_id, "Open") if value is None: return None return bool(value) async def get_opening(self) -> bool | None: """Returns an indication of whether the blind is moving in the open direction as a boolean (True=opening, False=closing). If the blind is stopped, reports the direction it last moved. """ value = await self.director.get_item_variable_value(self.item_id, "Opening") if value is None: return None return bool(value) async def get_stopped(self) -> bool | None: """Returns an indication of whether the blind is stopped as a boolean (True=stopped, False=moving).""" value = await self.director.get_item_variable_value(self.item_id, "Stopped") if value is None: return None return bool(value) async def get_target_level(self) -> int | None: """Returns the target level (desired position) of a blind as an int 0-100. The blind will move if this is different from the current level. 0 is fully closed and 100 is fully open. """ value = await self.director.get_item_variable_value( self.item_id, "Target Level" ) if value is None: return None return int(value) async def open(self) -> None: """Opens the blind completely.""" await self.director.send_post_request( f"/api/v1/items/{self.item_id}/commands", "SET_LEVEL_TARGET:LEVEL_TARGET_OPEN", {}, ) async def close(self) -> None: """Closes the blind completely.""" await self.director.send_post_request( f"/api/v1/items/{self.item_id}/commands", "SET_LEVEL_TARGET:LEVEL_TARGET_CLOSED", {}, ) async def set_level_target(self, level: int) -> None: """Sets the desired level of a blind; it will start moving towards that level. Level 0 is fully closed and level 100 is fully open. Parameters: `level` - (int) 0-100 """ await self.director.send_post_request( f"/api/v1/items/{self.item_id}/commands", "SET_LEVEL_TARGET", {"LEVEL_TARGET": level}, ) async def stop(self) -> None: """Stops the blind if it is moving. Shortly after stopping, the target level will be set to the level the blind had actually reached when it stopped. """ await self.director.send_post_request( f"/api/v1/items/{self.item_id}/commands", "STOP", {}, ) async def toggle(self) -> None: """Toggles the blind between open and closed. Has no effect if the blind is partially open. """ await self.director.send_post_request( f"/api/v1/items/{self.item_id}/commands", "TOGGLE", {}, ) lawtancool-pyControl4-5334f51/pyControl4/climate.py000066400000000000000000000157161514675053300222740ustar00rootroot00000000000000"""Controls Control4 Climate Control devices.""" from __future__ import annotations from pyControl4 import C4Entity class C4Climate(C4Entity): # ------------------------ # HVAC and Fan States # ------------------------ async def get_hvac_state(self) -> str | None: """Returns the current HVAC state (e.g., on/off or active mode).""" return await self.director.get_item_variable_value(self.item_id, "HVAC_STATE") async def get_fan_state(self) -> str | None: """Returns the current power state of the fan (on, off).""" return await self.director.get_item_variable_value(self.item_id, "FAN_STATE") # ------------------------ # Mode Getters # ------------------------ async def get_hvac_mode(self) -> str | None: """Returns the currently active HVAC mode.""" return await self.director.get_item_variable_value(self.item_id, "HVAC_MODE") async def get_hvac_modes(self) -> list[str] | None: """Returns a list of supported HVAC modes.""" value = await self.director.get_item_variable_value( self.item_id, "HVAC_MODES_LIST" ) if value is None: return None return [m.strip() for m in value.split(",") if m.strip()] async def get_fan_mode(self) -> str | None: """Returns the currently active fan mode.""" return await self.director.get_item_variable_value(self.item_id, "FAN_MODE") async def get_fan_modes(self) -> list[str] | None: """Returns a list of supported fan modes.""" value = await self.director.get_item_variable_value( self.item_id, "FAN_MODES_LIST" ) if value is None: return None return [m.strip() for m in value.split(",") if m.strip()] async def get_hold_mode(self) -> str | None: """Returns the currently active hold mode.""" return await self.director.get_item_variable_value(self.item_id, "HOLD_MODE") async def get_hold_modes(self) -> list[str] | None: """Returns a list of supported hold modes.""" value = await self.director.get_item_variable_value( self.item_id, "HOLD_MODES_LIST" ) if value is None: return None return [m.strip() for m in value.split(",") if m.strip()] # ------------------------ # Setpoint Getters # ------------------------ async def get_cool_setpoint_f(self) -> float | None: """Returns the cooling setpoint temperature in Fahrenheit.""" value = await self.director.get_item_variable_value( self.item_id, "COOL_SETPOINT_F" ) if value is None: return None return float(value) async def get_cool_setpoint_c(self) -> float | None: """Returns the cooling setpoint temperature in Celsius.""" value = await self.director.get_item_variable_value( self.item_id, "COOL_SETPOINT_C" ) if value is None: return None return float(value) async def get_heat_setpoint_f(self) -> float | None: """Returns the heating setpoint temperature in Fahrenheit.""" value = await self.director.get_item_variable_value( self.item_id, "HEAT_SETPOINT_F" ) if value is None: return None return float(value) async def get_heat_setpoint_c(self) -> float | None: """Returns the heating setpoint temperature in Celsius.""" value = await self.director.get_item_variable_value( self.item_id, "HEAT_SETPOINT_C" ) if value is None: return None return float(value) # ------------------------ # Sensor Readings # ------------------------ async def get_humidity(self) -> float | None: """Returns the current humidity percentage.""" value = await self.director.get_item_variable_value(self.item_id, "HUMIDITY") if value is None: return None return float(value) async def get_current_temperature_f(self) -> float | None: """Returns the current ambient temperature in Fahrenheit.""" value = await self.director.get_item_variable_value( self.item_id, "TEMPERATURE_F" ) if value is None: return None return float(value) async def get_current_temperature_c(self) -> float | None: """Returns the current ambient temperature in Celsius.""" value = await self.director.get_item_variable_value( self.item_id, "TEMPERATURE_C" ) if value is None: return None return float(value) # ------------------------ # Setters / Commands # ------------------------ async def set_cool_setpoint_f(self, temp: float) -> None: """Sets the cooling setpoint temperature in Fahrenheit.""" await self.director.send_post_request( f"/api/v1/items/{self.item_id}/commands", "SET_SETPOINT_COOL", {"FAHRENHEIT": temp}, ) async def set_cool_setpoint_c(self, temp: float) -> None: """Sets the cooling setpoint temperature in Celsius.""" await self.director.send_post_request( f"/api/v1/items/{self.item_id}/commands", "SET_SETPOINT_COOL", {"CELSIUS": temp}, ) async def set_heat_setpoint_f(self, temp: float) -> None: """Sets the heating setpoint temperature in Fahrenheit.""" await self.director.send_post_request( f"/api/v1/items/{self.item_id}/commands", "SET_SETPOINT_HEAT", {"FAHRENHEIT": temp}, ) async def set_heat_setpoint_c(self, temp: float) -> None: """Sets the heating setpoint temperature in Celsius.""" await self.director.send_post_request( f"/api/v1/items/{self.item_id}/commands", "SET_SETPOINT_HEAT", {"CELSIUS": temp}, ) async def set_hvac_mode(self, mode: str) -> None: """Sets the HVAC operating mode (e.g., heat, cool, auto).""" await self.director.send_post_request( f"/api/v1/items/{self.item_id}/commands", "SET_MODE_HVAC", {"MODE": mode}, ) async def set_fan_mode(self, mode: str) -> None: """Sets the fan operating mode (e.g., auto, on, circulate).""" await self.director.send_post_request( f"/api/v1/items/{self.item_id}/commands", "SET_MODE_FAN", {"MODE": mode}, ) async def set_preset(self, preset: str) -> None: """Applies a predefined climate preset by name.""" await self.director.send_post_request( f"/api/v1/items/{self.item_id}/commands", "SET_PRESET", {"NAME": preset}, ) async def set_hold_mode(self, mode: str) -> None: """Sets the hold mode.""" await self.director.send_post_request( f"/api/v1/items/{self.item_id}/commands", "SET_MODE_HOLD", {"MODE": mode}, ) lawtancool-pyControl4-5334f51/pyControl4/director.py000066400000000000000000000272761514675053300224750ustar00rootroot00000000000000"""Handles communication with a Control4 Director, and provides functions for getting details about items on the Director. """ from __future__ import annotations from contextlib import asynccontextmanager from typing import Any, AsyncGenerator import aiohttp import asyncio import json from .error_handling import check_response_for_error class C4Director: def __init__( self, ip: str, director_bearer_token: str, session_no_verify_ssl: aiohttp.ClientSession | None = None, ): """Creates a Control4 Director object. Parameters: `ip` - The IP address of the Control4 Director/Controller. `director_bearer_token` - The bearer token used to authenticate with the Director. See `pyControl4.account.C4Account.get_director_bearer_token` for how to get this. `session` - (Optional) Allows the use of an `aiohttp.ClientSession` object for all network requests. This session will not be closed by the library. If not provided, the library will open and close its own `ClientSession`s as needed. """ self.base_url = f"https://{ip}" self.headers = {"Authorization": f"Bearer {director_bearer_token}"} self.director_bearer_token = director_bearer_token self.session = session_no_verify_ssl @asynccontextmanager async def _get_session(self) -> AsyncGenerator[aiohttp.ClientSession, None]: """Returns the configured session or creates a temporary one. If self.session is set, yields it without closing. Otherwise, creates and closes a temporary session. """ if self.session is not None: yield self.session else: async with aiohttp.ClientSession( connector=aiohttp.TCPConnector(verify_ssl=False) ) as session: yield session async def send_get_request(self, uri: str) -> str: """Sends a GET request to the specified API URI. Returns the Director's JSON response as a string. Parameters: `uri` - The API URI to send the request to. Do not include the IP address of the Director. """ async with self._get_session() as session: async with asyncio.timeout(10): async with session.get( self.base_url + uri, headers=self.headers ) as resp: text = await resp.text() check_response_for_error(text) return text async def send_post_request( self, uri: str, command: str, params: dict[str, Any], is_async: bool = True ) -> str: """Sends a POST request to the specified API URI. Used to send commands to the Director. Returns the Director's JSON response as a string. Parameters: `uri` - The API URI to send the request to. Do not include the IP address of the Director. `command` - The Control4 command to send. `params` - The parameters of the command, provided as a dictionary. """ data_dict = { "async": is_async, "command": command, "tParams": params, } async with self._get_session() as session: async with asyncio.timeout(10): async with session.post( self.base_url + uri, headers=self.headers, json=data_dict ) as resp: text = await resp.text() check_response_for_error(text) return text async def get_all_items_by_category(self, category: str) -> list[dict[str, Any]]: """Returns a list of items related to a particular category. Parameters: `category` - Control4 Category Name: controllers, comfort, lights, cameras, sensors, audio_video, motorization, thermostats, motors, control4_remote_hub, outlet_wireless_dimmer, voice-scene """ data = await self.send_get_request(f"/api/v1/categories/{category}") result: list[dict[str, Any]] = json.loads(data) return result async def get_all_item_info(self) -> list[dict[str, Any]]: """Returns a list of all the items on the Director.""" data = await self.send_get_request("/api/v1/items") result: list[dict[str, Any]] = json.loads(data) return result async def get_item_info(self, item_id: int) -> list[dict[str, Any]]: """Returns a list of the details of the specified item. Parameters: `item_id` - The Control4 item ID. """ data = await self.send_get_request(f"/api/v1/items/{item_id}") result: list[dict[str, Any]] = json.loads(data) return result async def get_item_setup(self, item_id: int) -> dict[str, Any]: """Returns the setup info of the specified item. Parameters: `item_id` - The Control4 item ID. """ data = await self.send_post_request( f"/api/v1/items/{item_id}/commands", "GET_SETUP", {}, False ) result: dict[str, Any] = json.loads(data) return result async def get_item_variables(self, item_id: int) -> list[dict[str, Any]]: """Returns a list of the variables available for the specified item. Parameters: `item_id` - The Control4 item ID. """ data = await self.send_get_request(f"/api/v1/items/{item_id}/variables") result: list[dict[str, Any]] = json.loads(data) return result async def get_item_variable_value( self, item_id: int, var_name: str | list[str] | tuple[str, ...] | set[str] ) -> Any | None: """Returns the value of the specified variable for the specified item. The returned value is the JSON ``"value"`` field from the Director response. If that field is the string ``"Undefined"``, this method returns ``None``. Parameters: `item_id` - The Control4 item ID. `var_name` - The Control4 variable name or names. """ if isinstance(var_name, (tuple, list, set)): var_name = ",".join(var_name) data = await self.send_get_request( f"/api/v1/items/{item_id}/variables?varnames={var_name}" ) if data == "[]": raise ValueError( f"Empty response received from Director! The variable {var_name} " f"doesn't seem to exist for item {item_id}." ) json_dict = json.loads(data) if not isinstance(json_dict, list) or not json_dict: raise ValueError( f"Invalid response format from Director for variable {var_name}: {data}" ) value = json_dict[0].get("value") if value == "Undefined": return None return value async def get_all_item_variable_value( self, var_name: str | list[str] | tuple[str, ...] | set[str] ) -> list[dict[str, Any]]: """Returns a list of dictionaries with the values of the specified variable for all items that have it. Parameters: `var_name` - The Control4 variable name or names. """ if isinstance(var_name, (tuple, list, set)): var_name = ",".join(var_name) data = await self.send_get_request( f"/api/v1/items/variables?varnames={var_name}" ) if data == "[]": raise ValueError( f"Empty response received from Director! The variable {var_name} " f"doesn't seem to exist for any items." ) json_dict: list[dict[str, Any]] = json.loads(data) for item in json_dict: if item.get("value") == "Undefined": item["value"] = None return json_dict async def get_item_commands(self, item_id: int) -> list[dict[str, Any]]: """Returns the commands available for the specified item. Parameters: `item_id` - The Control4 item ID. """ data = await self.send_get_request(f"/api/v1/items/{item_id}/commands") result: list[dict[str, Any]] = json.loads(data) return result async def get_item_network(self, item_id: int) -> list[dict[str, Any]]: """Returns the network information for the specified item. Parameters: `item_id` - The Control4 item ID. """ data = await self.send_get_request(f"/api/v1/items/{item_id}/network") result: list[dict[str, Any]] = json.loads(data) return result async def get_item_bindings(self, item_id: int) -> list[dict[str, Any]]: """Returns the bindings information for the specified item. Parameters: `item_id` - The Control4 item ID. """ data = await self.send_get_request(f"/api/v1/items/{item_id}/bindings") result: list[dict[str, Any]] = json.loads(data) return result async def get_ui_configuration(self) -> dict[str, Any]: """Returns a dictionary of the Control4 App UI Configuration enumerating rooms and capabilities Returns: { "experiences": [ { "type": "watch", "sources": { "source": [ { "id": 59, "type": "HDMI" }, { "id": 946, "type": "HDMI" }, { "id": 950, "type": "HDMI" }, { "id": 33, "type": "VIDEO_SELECTION" } ] }, "active": false, "room_id": 9, "username": "primaryuser" }, { "type": "listen", "sources": { "source": [ { "id": 298, "type": "DIGITAL_AUDIO_SERVER", "name": "My Music" }, { "id": 302, "type": "AUDIO_SELECTION", "name": "Stations" }, { "id": 306, "type": "DIGITAL_AUDIO_SERVER", "name": "ShairBridge" }, { "id": 937, "type": "DIGITAL_AUDIO_SERVER", "name": "Spotify Connect" }, { "id": 100002, "type": "DIGITAL_AUDIO_CLIENT", "name": "Digital Media" } ] }, "active": false, "room_id": 9, "username": "primaryuser" }, { "type": "cameras", "sources": { "source": [ { "id": 877, "type": "Camera" }, ... } ... } """ data = await self.send_get_request("/api/v1/agents/ui_configuration") result: dict[str, Any] = json.loads(data) return result lawtancool-pyControl4-5334f51/pyControl4/error_handling.py000066400000000000000000000110651514675053300236440ustar00rootroot00000000000000"""Handles errors received from the Control4 API.""" from __future__ import annotations from typing import Any import json import xmltodict class C4Exception(Exception): """Base error for pyControl4.""" def __init__(self, message: str) -> None: self.message = message class NotFound(C4Exception): """Raised when a 404 response is received from the Control4 API. Occurs when the requested controller, etc. could not be found.""" class Unauthorized(C4Exception): """Raised when unauthorized, but no other recognized details are provided. Occurs when token is invalid.""" class BadCredentials(Unauthorized): """Raised when provided credentials are incorrect.""" class BadToken(Unauthorized): """Raised when director bearer token is invalid.""" class InvalidCategory(C4Exception): """Raised when an invalid category is provided when calling `pyControl4.director.C4Director.get_all_items_by_category`.""" ERROR_CODES = {"401": Unauthorized, "404": NotFound} ERROR_DETAILS = { "Permission denied Bad credentials": BadCredentials, } DIRECTOR_ERRORS = {"Unauthorized": Unauthorized, "Invalid category": InvalidCategory} DIRECTOR_ERROR_DETAILS = {"Expired or invalid token": BadToken} def _check_response_format(response_text: str) -> str: """Known Control4 authentication API error message formats: ```json { "C4ErrorResponse": { "code": 401, "details": "Permission denied Bad credentials", "message": "Permission denied", "subCode": 0 } } ``` ```json { "code": 404, "details": "Account with id:000000 not found in DB", "message": "Account not found", "subCode": 0 }``` ```xml 401
Permission denied 0
``` Known Control4 director error message formats: ```json { "error": "Unauthorized", "details": "Expired or invalid token" } ``` """ if response_text.startswith("<"): return "XML" return "JSON" def _extract_error_info(dictionary: dict[str, Any]) -> dict[str, Any] | None: """Extract error information from a parsed Control4 response. Returns a dict with 'details', 'code', or 'error' key, or None if no error found. """ # Check for C4ErrorResponse format if "C4ErrorResponse" in dictionary: error_resp = dictionary.get("C4ErrorResponse", {}) return { "details": error_resp.get("details"), "code": error_resp.get("code"), "type": "C4ErrorResponse", } # Check for direct code format if "code" in dictionary: return { "details": dictionary.get("details"), "code": dictionary.get("code"), "type": "code", } # Check for error format (director) if "error" in dictionary: return { "details": dictionary.get("details"), "error": dictionary.get("error"), "type": "error", } return None def _raise_error(error_info: dict[str, Any], response_text: str) -> None: """Raise appropriate exception based on error info.""" details = error_info.get("details") code = error_info.get("code") error = error_info.get("error") # Try to match by details first (most specific) if details: if details in ERROR_DETAILS: raise ERROR_DETAILS[details](response_text) if details in DIRECTOR_ERROR_DETAILS: raise DIRECTOR_ERROR_DETAILS[details](response_text) # Try to match by code/error (less specific) if code is not None: raise ERROR_CODES.get(str(code), C4Exception)(response_text) if error is not None: raise DIRECTOR_ERRORS.get(str(error), C4Exception)(response_text) # If nothing matched, raise generic error raise C4Exception(response_text) def check_response_for_error(response_text: str) -> None: """Checks a string response from the Control4 API for error codes. Parameters: `response_text` - JSON or XML response from Control4, as a string. """ response_format = _check_response_format(response_text) if response_format == "JSON": dictionary = json.loads(response_text) else: # XML dictionary = xmltodict.parse(response_text) error_info = _extract_error_info(dictionary) if error_info: _raise_error(error_info, response_text) lawtancool-pyControl4-5334f51/pyControl4/fan.py000066400000000000000000000044661514675053300214220ustar00rootroot00000000000000"""Controls Control4 Fan devices.""" from __future__ import annotations from pyControl4 import C4Entity class C4Fan(C4Entity): # ------------------------ # Fan State Getters # ------------------------ async def get_state(self) -> bool | None: """ Returns the current power state of the fan. Returns: bool: True if the fan is on, False otherwise. """ value = await self.director.get_item_variable_value(self.item_id, "IS_ON") if value is None: return None return bool(value) async def get_speed(self) -> int | None: """ Returns the current speed of the fan controller. Valid speed values: 0 - Off 1 - Low 2 - Medium 3 - Medium High 4 - High Note: Only valid for fan controllers. On non-dimmer switches, use `get_state()` instead. Returns: int: Current fan speed (0–4). """ value = await self.director.get_item_variable_value( self.item_id, "CURRENT_SPEED" ) if value is None: return None return int(value) # ------------------------ # Fan Control Setters # ------------------------ async def set_speed(self, speed: int) -> None: """ Sets the fan speed or turns it off. Parameters: speed (int): Fan speed level: 0 - Off 1 - Low 2 - Medium 3 - Medium High 4 - High """ await self.director.send_post_request( f"/api/v1/items/{self.item_id}/commands", "SET_SPEED", {"SPEED": speed}, ) async def set_preset(self, preset: int) -> None: """ Sets the fan's preset speed — the speed used when the fan is turned on without specifying speed. Parameters: preset (int): Preset fan speed level: 0 - Off 1 - Low 2 - Medium 3 - Medium High 4 - High """ await self.director.send_post_request( f"/api/v1/items/{self.item_id}/commands", "DESIGNATE_PRESET", {"PRESET": preset}, ) lawtancool-pyControl4-5334f51/pyControl4/light.py000066400000000000000000000054171514675053300217620ustar00rootroot00000000000000"""Controls Control4 Light devices.""" from __future__ import annotations from pyControl4 import C4Entity class C4Light(C4Entity): async def get_level(self) -> int | None: """Returns the level of a dimming-capable light as an int 0-100. Will cause an error if called on a non-dimmer switch. Use `get_state()` instead. """ value = await self.director.get_item_variable_value(self.item_id, "LIGHT_LEVEL") if value is None: return None return int(value) async def get_state(self) -> bool | None: """Returns the power state of a dimmer or switch as a boolean (True=on, False=off). """ value = await self.director.get_item_variable_value(self.item_id, "LIGHT_STATE") if value is None: return None return bool(value) async def set_level(self, level: int) -> None: """Sets the light level of a dimmer or turns on/off a switch. Any `level > 0` will turn on a switch, and `level = 0` will turn off a switch. Parameters: `level` - (int) 0-100 """ await self.director.send_post_request( f"/api/v1/items/{self.item_id}/commands", "SET_LEVEL", {"LEVEL": level}, ) async def ramp_to_level(self, level: int, time: int) -> None: """Ramps the light level of a dimmer over time. Any `level > 0` will turn on a switch, and `level = 0` will turn off a switch. Parameters: `level` - (int) 0-100 `time` - (int) Duration in milliseconds """ await self.director.send_post_request( f"/api/v1/items/{self.item_id}/commands", "RAMP_TO_LEVEL", {"LEVEL": level, "TIME": time}, ) async def set_color_xy( self, x: float, y: float, *, rate: int | None = None ) -> None: """Sends SET_COLOR_TARGET with xy""" params = { "LIGHT_COLOR_TARGET_X": float(x), "LIGHT_COLOR_TARGET_Y": float(y), "LIGHT_COLOR_TARGET_MODE": 0, } if rate is not None: params["RATE"] = int(rate) await self.director.send_post_request( f"/api/v1/items/{self.item_id}/commands", "SET_COLOR_TARGET", params, ) async def set_color_temperature( self, kelvin: int, *, rate: int | None = None ) -> None: params = { "LIGHT_COLOR_TARGET_COLOR_CORRELATED_TEMPERATURE": int(kelvin), "LIGHT_COLOR_TARGET_MODE": 1, } if rate is not None: params["RATE"] = int(rate) await self.director.send_post_request( f"/api/v1/items/{self.item_id}/commands", "SET_COLOR_TARGET", params, ) lawtancool-pyControl4-5334f51/pyControl4/py.typed000066400000000000000000000000001514675053300217570ustar00rootroot00000000000000lawtancool-pyControl4-5334f51/pyControl4/relay.py000066400000000000000000000046311514675053300217640ustar00rootroot00000000000000"""Controls Control4 Relay devices. These can include locks, and potentially other types of devices. """ from __future__ import annotations from pyControl4 import C4Entity class C4Relay(C4Entity): async def get_relay_state(self) -> int | None: """Returns the current state of the relay. For locks, `0` means locked and `1` means unlocked. For relays in general, `0` probably means open and `1` probably means closed. """ value = await self.director.get_item_variable_value(self.item_id, "RelayState") if value is None: return None return int(value) async def get_relay_state_verified(self) -> bool | None: """Returns True if Relay is functional. Notes: I think this is just used to verify that the relay is functional, not 100% sure though. """ value = await self.director.get_item_variable_value( self.item_id, "StateVerified" ) if value is None: return None return bool(value) async def open(self) -> None: """Set the relay to its open state. Example description JSON for this command from the director: ``` { "display": "Lock the Front › Door Lock", "command": "OPEN", "deviceId": 307 } ``` """ await self.director.send_post_request( f"/api/v1/items/{self.item_id}/commands", "OPEN", {}, ) async def close(self) -> None: """Set the relay to its closed state. Example description JSON for this command from the director: ``` { "display": "Unlock the Front › Door Lock", "command": "CLOSE", "deviceId": 307 } ``` """ await self.director.send_post_request( f"/api/v1/items/{self.item_id}/commands", "CLOSE", {}, ) async def toggle(self) -> None: """Toggles the relay state. Example description JSON for this command from the director: ``` { "display": "Toggle the Front › Door Lock", "command": "TOGGLE", "deviceId": 307 } ``` """ await self.director.send_post_request( f"/api/v1/items/{self.item_id}/commands", "TOGGLE", {}, ) lawtancool-pyControl4-5334f51/pyControl4/room.py000066400000000000000000000133361514675053300216260ustar00rootroot00000000000000"""Controls Control4 Room devices.""" from __future__ import annotations import json from typing import Any from pyControl4 import C4Entity class C4Room(C4Entity): """ A media-oriented view of a Control4 Room, supporting items of type="room" """ async def is_room_hidden(self) -> bool | None: """Returns True if the room is hidden from the end-user""" value = await self.director.get_item_variable_value(self.item_id, "ROOM_HIDDEN") if value is None: return None return int(value) != 0 async def is_on(self) -> bool | None: """Returns True/False if the room is "ON" from the director's perspective""" value = await self.director.get_item_variable_value(self.item_id, "POWER_STATE") if value is None: return None return int(value) != 0 async def set_room_off(self) -> None: """Turn the room "OFF" """ await self.director.send_post_request( f"/api/v1/items/{self.item_id}/commands", "ROOM_OFF", {}, ) async def _set_source(self, source_id: int, audio_only: bool) -> None: """ Sets the room source, turning on the room if necessary. If audio_only, only the current audio device is changed """ await self.director.send_post_request( f"/api/v1/items/{self.item_id}/commands", "SELECT_AUDIO_DEVICE" if audio_only else "SELECT_VIDEO_DEVICE", {"deviceid": source_id}, ) async def set_audio_source(self, source_id: int) -> None: """Sets the current audio source for the room""" await self._set_source(source_id, audio_only=True) async def set_video_and_audio_source(self, source_id: int) -> None: """Sets the current audio and video source for the room""" await self._set_source(source_id, audio_only=False) async def get_volume(self) -> int | None: """Returns the current volume for the room from 0-100""" value = await self.director.get_item_variable_value( self.item_id, "CURRENT_VOLUME" ) if value is None: return None return int(value) async def is_muted(self) -> bool | None: """Returns True if the room is muted""" value = await self.director.get_item_variable_value(self.item_id, "IS_MUTED") if value is None: return None return int(value) != 0 async def set_mute_on(self) -> None: """Mute the room""" await self.director.send_post_request( f"/api/v1/items/{self.item_id}/commands", "MUTE_ON", {}, ) async def set_mute_off(self) -> None: """Unmute the room""" await self.director.send_post_request( f"/api/v1/items/{self.item_id}/commands", "MUTE_OFF", {}, ) async def toggle_mute(self) -> None: """Toggle the current mute state for the room""" await self.director.send_post_request( f"/api/v1/items/{self.item_id}/commands", "MUTE_TOGGLE", {}, ) async def set_volume(self, volume: int) -> None: """Set the room volume, 0-100""" await self.director.send_post_request( f"/api/v1/items/{self.item_id}/commands", "SET_VOLUME_LEVEL", {"LEVEL": volume}, ) async def set_increment_volume(self) -> None: """Increase volume by 1""" await self.director.send_post_request( f"/api/v1/items/{self.item_id}/commands", "PULSE_VOL_UP", {}, ) async def set_decrement_volume(self) -> None: """Decrease volume by 1""" await self.director.send_post_request( f"/api/v1/items/{self.item_id}/commands", "PULSE_VOL_DOWN", {}, ) async def set_play(self) -> None: await self.director.send_post_request( f"/api/v1/items/{self.item_id}/commands", "PLAY", {}, ) async def set_pause(self) -> None: await self.director.send_post_request( f"/api/v1/items/{self.item_id}/commands", "PAUSE", {}, ) async def set_stop(self) -> None: """Stops the currently playing media but does not turn off the room""" await self.director.send_post_request( f"/api/v1/items/{self.item_id}/commands", "STOP", {}, ) async def get_audio_devices(self) -> dict[str, Any]: """ Note: As tested in OS 3.2.3 this doesn't work, but may work in previous versions Get the audio devices located in the room. Note that this is literally the devices in the room, not necessarily all devices _playable_ in the room. See `pyControl4.director.C4Director.get_ui_configuration` for a more accurate list """ data = await self.director.send_get_request( f"/api/v1/locations/rooms/{self.item_id}/audio_devices" ) result: dict[str, Any] = json.loads(data) return result async def get_video_devices(self) -> dict[str, Any]: """ Note: As tested in OS 3.2.3 this doesn't work, but may work in previous versions Get the video devices located in the room. Note that this is literally the devices in the room, not necessarily all devices _playable_ in the room. See `pyControl4.director.C4Director.get_ui_configuration` for a more accurate list """ data = await self.director.send_get_request( f"/api/v1/locations/rooms/{self.item_id}/video_devices" ) result: dict[str, Any] = json.loads(data) return result lawtancool-pyControl4-5334f51/pyControl4/websocket.py000066400000000000000000000255751514675053300226500ustar00rootroot00000000000000"""Handles Websocket connections to a Control4 Director, allowing for real-time updates using callbacks. """ from __future__ import annotations from typing import Any, Callable from types import MappingProxyType import aiohttp import asyncio import socketio_v4 as socketio # type: ignore[import-untyped] import logging from .error_handling import check_response_for_error _LOGGER = logging.getLogger(__name__) _NAMESPACE_URI = "/api/v1/items/datatoui" _PROBE_MESSAGE = "2probe" _STATUS_ACK_MESSAGE = "2" class _C4DirectorNamespace(socketio.AsyncClientNamespace): # type: ignore[misc] def __init__(self, *args: Any, **kwargs: Any) -> None: self.url: str = kwargs.pop("url") self.token: str = kwargs.pop("token") self.callback: Callable[..., Any] = kwargs.pop("callback") self.session: aiohttp.ClientSession | None = kwargs.pop("session") self.connect_callback: Callable[..., Any] | None = kwargs.pop( "connect_callback" ) self.disconnect_callback: Callable[..., Any] | None = kwargs.pop( "disconnect_callback" ) super().__init__(*args, **kwargs) self.uri = _NAMESPACE_URI self.subscription_id: str | None = None self.connected: bool = False async def on_connect(self) -> None: _LOGGER.debug("Control4 Director socket.io connection established!") if self.connect_callback is not None: await self.connect_callback() async def on_disconnect(self) -> None: self.connected = False self.subscription_id = None _LOGGER.debug("Control4 Director socket.io disconnected.") if self.disconnect_callback is not None: await self.disconnect_callback() async def trigger_event(self, event: str, *args: Any) -> None: if event == "subscribe": await self.on_subscribe(*args) elif event == "connect": await self.on_connect() elif event == "disconnect": await self.on_disconnect() elif event == "clientId": await self.on_clientId(*args) elif event == self.subscription_id: msg = args[0] if "status" in msg: _LOGGER.debug(f'Status message received from Director: {msg["status"]}') await self.emit(_STATUS_ACK_MESSAGE) else: await self.callback(args[0]) async def on_clientId(self, client_id: str) -> None: await self.emit(_PROBE_MESSAGE) if not self.connected and not self.subscription_id: _LOGGER.debug("Fetching subscriptionID from Control4") session = self.session or aiohttp.ClientSession( connector=aiohttp.TCPConnector(verify_ssl=False) ) try: async with asyncio.timeout(10): async with session.get( self.url + self.uri, params={"JWT": self.token, "SubscriptionClient": client_id}, ) as resp: check_response_for_error(await resp.text()) data = await resp.json() subscription_id = data.get("subscriptionId") if subscription_id is None: raise ValueError( "Failed to get subscription ID from Control4 Director" ) self.connected = True self.subscription_id = subscription_id await self.emit("startSubscription", self.subscription_id) finally: if self.session is None: await session.close() async def on_subscribe(self, message: Any) -> None: pass class C4Websocket: def __init__( self, ip: str, session_no_verify_ssl: aiohttp.ClientSession | None = None, connect_callback: Callable[..., Any] | None = None, disconnect_callback: Callable[..., Any] | None = None, ): """Creates a Control4 Websocket object. Parameters: `ip` - The IP address of the Control4 Director/Controller. `session_no_verify_ssl` - (Optional) Allows the use of an `aiohttp.ClientSession` object for all network requests. This session will not be closed by the library. If not provided, the library will open and close its own `ClientSession`s as needed. This session is also passed to the underlying socketio/engineio client to avoid blocking `ssl.create_default_context()` calls inside the event loop. `connect_callback` - (Optional) A callback to be called when the Websocket connection is opened or reconnected after a network error. `disconnect_callback` - (Optional) A callback to be called when the Websocket connection is lost due to a network error. """ self.base_url: str = f"https://{ip}" self.wss_url: str = f"wss://{ip}" self.session: aiohttp.ClientSession | None = session_no_verify_ssl self.connect_callback: Callable[..., Any] | None = connect_callback self.disconnect_callback: Callable[..., Any] | None = disconnect_callback self._item_callbacks: dict[int, list[Callable[..., Any]]] = dict() self._sio: socketio.AsyncClient | None = None @property def item_callbacks(self) -> MappingProxyType[int, list[Callable[..., Any]]]: """Returns a read-only view of registered item ids (key) and their callbacks (value). Use add_item_callback() or remove_item_callback() to modify callbacks. """ return MappingProxyType(self._item_callbacks) def add_item_callback(self, item_id: int, callback: Callable[..., Any]) -> None: """Register a callback to receive updates about an item. Parameters: `item_id` - The Control4 item ID. `callback` - The callback to be called when an update is received for the provided item id. """ _LOGGER.debug("Subscribing to updates for item id: %s", item_id) if item_id not in self._item_callbacks: self._item_callbacks[item_id] = [] # Avoid duplicates if callback not in self._item_callbacks[item_id]: self._item_callbacks[item_id].append(callback) def remove_item_callback( self, item_id: int, callback: Callable[..., Any] | None = None ) -> None: """Unregister callback(s) for an item. Parameters: `item_id` - The Control4 item ID. `callback` - (Optional) Specific callback to remove. If None, removes all callbacks for this item_id. """ if item_id not in self._item_callbacks: return if callback is None: # Remove all callbacks for this item_id del self._item_callbacks[item_id] else: # Remove a specific callback try: self._item_callbacks[item_id].remove(callback) # If no more callbacks, remove the entry if not self._item_callbacks[item_id]: del self._item_callbacks[item_id] except ValueError: pass async def sio_connect(self, director_bearer_token: str) -> None: """Start WebSockets connection and listen, using the provided director_bearer_token to authenticate with the Control4 Director. If a connection already exists, it will be disconnected and a new connection will be created. This function should be called using a new token every 86400 seconds (the expiry time of the director tokens), otherwise the Control4 Director will stop sending WebSocket messages. Parameters: `director_bearer_token` - The bearer token used to authenticate with the Director. See `pyControl4.account.C4Account.get_director_bearer_token` for how to get this. """ # Disconnect previous sio object await self.sio_disconnect() if self.session is not None: # Create a new session using the caller's connector so engineio # can safely close it in _reset() without affecting the caller's # session. Setting ssl_verify=True prevents engineio from # creating its own no-verify SSLContext, allowing the # connector's SSL configuration to take effect. http_session = aiohttp.ClientSession( connector=self.session.connector, connector_owner=False ) self._sio = socketio.AsyncClient(ssl_verify=True, http_session=http_session) else: self._sio = socketio.AsyncClient(ssl_verify=False) self._sio.register_namespace( _C4DirectorNamespace( token=director_bearer_token, url=self.base_url, callback=self._callback, session=self.session, connect_callback=self.connect_callback, disconnect_callback=self.disconnect_callback, ) ) await self._sio.connect( self.wss_url, transports=["websocket"], headers={"JWT": director_bearer_token}, ) async def sio_disconnect(self) -> None: """Disconnects the WebSockets connection, if it has been created.""" if isinstance(self._sio, socketio.AsyncClient): await self._sio.disconnect() async def _callback(self, message: Any) -> None: if "status" in message: _LOGGER.debug(f'Subscription {message["status"]}') return if isinstance(message, list): for m in message: await self._process_message(m) else: await self._process_message(message) async def _process_message(self, message: Any) -> None: """Process an incoming event message.""" _LOGGER.debug(message) device_id = message.get("iddevice") if isinstance(message, dict) else None if device_id is None: _LOGGER.debug("Received message without iddevice field") return callbacks = self._item_callbacks.get(device_id, []) if not callbacks: _LOGGER.debug(f"No Callback for device id {device_id}") return for callback in callbacks[:]: try: if isinstance(message, list): for m in message: await callback(device_id, m) else: await callback(device_id, message) except Exception as exc: _LOGGER.warning(f"Captured exception during callback: {str(exc)}") lawtancool-pyControl4-5334f51/pytest.ini000066400000000000000000000001421514675053300202430ustar00rootroot00000000000000[pytest] markers = asyncio: marks tests as asyncio tests, requires the pytest-asyncio plugin. lawtancool-pyControl4-5334f51/requirements-dev.txt000066400000000000000000000001361514675053300222550ustar00rootroot00000000000000aiohttp xmltodict python-socketio-v4 websocket-client pdoc3 pytest-asyncio types-xmltodict lawtancool-pyControl4-5334f51/requirements.txt000066400000000000000000000000661514675053300215030ustar00rootroot00000000000000aiohttp xmltodict python-socketio-v4 websocket-client lawtancool-pyControl4-5334f51/setup.py000066400000000000000000000016101514675053300177250ustar00rootroot00000000000000import setuptools with open("README.md", "r") as fh: long_description = fh.read() setuptools.setup( name="pyControl4", # Replace with your own username version="2.0.2", author="lawtancool", author_email="contact@lawrencetan.ca", description="Python 3 asyncio package for interacting with Control4 systems", long_description=long_description, long_description_content_type="text/markdown", url="https://github.com/lawtancool/pyControl4", packages=setuptools.find_packages(), package_data={"pyControl4": ["py.typed"]}, classifiers=[ "Programming Language :: Python :: 3", "License :: OSI Approved :: Apache Software License", "Operating System :: OS Independent", ], python_requires=">=3.11", install_requires=[ "aiohttp", "xmltodict", "python-socketio-v4", "websocket-client", ], ) lawtancool-pyControl4-5334f51/tests/000077500000000000000000000000001514675053300173575ustar00rootroot00000000000000lawtancool-pyControl4-5334f51/tests/__init__.py000066400000000000000000000000001514675053300214560ustar00rootroot00000000000000lawtancool-pyControl4-5334f51/tests/conftest.py000066400000000000000000000003261514675053300215570ustar00rootroot00000000000000import pytest from pyControl4.director import C4Director @pytest.fixture def director(): """Create a C4Director with no real session (for mocked tests).""" return C4Director("192.168.1.1", "test-token") lawtancool-pyControl4-5334f51/tests/test_director.py000066400000000000000000000124301514675053300226030ustar00rootroot00000000000000"""Tests for C4Director — get_item_variable_value, get_all_item_variable_value, and basic request wrappers. """ import json from unittest.mock import AsyncMock, patch import pytest @pytest.mark.asyncio async def test_get_item_variable_value_int(director): """Normal integer value is returned as-is.""" response = json.dumps([{"id": 100, "varName": "LIGHT_LEVEL", "value": 75}]) with patch.object( director, "send_get_request", new=AsyncMock(return_value=response) ): result = await director.get_item_variable_value(100, "LIGHT_LEVEL") assert result == 75 @pytest.mark.asyncio async def test_get_item_variable_value_zero(director): """Zero is returned as 0, not confused with None or falsy.""" response = json.dumps([{"id": 100, "varName": "LIGHT_LEVEL", "value": 0}]) with patch.object( director, "send_get_request", new=AsyncMock(return_value=response) ): result = await director.get_item_variable_value(100, "LIGHT_LEVEL") assert result == 0 assert result is not None @pytest.mark.asyncio async def test_get_item_variable_value_bool(director): """Boolean value is returned as a Python bool.""" response = json.dumps([{"id": 100, "varName": "IS_ON", "value": True}]) with patch.object( director, "send_get_request", new=AsyncMock(return_value=response) ): result = await director.get_item_variable_value(100, "IS_ON") assert result is True @pytest.mark.asyncio async def test_get_item_variable_value_string(director): """String value is returned as-is.""" response = json.dumps( [{"id": 100, "varName": "PARTITION_STATE", "value": "ARMED_AWAY"}] ) with patch.object( director, "send_get_request", new=AsyncMock(return_value=response) ): result = await director.get_item_variable_value(100, "PARTITION_STATE") assert result == "ARMED_AWAY" @pytest.mark.asyncio async def test_get_item_variable_value_null(director): """JSON null value passes through as None (distinct from 'Undefined').""" response = json.dumps([{"id": 100, "varName": "OPTIONAL_VAR", "value": None}]) with patch.object( director, "send_get_request", new=AsyncMock(return_value=response) ): result = await director.get_item_variable_value(100, "OPTIONAL_VAR") assert result is None @pytest.mark.asyncio async def test_get_item_variable_value_empty_response(director): """Empty list response raises ValueError.""" with patch.object(director, "send_get_request", new=AsyncMock(return_value="[]")): with pytest.raises(ValueError): await director.get_item_variable_value(100, "NONEXISTENT") @pytest.mark.asyncio async def test_get_item_variable_value_invalid_format(director): """Non-list JSON response raises ValueError (2.0 guard).""" with patch.object( director, "send_get_request", new=AsyncMock(return_value='{"value": 1}') ): with pytest.raises(ValueError): await director.get_item_variable_value(100, "TEST") @pytest.mark.asyncio async def test_get_item_variable_value_list_var_name(director): """List of var_names is joined with comma in the request URI.""" response = json.dumps([{"id": 100, "varName": "A", "value": 1}]) mock = AsyncMock(return_value=response) with patch.object(director, "send_get_request", new=mock): await director.get_item_variable_value(100, ["A", "B"]) uri = mock.call_args[0][0] assert "varnames=A,B" in uri @pytest.mark.asyncio async def test_get_item_variable_value_tuple_var_name(director): """Tuple of var_names is joined with comma in the request URI.""" response = json.dumps([{"id": 100, "varName": "X", "value": 42}]) mock = AsyncMock(return_value=response) with patch.object(director, "send_get_request", new=mock): await director.get_item_variable_value(100, ("X", "Y")) uri = mock.call_args[0][0] assert "varnames=X,Y" in uri @pytest.mark.asyncio async def test_get_all_item_variable_value_mixed(director): """get_all_item_variable_value normalizes Undefined values in-place.""" response = json.dumps( [ {"id": 1, "varName": "HUMIDITY", "value": "Undefined"}, {"id": 2, "varName": "HUMIDITY", "value": 45}, {"id": 3, "varName": "HUMIDITY", "value": 0}, ] ) with patch.object( director, "send_get_request", new=AsyncMock(return_value=response) ): result = await director.get_all_item_variable_value("HUMIDITY") assert result[0]["value"] is None assert result[1]["value"] == 45 assert result[2]["value"] == 0 @pytest.mark.asyncio async def test_get_all_item_variable_value_empty(director): """Empty list response raises ValueError.""" with patch.object(director, "send_get_request", new=AsyncMock(return_value="[]")): with pytest.raises(ValueError): await director.get_all_item_variable_value("NONEXISTENT") @pytest.mark.asyncio async def test_get_all_item_info_returns_parsed(director): """get_all_item_info returns parsed list (2.0 returns parsed JSON).""" raw = '[{"id": 1, "name": "Light"}]' with patch.object(director, "send_get_request", new=AsyncMock(return_value=raw)): result = await director.get_all_item_info() assert isinstance(result, list) assert result[0]["id"] == 1 lawtancool-pyControl4-5334f51/tests/test_error_handling.py000066400000000000000000000126651514675053300237770ustar00rootroot00000000000000"""Tests for error_handling.py — all branches of check_response_for_error.""" import json import pytest from pyControl4.error_handling import ( BadCredentials, BadToken, C4Exception, InvalidCategory, NotFound, Unauthorized, check_response_for_error, ) # --- Happy paths (no exception raised) --- def test_json_no_error_keys(): """JSON response without error keys should not raise.""" check_response_for_error(json.dumps({"result": "ok"})) def test_xml_no_error_keys(): """XML response without error keys should not raise.""" check_response_for_error("ok") # --- C4ErrorResponse format --- def test_c4error_bad_credentials(): """C4ErrorResponse with matching details raises BadCredentials.""" payload = json.dumps( { "C4ErrorResponse": { "code": 401, "details": "Permission denied Bad credentials", "message": "Permission denied", } } ) with pytest.raises(BadCredentials): check_response_for_error(payload) def test_c4error_401_no_matching_details(): """C4ErrorResponse with code 401 but non-matching details raises Unauthorized.""" payload = json.dumps( { "C4ErrorResponse": { "code": 401, "details": "", "message": "Permission denied", } } ) with pytest.raises(Unauthorized): check_response_for_error(payload) def test_c4error_404(): """C4ErrorResponse with code 404 raises NotFound.""" payload = json.dumps( { "C4ErrorResponse": { "code": 404, "message": "Not found", } } ) with pytest.raises(NotFound): check_response_for_error(payload) def test_c4error_unknown_code_falls_back_to_base(): """C4ErrorResponse with unrecognized code raises exactly C4Exception (not a subclass).""" payload = json.dumps( { "C4ErrorResponse": { "code": 999, "message": "Unknown error", } } ) with pytest.raises(C4Exception) as exc_info: check_response_for_error(payload) assert type(exc_info.value) is C4Exception # --- Flat JSON code format --- def test_flat_json_404(): """Flat JSON with code 404 raises NotFound.""" payload = json.dumps({"code": 404, "message": "Account not found"}) with pytest.raises(NotFound): check_response_for_error(payload) def test_flat_json_bad_credentials(): """Flat JSON with matching details raises BadCredentials (details take priority).""" payload = json.dumps( { "code": 401, "details": "Permission denied Bad credentials", "message": "Permission denied", } ) with pytest.raises(BadCredentials): check_response_for_error(payload) # --- Director error format --- def test_director_error_bad_token(): """Director error with matching details raises BadToken.""" payload = json.dumps( {"error": "Unauthorized", "details": "Expired or invalid token"} ) with pytest.raises(BadToken): check_response_for_error(payload) def test_director_error_unauthorized_no_matching_details(): """Director 'Unauthorized' without matching details raises Unauthorized.""" payload = json.dumps({"error": "Unauthorized"}) with pytest.raises(Unauthorized): check_response_for_error(payload) def test_director_error_invalid_category(): """Director 'Invalid category' raises InvalidCategory.""" payload = json.dumps({"error": "Invalid category"}) with pytest.raises(InvalidCategory): check_response_for_error(payload) def test_director_error_unknown_falls_back_to_base(): """Director error with unrecognized string raises exactly C4Exception (not a subclass).""" payload = json.dumps({"error": "Something else"}) with pytest.raises(C4Exception) as exc_info: check_response_for_error(payload) assert type(exc_info.value) is C4Exception # --- XML C4ErrorResponse --- def test_xml_c4error_401(): """XML C4ErrorResponse with code 401 raises Unauthorized.""" xml = ( '' "" "401" "
" "Permission denied" "0" "
" ) with pytest.raises(Unauthorized): check_response_for_error(xml) # --- Exception hierarchy and behavior --- def test_exception_hierarchy(): """Verify the exception inheritance chain.""" assert issubclass(BadCredentials, Unauthorized) assert issubclass(BadToken, Unauthorized) assert issubclass(Unauthorized, C4Exception) assert issubclass(NotFound, C4Exception) assert issubclass(InvalidCategory, C4Exception) assert issubclass(C4Exception, Exception) def test_exception_stores_message(): """C4Exception stores the response text as .message.""" exc = C4Exception("some response text") assert exc.message == "some response text" def test_raised_exception_preserves_response_text(): """Exception raised by check_response_for_error carries the original response.""" payload = json.dumps({"error": "Invalid category"}) with pytest.raises(InvalidCategory) as exc_info: check_response_for_error(payload) assert exc_info.value.message == payload lawtancool-pyControl4-5334f51/tests/test_undefined_handling.py000066400000000000000000000044041514675053300245770ustar00rootroot00000000000000"""Tests for handling of 'Undefined' variable values from the Control4 Director.""" import json from unittest.mock import AsyncMock, patch import pytest from pyControl4.light import C4Light from pyControl4.blind import C4Blind @pytest.mark.asyncio async def test_get_item_variable_value_undefined(director): """Test that get_item_variable_value normalizes 'Undefined' to None.""" response = json.dumps([{"id": 123, "varName": "HUMIDITY", "value": "Undefined"}]) with patch.object( director, "send_get_request", new=AsyncMock(return_value=response) ): result = await director.get_item_variable_value(123, "HUMIDITY") assert result is None @pytest.mark.asyncio async def test_get_all_item_variable_value_undefined(director): """Test that get_all_item_variable_value normalizes 'Undefined' to None in items.""" response = json.dumps( [ {"id": 100, "varName": "HUMIDITY", "value": "Undefined"}, {"id": 100, "varName": "TEMPERATURE_F", "value": 72.5}, {"id": 200, "varName": "HUMIDITY", "value": 45}, ] ) with patch.object( director, "send_get_request", new=AsyncMock(return_value=response) ): result = await director.get_all_item_variable_value("HUMIDITY,TEMPERATURE_F") assert result[0]["value"] is None assert result[1]["value"] == 72.5 assert result[2]["value"] == 45 @pytest.mark.asyncio async def test_light_get_level_undefined(director): """Test that int callers propagate None instead of crashing.""" light = C4Light(director, 100) response = json.dumps([{"id": 100, "varName": "LIGHT_LEVEL", "value": "Undefined"}]) with patch.object( director, "send_get_request", new=AsyncMock(return_value=response) ): result = await light.get_level() assert result is None @pytest.mark.asyncio async def test_blind_get_fully_open_undefined(director): """Test that bool callers propagate None instead of a misleading value.""" blind = C4Blind(director, 200) response = json.dumps([{"id": 200, "varName": "Fully Open", "value": "Undefined"}]) with patch.object( director, "send_get_request", new=AsyncMock(return_value=response) ): result = await blind.get_fully_open() assert result is None lawtancool-pyControl4-5334f51/tests/test_websocket_ssl.py000066400000000000000000000036371514675053300236500ustar00rootroot00000000000000"""Tests for SSL context passthrough in C4Websocket.""" from unittest.mock import AsyncMock, MagicMock, patch import aiohttp import socketio_v4 import pytest from pyControl4.websocket import C4Websocket @pytest.mark.asyncio async def test_sio_connect_without_session(): """Test that sio_connect uses ssl_verify=False without http_session when no session is provided.""" ws = C4Websocket("192.168.1.1") with patch.object( socketio_v4.AsyncClient, "__init__", return_value=None ) as mock_init, patch.object( socketio_v4.AsyncClient, "register_namespace" ), patch.object( socketio_v4.AsyncClient, "connect", new_callable=AsyncMock ): await ws.sio_connect("test-token") mock_init.assert_called_once_with(ssl_verify=False) @pytest.mark.asyncio async def test_sio_connect_with_session(): """Test that sio_connect creates a new session sharing the caller's connector and passes it as http_session, so engineio can safely close it without affecting the caller's session.""" mock_connector = MagicMock() mock_session = MagicMock() mock_session.connector = mock_connector ws = C4Websocket("192.168.1.1", session_no_verify_ssl=mock_session) with patch.object( socketio_v4.AsyncClient, "__init__", return_value=None ) as mock_init, patch.object( socketio_v4.AsyncClient, "register_namespace" ), patch.object( socketio_v4.AsyncClient, "connect", new_callable=AsyncMock ), patch.object( aiohttp, "ClientSession" ) as mock_session_cls: mock_http_session = MagicMock() mock_session_cls.return_value = mock_http_session await ws.sio_connect("test-token") mock_session_cls.assert_called_once_with( connector=mock_connector, connector_owner=False ) mock_init.assert_called_once_with( ssl_verify=False, http_session=mock_http_session )